consult-second-brain

Posted on Jul 17, 2024

consult’s ability to provide multiple sources in a completing-read interface gives me a way to unify access and creation of org-roam nodes. I’ve found this setup to minimize mental cache misses while benefiting from the easy-to-reach function consult-buffer.

Okay, it’s cliche to call it a “second brain”, but this label evokes an expectation that your org-roam nodes should be as easy to access and modify as the thoughts you have in your head. On a freshly set-up instance of org-roam, access of nodes probably looks like this:

  1. Call org-roam-node-find
  2. Type in name of node (possibly with completing-read)
  3. Select node

Unfortunately, this isn’t good enough for me. Don’t ask me why, I actually can’t really put my finger on it. I’ve tried binding it to a short keybinding, but it still feels wrong to me. For notes that are intended to be atomic and easily-reachable, having to call specific functions to create and refer to them doesn’t cut it. If it were up to me, I would list my org-roam notes under a command I use that answers my internal monologue when it asks wait, what was this again?.

Fortunately, the emacs cake may be had and eaten too. I’d bet one of my most used commands is consult-buffer, which is incidentally what I use to satisfy the aforementioned what is this? question. After all, if everything is a buffer to emacs, why shouldn’t my bite-sized notes be buffers?

This makes consult-buffer a natural place to put all my org-roam notes under. This can be accomplished thanks to consult’s ability to read from multiple sources and provide the results of all sources as completion candidates. In fact, this is the default behavior of consult-buffer. The variable consult-buffer-sources includes a list of all sources it pulls from for completion candidates. All we have to do is add our own.

The Code

Following the plethora of examples on the consult wiki, the following code does just that.

(defvar org-roam-nodes-source
         (list :name     "org-roam node"
               :category 'org-heading
               :face 'org-roam-title
               :narrow   ?n
               :require-match nil
               :action (lambda (cand)
                         ;; strip off nerd icon
                         (let ((node-name (substring-no-properties cand 2)))
                           (progn
                             (org-roam-node-open (org-roam-node-from-title-or-alias
                                                  node-name t))
                             (when (org-at-heading-p)
                               (org-fold-show-entry t)
                               (recenter-top-bottom 0))))

               :new (lambda (name)
                      (let ((info nil))
                        (setq info (plist-put info 'title name))
                        (org-roam-capture-  :goto nil
                                            :keys "h"
                                            :node (org-roam-node-create :title name)
                                            :templates org-roam-capture-templates
                                            :info info
                                            :props info)))
               :items (lambda ()
                        (mapcar
                         (lambda (str)
                           ;; requires nerd icons
                           (concat (nerd-icons-faicon "nf-fae-brain") " " str))
                         (org-roam--get-titles)))))


(add-to-list 'consult-buffer-sources 'org-roam-nodes-source 'append)

Now, access to my buffers and org-roam nodes becomes a cinch. I’ve also specified the ability to create new nodes if consult finds no match. Note that I use top level headlines for my nodes, unlike the more popular one-file-per-node.

What just happened?

First demo:

  • Call consult-buffer
  • Narrow to org-roam nodes by typing n SPC
  • Select completion candidate

Second demo:

  • Call consult-buffer
  • Select candidate with no match
  • org-roam capture as usual