;;; config.el -*- lexical-binding: t; -*-
;;;
;;;
;;; BEGIN_Interface_Tweaks
;;;
;;;
(add-to-list 'initial-frame-alist '(fullscreen . fullboth))
(push '(fullscreen . fullboth) default-frame-alist)
;; Banner made with "convert image.png -resize 600 -quality 75 OUTPUT.png"
;; (-resize 400 for 1080p).
(setopt fancy-splash-image (expand-file-name "banner/trancendent-gnu.png" doom-user-dir))
;; Doom applies these in `doom-init-fonts-h': on `after-init-hook' normally, or
;; on `server-after-make-frame-hook' under a daemon, where no frame -- and so no
;; font -- exists until the first client connects. Each accepts a font-spec, a
;; font string ("Input Mono-12"), or an XLFD string.
;; + `doom-font' default and fixed-pitch faces
;; + `doom-variable-pitch-font' variable-pitch face
;; + `doom-serif-font' fixed-pitch-serif face
;; + `doom-big-font' `doom-big-font-mode', for presentations
;; + `doom-symbol-font' the `symbol' and `mathematical' scripts
;; + `doom-emoji-font' the `emoji' script
;; The last two fall back to `doom-symbol-fallback-font-families' and
;; `doom-emoji-fallback-font-families' when left unset.
(setopt doom-theme 'doom-dracula)
(setopt doom-font (font-spec :family "FiraCode Nerd Font Mono" :size 13)
doom-variable-pitch-font (font-spec :family "Fira Sans" :size 13)
doom-big-font (font-spec :family "FiraCode Nerd Font Mono" :size 19)
doom-serif-font (font-spec :family "Source Serif 4" :size 13)
;; No :size -- colour emoji scale from the default face height.
doom-emoji-font (font-spec :family "Noto Color Emoji"))
;; `doom-symbol-font' deliberately NOT set, `doom-emoji-font' deliberately IS.
;; `doom-init-fonts-h' applies the face fonts first and the fontsets afterwards,
;; and a frame resolves glyphs through the fontset derived when its faces were
;; set -- so on a daemon's first frame the emoji entry lands too late and emoji
;; fall through to Symbola in monochrome. Measured on U+1F600: Font Awesome 7
;; Free with the symbol font set, Symbola with it merely removed, Noto Color
;; Emoji only once the faces are re-applied over the finished fontset. One
;; re-application suffices; it removes itself because `set-fontset-font' should
;; not run per frame (Doom notes superfluous calls can segfault).
(defun bmg/reload-fonts-once (&rest _)
"Re-apply fonts once a graphical frame exists, then stand down."
(when (display-graphic-p)
(remove-hook 'server-after-make-frame-hook #'bmg/reload-fonts-once)
(remove-hook 'after-init-hook #'bmg/reload-fonts-once)
(doom/reload-font)))
(add-hook 'server-after-make-frame-hook #'bmg/reload-fonts-once 90)
(add-hook 'after-init-hook #'bmg/reload-fonts-once 90)
(with-eval-after-load 'doom-themes
(setopt doom-themes-enable-bold t
doom-themes-enable-italic t))
;; doom-dracula paints comments in `base5' (#6272a4): 3.03:1 on the #282a36
;; background, below WCAG AA (4.5:1) and barely over the 3:1 large-text floor.
(custom-set-faces!
'(font-lock-comment-face :foreground "#b8c0dc") ; 7.87:1 on #282a36
'(font-lock-comment-delimiter-face :foreground "#b8c0dc") ; 7.87:1 on #282a36
;; Doom sets `avy-background' t, so a jump repaints every visible character
;; in this face; dracula leaves it at base5 (#6272a4), 3.03:1. Dim enough
;; against 13.36:1 body text to mark the mode, still over WCAG AA.
'(avy-background-face :foreground "#9aa2c4")) ; 5.65:1 on #282a36
;; Magit diff legibility. `diff-refine-added' and `diff-refine-removed' ship
;; with `:inverse-video t', so foreground and background swap at render time:
;; doom-dracula defines refine-added as dark #1E2029 bg with #50fa7b text, but
;; it *paints* as a solid #50fa7b block with near-black glyphs.
;; `magit-diff-refine-hunk' is t, so this hits the hunk at point only.
;;
;; Dracula's removed faces also fail WCAG on their own, before any inversion:
;; magit-diff-removed #cc4444 on #564859 = 1.82:1
;; magit-diff-removed-highlight #ff5555 on #694959 = 2.48:1
;; diff-refine-removed (as rendered) #44475a on #ff5555 = 2.91:1
;; `custom-set-faces!' re-applies on `doom-load-theme-hook', so these survive a
;; theme reload -- but they ARE dracula-specific, so revisit on a theme change.
(custom-set-faces!
;; Word-level refinement gets NO background of its own -- a changed word is
;; just a brighter, bolder version of the line's own colour. Bold carries
;; most of the signal, so the ~1.12x luminance step only has to catch the
;; eye, not shout -- keep the hue, lift it slightly. `:inherit nil'
;; is the load-bearing part: `:background unspecified' alone does NOT mean "no
;; background", it means "not set here", so the attribute falls through to
;; `:inherit', and doom-dracula points these faces at `diff-added'/
;; `diff-removed', whose #1E2029 and #44475a backgrounds were the box behind
;; each changed word. `:inverse-video nil' overrides upstream
;; `diff-refine-changed', which sets it; `:underline nil' is explicit rather
;; than omitted so that re-evaluating this block in a running session clears a
;; previously applied underline.
'(diff-refine-added
:inherit nil :inverse-video nil :background unspecified
:foreground "#96f5ba" :weight bold :underline nil) ; 1.12x line fg, 10.50:1 on #17331f
'(diff-refine-removed
:inherit nil :inverse-video nil :background unspecified
:foreground "#fab0b0" :weight bold :underline nil) ; 1.13x line fg, 8.02:1 on #42212b
;; smerge's refine faces inherit the same way, in conflict resolution.
'(smerge-refined-added
:inherit nil :inverse-video nil :background unspecified
:foreground "#96f5ba" :weight bold :underline nil)
'(smerge-refined-removed
:inherit nil :inverse-video nil :background unspecified
:foreground "#fab0b0" :weight bold :underline nil)
;; Line-level faces. `magit-diff-highlight-hunk-body' nil (below) leaves the
;; "-highlight" variants unused, but they stay correct in case it flips back.
'(magit-diff-added :background "#17331f" :foreground "#7aeda4") ; 9.46:1
'(magit-diff-added-highlight :background "#1f5233" :foreground "#93ffb9") ; 7.46:1
'(magit-diff-removed :background "#42212b" :foreground "#f5a3a3") ; 7.18:1
'(magit-diff-removed-highlight :background "#5c2836" :foreground "#ffbcbc")) ; 7.27:1
;; Show hunk focus with the hunk *heading* alone, not by repainting the body:
;; by default Magit swaps every line of the hunk at point to the brighter
;; "-highlight" faces. `magit-section-paint' clears the highlight flag only
;; after `forward-line', so the heading is painted separately and keeps
;; `magit-diff-hunk-heading-highlight' (the purple bar) regardless.
(with-eval-after-load 'magit-diff
(setopt magit-diff-highlight-hunk-body nil))
;; Break a gnus face inheritance cycle: gnus-group-news-low-empty inherits from
;; gnus-group-news-low, which inherits from gnus-group-news-low-empty.
(with-eval-after-load 'gnus
(set-face-attribute 'gnus-group-news-low nil
:weight 'normal
:foreground "#565761") ; doom-dracula base4, 1.99:1 on #282a36
(set-face-attribute 'gnus-group-news-low-empty nil
:weight 'normal
;; Not a doom-dracula palette colour: sits between base2
;; (#373844) and base3 (#44475a), a step dimmer than the
;; non-empty face above.
:foreground "#3d3f4c")) ; 1.37:1 on #282a36
(with-eval-after-load 'org-modern
(set-face-attribute 'org-modern-symbol nil :family "Font Awesome 7 Free")
(set-face-attribute 'org-modern-label nil :height 1.0)
(set-face-attribute 'org-modern-block-name nil :height 1.0))
;; be as colorful as possible.
(setopt treesit-font-lock-level 4)
(setopt display-line-numbers-type t)
;; Blinking cursors are annoying
(blink-cursor-mode -1)
(setq +dashboard-menu-sections
'(("Open org-agenda"
:icon (nerd-icons-octicon "nf-oct-calendar" :face '+dashboard-menu-title)
:action bmg/switch-to-agenda)
("Recently opened files"
:icon (nerd-icons-faicon "nf-fa-file_text" :face '+dashboard-menu-title)
:action recentf-open-files)
("Reload last session"
:icon (nerd-icons-octicon "nf-oct-history" :face '+dashboard-menu-title)
:when (cond ((modulep! :ui workspaces)
(file-exists-p (expand-file-name persp-auto-save-fname persp-save-dir)))
((require 'desktop nil t)
(file-exists-p (desktop-full-file-name))))
:action doom/quickload-session)
("Open project"
:icon (nerd-icons-octicon "nf-oct-briefcase" :face '+dashboard-menu-title)
:action projectile-switch-project)
("Open private configuration"
:icon (nerd-icons-octicon "nf-oct-tools" :face '+dashboard-menu-title)
:when (file-directory-p doom-user-dir)
:action doom/open-private-config)
("Open documentation"
:icon (nerd-icons-octicon "nf-oct-book" :face '+dashboard-menu-title)
:action doom/help)
("RSS"
:icon (nerd-icons-octicon "nf-oct-rss" :face '+dashboard-menu-title)
:action =rss)
))
;;;
;;;
;;; END_Interface_Tweaks
;;;
;;;
;;;
;;;
;;; BEGIN_General
;;;
;;;
;; Used by GPG, email clients, file templates and snippets.
(setopt user-full-name "Brian McGillion"
user-mail-address "brian@ssrc.tii.ae"
which-key-idle-delay 0.5
auth-sources '("~/.authinfo.gpg" "~/.netrc")
;; M-x projectile-discover-projects-in-directory
;; M-x projectile-discover-projects-in-search-path
projectile-project-search-path '(("~/.dotfiles" . 1)("~/projects" . 6)("~/.config" . 2)("~/Documents/org" . 2))
projectile-auto-discover nil)
;; Project discovery walks *every* subdirectory until DEPTH runs out, with no
;; ignore list, and `file-directory-p' follows symlinks -- so
;; `.direnv/flake-inputs/<hash>-source' drags the scan into /nix/store and it
;; starts indexing nixpkgs. Upstream also only tests for a project at *exactly*
;; DEPTH levels down, never on the way, so shallow projects under a deep search
;; path are missed. Both are fixed here: prune the noise directories, and
;; register a directory as soon as it is itself a project root instead of
;; descending through it.
(defvar bmg/projectile-discovery-ignored-directories
'(".direnv" ".devenv" ".git" ".jj" ".hg" ".svn" ".cache" ".venv"
"node_modules" "result")
"Directory names `projectile' project discovery must never descend into.")
(defun bmg/projectile-project-root-p (directory)
"Return non-nil if DIRECTORY is itself the root of a project."
(let ((root (ignore-errors (projectile-project-root directory))))
(and root
(equal (file-truename (file-name-as-directory directory))
(file-truename root)))))
(with-eval-after-load 'projectile
;; Also keep them out of in-project file listings. A leading `*' means
;; "ignore anywhere", not just at the project top level.
(dolist (dir '("*.direnv" "*.devenv"))
(cl-pushnew dir projectile-globally-ignored-directories :test #'equal))
(define-advice projectile-discover-projects-in-directory
(:around (fn directory &optional depth) bmg/prune-discovery)
"Skip noise directories, and stop descending at project roots."
(unless (member (file-name-nondirectory (directory-file-name directory))
bmg/projectile-discovery-ignored-directories)
(if (and (> (or depth 1) 0)
(file-directory-p directory)
(bmg/projectile-project-root-p directory))
;; DIRECTORY is a project root: record it, don't walk into it.
(funcall fn directory 0)
(funcall fn directory depth)))))
(add-hook 'text-mode-hook #'visual-line-mode)
(global-auto-revert-mode t)
(with-eval-after-load 'dirvish
(dirvish-side-follow-mode t)
(setopt dirvish-hide-details '(dirvish dirvish-side))
;; The side panel doesn't always reach `dirvish--build-layout', so
;; `dired-hide-details-mode' never gets toggled on.
(add-hook 'dirvish-find-entry-hook
(defun bmg/dirvish-side-hide-details (_path find-fn)
(when (and (eq find-fn 'dired)
(when-let* ((dv (dirvish-curr)))
(eq (dv-type dv) 'side)))
(dired-hide-details-mode 1))
nil)))
;; Don't reroot the side panel into the roam tree: roam/projects/<project>/
;; shares a basename with the code checkout, and dirvish renames side buffers
;; non-uniquely (basename + second), so rerooting twice in a second errors.
(with-eval-after-load 'dirvish-side
(advice-add 'dirvish-side--auto-jump :before-while
(defun bmg/dirvish-side-skip-org-roam ()
(not (and buffer-file-name
(bound-and-true-p org-roam-directory)
(file-in-directory-p buffer-file-name org-roam-directory))))))
;; Doom's :checkers spell is disabled in init.el; jinx replaces it. jinx links
;; libenchant, which reads the same aspell dictionaries installed from
;; ~/.dotfiles. The package comes from emacsWithPackages, so it needs no
;; `package!' declaration -- it is already on `load-path'.
(use-package jinx
:hook (doom-first-buffer . global-jinx-mode)
:config
;; enchant checks both at once: en_GB primary, US spellings accepted.
(setopt jinx-languages "en_GB en_US")
;; The default list covers neither nix nor elisp. t splits camelCase in every
;; mode, which is what lets `callPackage' and `mkDefault' pass.
(setopt jinx-camel-modes t)
;; Flake refs (github:owner/repo) sit in strings and commented-out code, which
;; jinx checks as prose. Skip the whole token. `jinx--mode-list' appends to
;; the `t' defaults, so jinx's own exclusions still apply.
(add-to-list 'jinx-exclude-regexps
'(nix-ts-mode "[a-z][a-z0-9+.-]*:[^\"[:space:]]*/[^\"[:space:]]*")))
;; ispell is still reachable via M-x ispell-buffer; M-$ goes to jinx below.
(setopt ispell-dictionary "en_GB")
;; NixOS has no /usr/share/dict/words. aspell still spell-checks; this only
;; affects M-TAB word completion.
(setopt text-mode-ispell-word-completion nil)
(set-language-environment-charset "UTF-8")
;; Use the Languagetool only in server mode
(setopt langtool-http-server-host "localhost"
langtool-http-server-port 8081
langtool-default-language nil)
;; harper-ls grammar-checks comment text as you type; LanguageTool stays manual
;; (C-c g c) for prose. harper has no parser for elisp, yaml or json -- it
;; falls back to linting those files as plain English -- so activation is an
;; explicit whitelist rather than all of prog-mode.
(defvar bmg/harper-modes
'(nix-ts-mode
c-mode c-ts-mode c++-mode c++-ts-mode
rust-mode rust-ts-mode
python-mode python-ts-mode
sh-mode bash-ts-mode
js-mode js-ts-mode typescript-ts-mode tsx-ts-mode
go-mode go-ts-mode
toml-ts-mode)
"Major modes where harper-ls has a real parser and lints only comments.")
(with-eval-after-load 'lsp-mode
;; jinx owns spelling. harper's SpellCheck duplicates it on real typos and
;; regresses it otherwise -- no camelCase splitting (`mkDefault' -> "did you
;; mean default?") and a separate dictionary that lacks the project jargon.
;; The rest are prose rules that misfire on code comments: TODO is a code
;; convention not "to-do"; commented-out code trips the capitalisation rules
;; (github:, url =, lsp, cli); SPDX year ranges trip the en-dash rule.
(lsp-register-custom-settings
'(("harper-ls.linters.SpellCheck" nil t)
("harper-ls.linters.ToDoHyphen" nil t)
("harper-ls.linters.OrthographicConsistency" nil t)
("harper-ls.linters.SentenceCapitalization" nil t)
("harper-ls.linters.NumericRangeEnDash" nil t)))
;; :add-on? runs harper-ls beside the primary server (nixd, clangd, ...)
;; instead of competing with it for the buffer.
(lsp-register-client
(make-lsp-client
:new-connection (lsp-stdio-connection '("harper-ls" "--stdio"))
:activation-fn (lambda (_filename mode) (memq mode bmg/harper-modes))
:add-on? t
:priority -1
:server-id 'harper-ls
:initialized-fn (lambda (workspace)
(with-lsp-workspace workspace
(lsp--set-configuration
(lsp-configuration-section "harper-ls")))))))
(with-eval-after-load 'license-snippets
(license-snippets-init))
(load! "elfeed-config.el")
;; misc selection of useful functions
(use-package crux
:defer t)
;; https://github.com/NinjaTrappeur/my-repo-pins -- clone a repo, or jump to it
;; under `my-repo-pins-code-root'.
(use-package my-repo-pins
:commands (my-repo-pins)
:init
(setopt my-repo-pins-code-root "~/projects/code"))
(use-package inheritenv
:config
;; Carry the environment into called subshells; tested against go-mode's
;; go-import-add.
(inheritenv-add-advice #'process-lines)
(inheritenv-add-advice #'shell-command-to-string))
(with-eval-after-load 'consult
;; Live preview without pressing C-SPC every time -- overriding the override
;; in vertico/config.el.
(consult-customize
+default/search-project +default/search-other-project
+default/search-project-for-symbol-at-point
+default/search-cwd +default/search-other-cwd
+default/search-notes-for-symbol-at-point
+default/search-emacsd
consult-ripgrep consult-git-grep consult-grep
consult-bookmark consult-recent-file
consult-source-recent-file consult-source-project-recent-file consult-source-bookmark
;; Listed in their own right: `consult-org-roam-search' *calls*
;; `consult-ripgrep', but consult resolves this against `this-command', so
;; the entry above never applies and it falls back to `consult-preview-key'.
consult-org-roam-search consult-org-roam-file-find
consult-org-roam-backlinks consult-org-roam-forward-links
:preview-key (list "C-SPC" :debounce 0.1 'any))
;; Same live preview as above, except on documents. rga reports line numbers
;; of the *extracted text*, not positions Emacs can visit, so auto-preview
;; spawns an epdfinfo render to land somewhere meaningless.
;;
;; (:keys ... :predicate ...) is consult's per-candidate form: consult.el:2814
;; registers the keymap, consult.el:1733 calls :predicate per candidate.
;; `consult--prefix-group' is the file name set by `consult--grep-format';
;; defaulting to "" previews unknown candidates, the safe direction.
(consult-customize
bmg/search-knowledge-base bmg/rga-search
:preview-key
(list :keys (list "C-SPC" :debounce 0.1 'any)
:predicate
(lambda (cand)
;; Every format an rga adapter extracts, not just documents --
;; all are binaries Emacs cannot preview by opening at a line.
;; `case-fold-search' bound so `.PDF' matches by design rather
;; than by whatever the calling buffer happened to have.
(if (let ((case-fold-search t))
(string-match-p
"\\.\\(pdf\\|epub\\|docx\\|odt\\|fb2\\|ipynb\\|rtf\\|zip\\|tar\\|gz\\|bz2\\|xz\\|zst\\|sqlite3?\\|db\\|mbox\\|mkv\\|mp4\\|avi\\|mov\\|webm\\)\\'"
(or (get-text-property 0 'consult--prefix-group cand) "")))
"C-SPC"
(list "C-SPC" :debounce 0.1 'any))))))
;; Persistent directory, so a doom sync cannot remove the binary from a running
;; daemon. 'download rather than 'ask: fetch the latest version unprompted.
(setopt ghostel-module-directory (file-name-concat doom-data-dir "ghostel/"))
(setopt ghostel-module-auto-install 'download)
;; Don't let a stray keystroke take the daemon down with every client on it.
;;
;; `C-x C-c' is NOT the risk it looks like: `save-buffers-kill-terminal' sees a
;; client frame, hands off to `server-save-buffers-kill-terminal', and with
;; `server-stop-automatically' nil that takes its `server-delete-client' branch
;; and never reaches `save-buffers-kill-emacs' -- only the client frame closes,
;; and `confirm-kill-emacs' is never consulted on that path at all. The two
;; paths that really do end the daemon:
;; SPC q K `save-buffers-kill-emacs' -- honours `confirm-kill-emacs'.
;; SPC q Q `kill-emacs' -- a bare (interactive "P") that consults nothing,
;; one key from SPC q q.
(defun bmg/confirm-kill-daemon (&optional prompt)
"Ask whether to kill Emacs when running as a daemon.
Returns non-nil to allow the kill; outside a daemon it always allows. PROMPT
is supplied by `confirm-kill-emacs'; `bmg/kill-emacs-confirm' passes none."
(or (not (daemonp))
(yes-or-no-p (or prompt "Really kill the Emacs daemon and every client? "))))
(setopt confirm-kill-emacs #'bmg/confirm-kill-daemon)
(defun bmg/kill-emacs-confirm ()
"Kill Emacs, confirming first when running as a daemon.
Doom binds SPC q Q straight to `kill-emacs'. Deliberately a command
rather than advice on `kill-emacs' itself: `save-buffers-kill-emacs'
calls `kill-emacs' after already prompting (which would ask twice), and
scripts drive `emacsclient -e \\='(kill-emacs)\\=' expecting no prompt at
all. Guarding the keybinding leaves both alone."
(interactive)
(when (bmg/confirm-kill-daemon)
(kill-emacs)))
;; Doom truncates compilation buffers from `compilation-filter-hook' via
;; `doom-comint-truncate-buffer-h', with `comint-buffer-maximum-size' set to
;; 2048 globally so a chatty process cannot grind the GC: right for a shell,
;; wrong for grep. Buffer-local here, so shells keep Doom's cap. Deliberately
;; not unlimited: the hook only fires above (* 80 comint-buffer-maximum-size)
;; bytes, so 100000 leaves ~8MB of headroom -- far beyond a full-corpus grep
;; (453K) while still bounding a runaway compile. `grep-mode' derives from
;; `compilation-mode', so one hook covers both.
(add-hook 'compilation-mode-hook
(defun bmg/compilation-keep-full-output-h ()
"Let compilation and grep buffers keep their whole result."
(setq-local comint-buffer-maximum-size 100000)))
;; Document search, shared by `bmg/consult-rga-args' below and by the retrieval
;; in llm-config.el. Defined here because that file is `load!'-ed from the LLM
;; section, which comes first.
;;
;; rga caches extracted document text in ~/.cache/ripgrep-all, but only for
;; blobs under `--rga-cache-max-blob-len', which defaults to 2MB *after zstd
;; compression*. Enough of the papers are text-dense to exceed that, and rga is
;; explicit that longer outputs "will not be cached and recomputed every time"
;; -- so every search re-extracted them: measured 4.76s per query over Papers,
;; cold or warm, against 0.85s once they cache. EPUBs were always under the
;; limit, which is why only the PDFs were slow.
;;
;; The ceiling governs cache *writes* only, so a generous value simply stops
;; anything being excluded, and costs nothing: the whole 4.7G corpus occupies
;; 91MB of cache. Terminal rga wants the same value in
;; ~/.config/ripgrep-all/config.jsonc as {"cache_max_blob_len": 536870912}.
(defconst bmg/rga-common-args
"--color=never --no-heading --with-filename --line-number --rga-cache-max-blob-len=512M"
"Flags shared by every `rga' invocation.
The file:line:text shape is parsed two ways: `bmg/consult-rga-args' adds
`--null' for `consult--grep-match-regexp', llm-config.el walks the plain
colon form with its own regexp. Neither is a `grep-mode' buffer.")
(defvar bmg/document-search-roots
(list "~/Documents/Papers/" "~/Documents/EPUB/")
"Source-document directories searched by the `rga' commands.
EPUB went missing for a long time, quietly excluding 121 books and 124
PDFs -- rga reads .epub natively, so this was only ever a path gap.")
(defconst bmg/consult-rga-args
(concat "rga --null --line-buffered --max-columns=1000 --max-columns-preview"
" --path-separator / --smart-case --max-count=5 "
bmg/rga-common-args)
"Command line for `rga', in the shape `consult-ripgrep-args' takes.
Derived from `bmg/rga-common-args' so the flags shared with llm-config.el
have one home and the consult-only delta stays visible. rga accepts every
flag stock `consult-ripgrep-args' passes to rg, `-P' included, so consult
selects PCRE and joins multi-word input with look-aheads.
The delta:
- `--null' -- `consult--grep-match-regexp' needs a NUL after the filename;
without it every candidate is silently dropped.
- `--max-columns-preview' -- otherwise rg replaces an over-long line with a
placeholder, and PDF paragraphs routinely pass 1000 characters.
- `--max-count=5' -- `the' over these roots is 1.8M matches uncapped and
9,858 capped, and consult holds every one in memory. Kept out of
`bmg/rga-common-args' so llm-config.el keeps its own `--max-count=3'.
See `bmg/search-knowledge-base' for how it interacts with the `#' filter.
- `--smart-case' rather than the old `-i'; narrows acronyms (`LLM' finds
2,194 against ~3,541), so append `-i' when that bites.
- `--search-zip' dropped; rga has its own archive adapters.
Appending a flag to a query overrides any of these: consult puts user
options after this list and rg takes the last occurrence.")
(defun bmg/rga--common-root (paths)
"Return the deepest directory containing every path in PATHS.
`consult--directory-prompt' resolves a path list against the *calling
buffer's* `default-directory' (consult.el:867-870), so binding it to this
keeps result names stable: the knowledge-base roots collapse to
~/Documents/ and a hit reads `org/roam/Foo.org:34:...'."
(let* ((seed (expand-file-name (car paths)))
(root (if (file-directory-p seed)
(file-name-as-directory seed)
(file-name-directory seed))))
(dolist (p (cdr paths) root)
(setq p (file-name-as-directory (expand-file-name p)))
(while (not (string-prefix-p root p))
(setq root (file-name-directory (directory-file-name root)))))))
(defun bmg/consult--rga-make-builder (paths)
"Return consult's ripgrep command builder, driven by `rga', over PATHS.
`consult--ripgrep-make-builder' reads `consult-ripgrep-args' once, when
the builder is constructed, so rebinding it around the call is enough."
(let ((consult-ripgrep-args bmg/consult-rga-args))
(consult--ripgrep-make-builder paths)))
(defun bmg/rga--pdf-page (cand)
"Return the PDF page number recorded in candidate CAND, or nil.
rga's poppler adapter prefixes each extracted line with `Page N:'; the line
number consult parses is an offset into the extracted text, not a position
Emacs can visit. Read off the `face' boundaries `consult--grep-format'
leaves, since a path may contain a colon."
(when cand
(when-let* ((file-end (next-single-property-change 0 'face cand))
((string-suffix-p ".pdf" (substring-no-properties cand 0 file-end) t))
(line-end (next-single-property-change (1+ file-end) 'face cand))
(content (substring-no-properties cand (1+ line-end)))
((string-match "\\`Page \\([0-9]+\\):" content)))
(string-to-number (match-string 1 content)))))
(defun bmg/consult--rga (prompt paths &optional initial)
"Run an asynchronous `rga' search over PATHS through consult.
PROMPT and INITIAL are as for `consult--grep'. PATHS is a list of root
strings, or a prefix-argument value, which makes `consult--directory-prompt'
ask which roots to search.
Timing delays only: Doom's 0.2/0.1 (vertico/config.el:129-132) assume a
local `rg', but one rga pass here is 1.39s. min-input stays at Doom's 2,
matching `consult-ripgrep'. `consult--grep' builds the pipeline
synchronously, so a `let' reaches these.
RET on a PDF lands on the matching page: `consult--grep' returns the
candidate after its `:state' has visited the file. RET only --
`embark-consult-goto-grep' still lands on page 1."
(unless (executable-find "rga")
(user-error "rga (ripgrep-all) not found in PATH"))
(require 'consult)
(let* ((roots (and (consp paths) (stringp (car paths)) paths))
(default-directory (if roots
(bmg/rga--common-root roots)
default-directory))
(consult-async-min-input 2)
(consult-async-input-debounce 0.4)
(consult-async-input-throttle 0.8)
(cand (consult--grep prompt #'bmg/consult--rga-make-builder
paths initial)))
(when-let* ((page (bmg/rga--pdf-page cand))
((derived-mode-p 'pdf-view-mode)))
(pdf-view-goto-page page))
cand))
;;;
;;;
;;; END_General
;;;
;;;;;;
;;;
;;; BEGIN_code
;;;
;;;
;; Add handlers for SELinux files and MDX (React/JSX markdown)
(add-to-list 'auto-mode-alist '("\\.te\\'" . m4-mode))
(add-to-list 'auto-mode-alist '("\\.mdx\\'" . gfm-mode)) ;; GitHub Flavored Markdown
;; Live markdown preview in an eww buffer, no xwidgets. `markdown-command'
;; defaults to a "markdown" binary that isn't installed; pandoc comes from
;; programs.pandoc.enable in ~/.dotfiles.
(with-eval-after-load 'markdown-mode
(setopt markdown-command "pandoc --from=gfm --to=html5 --standalone"))
(add-hook 'after-save-hook
'executable-make-buffer-file-executable-if-script-p)
(setopt c-default-style "linux") ;; set style to "linux"
(with-eval-after-load 'lsp-clangd
(setopt lsp-clients-clangd-args
'("-j=3"
"--background-index"
"--clang-tidy"
"--completion-style=detailed"
"--header-insertion=never"
"--header-insertion-decorators=0"))
(set-lsp-priority! 'clangd 2))
;; justl binds "e" to justl-exec-recipe itself; no extra map! needed.
;; `:commands' matters here: Doom leaves `use-package-always-defer' nil, so a
;; bare (use-package justl) loads the package at every startup.
(use-package justl
:commands (justl justl-exec-recipe-in-dir))
(defvar bmg/dotfiles-path (expand-file-name "~/.dotfiles")
"Path to dotfiles repository for Nix flake expressions.")
(with-eval-after-load 'lsp-mode
(setopt lsp-nix-nixd-server-path "nixd"
lsp-nix-nixd-formatting-command ["nixfmt"]
lsp-nix-nixd-nixpkgs-expr (format "import (builtins.getFlake \"%s\").inputs.nixpkgs { }" bmg/dotfiles-path)
lsp-nix-nixd-nixos-options-expr (format "(builtins.getFlake \"%s\").nixosConfigurations.arcadia.options" bmg/dotfiles-path)))
;; home-manager is integrated as a NixOS module here; a standalone config wants
;; lsp-nix-nixd-home-manager-options-expr (format "(builtins.getFlake \"%s\").homeConfigurations.\"user@host\".options" bmg/dotfiles-path)
;; NASM syntax, not gas -- .asm is ambiguous and this picks the dialect.
(use-package nasm-mode
:mode "\\.\\(nasm\\|asm\\)\\'")
;; x86 instruction lookup. Wants the combined "Intel 64 and IA-32 Architectures
;; Software Developer's Manual" PDF from
;; https://www.intel.com/content/www/us/en/developer/articles/technical/intel-sdm.html
(use-package x86-lookup
:commands x86-lookup
:config
;; setq, not setopt: the defcustom type is (file :must-match t) and the
;; SDM PDF is not always on disk, so setopt would warn at every startup.
(setq x86-lookup-pdf (expand-file-name "~/Documents/Papers/325462-sdm-vol-1-2abcd-3abcd.pdf")))
;; Doom installs pdf-tools through straight, and `pdf-info-epdfinfo-program'
;; defaults to that build directory. The Nix emacs ships a prebuilt epdfinfo;
;; `epdfinfo' in ~/.dotfiles puts it on PATH. `executable-find' rather than
;; a store path: the latter changes on every nixpkgs bump.
(with-eval-after-load 'pdf-tools
(when-let* ((server (executable-find "epdfinfo")))
(setopt pdf-info-epdfinfo-program server)))
;; ensure that worktrees are available in magit with %
(with-eval-after-load 'magit
(magit-add-section-hook 'magit-status-sections-hook
'magit-insert-worktrees
'magit-insert-stashes
'append))
;; `magit-insert-local-branches' belongs to `magit-refs-mode' (the `y' buffer)
;; and is absent from magit's own default `magit-status-sections-hook', so the
;; status buffer lists no branches. Added after Worktrees, since branches and
;; the worktrees that check them out read well together.
(with-eval-after-load 'magit
(magit-add-section-hook 'magit-status-sections-hook
'magit-insert-local-branches
'magit-insert-worktrees
'append)
(add-to-list 'magit-section-initial-visibility-alist '(local . hide))
(add-hook 'magit-status-mode-hook
(defun bmg/magit-status-refs-upstream-h ()
"Give `magit-insert-local-branches' the comparison base it expects."
(setq-local magit-buffer-refs-upstream "HEAD")))
(defun bmg/magit-branch-checkout-or-visit ()
"Check out the branch at point in a status buffer; visit it elsewhere.
In `magit-refs-mode' and anywhere else this falls through to
`magit-visit-ref', preserving stock behaviour in the `y' buffer."
(interactive)
(if-let* (((derived-mode-p 'magit-status-mode))
(branch (magit-section-value-if 'branch)))
(magit-branch-checkout branch)
(call-interactively #'magit-visit-ref)))
(map! :map magit-branch-section-map
[remap magit-visit-thing] #'bmg/magit-branch-checkout-or-visit))
;; Selecting a worktree in Magit only opened a status buffer for it: the Doom
;; workspace, its buffer list and the modeline all stayed on the mainline
;; checkout, so project commands run from anywhere else still searched mainline.
;;
;; `magit-diff-visit-directory' is the single funnel every worktree entry point
;; goes through -- `magit-worktree-status' (RET, % g), `magit-worktree-checkout'
;; (% b), `magit-worktree-branch' (% c), `magit-worktree-move' (% m) and the
;; return to the primary worktree after % k -- so one advice there covers all of
;; them, and the gate keeps submodules and ordinary directory visits from diffs
;; on Magit's original code path.
;;
;; Accepted consequences: RET on a worktree opens the project file picker, not
;; that worktree's Magit status -- a deliberate deviation from stock Magit,
;; traded for holding no landing logic here; `+workspace-project' is left unset, as Doom
;; does on this reuse branch; with a prefix argument the workspace is repointed
;; but no file picker opens; and buffers already visiting mainline files keep
;; reporting the mainline project, because `projectile-project-root' derives
;; from each buffer's `default-directory' -- only a separate workspace could
;; isolate those.
(defun bmg/magit-linked-worktree-p (directory)
"Return non-nil if DIRECTORY is a worktree of this repository.
The worktree being displayed, submodules and unrelated repositories do not
count."
(ignore-errors
(let ((dir (file-truename (file-name-as-directory (expand-file-name directory))))
(here (file-truename (file-name-as-directory (magit-toplevel)))))
(and (not (equal dir here))
(seq-some (lambda (worktree)
(equal dir (file-truename
(file-name-as-directory (car worktree)))))
(magit-list-worktrees))))))
;; direnv keys its allow list on the *absolute path* of the .envrc, not its
;; contents, so a worktree of a repo you already trust arrives unallowed even
;; though the .envrc is tracked and byte-identical.
(defun bmg/magit-worktree-allow-direnv (root)
"Allow ROOT's .envrc if it has one, so its devshell can load."
(when (and (executable-find "direnv")
(file-exists-p (expand-file-name ".envrc" root)))
(if (zerop (call-process "direnv" nil nil nil "allow" root))
(message "direnv allowed: %s" (abbreviate-file-name root))
(message "direnv allow failed in %s" (abbreviate-file-name root)))))
(defun bmg/magit-switch-project-to-worktree (worktree)
"Make WORKTREE the current project, reusing the current Doom workspace."
(let ((root (file-name-as-directory (expand-file-name worktree)))
(+workspaces-on-switch-project-behavior nil))
;; Before the switch: envrc evaluates on the first file visited in the new
;; project, which projectile is about to trigger.
(bmg/magit-worktree-allow-direnv root)
(projectile-switch-project-by-name root)))
(with-eval-after-load 'magit-diff
(define-advice magit-diff-visit-directory
(:around (fn directory &optional other-window) bmg/switch-project-to-worktree)
"Switch project when DIRECTORY is another worktree of this repository."
(if (bmg/magit-linked-worktree-p directory)
(bmg/magit-switch-project-to-worktree directory)
(funcall fn directory other-window))))
;; No projectile cache invalidation here on purpose: Doom's :tools magit module
;; already runs `+magit-invalidate-projectile-cache-h' from
;; `magit-refresh-buffer-hook', keyed on the status buffer's hash, so it covers
;; every working-tree change -- rebase, pull, reset, external edits -- rather
;; than a hand-picked list of commands, and does it more cheaply.
;;;
;;;
;;; END_code
;;;
;;;The (format +lsp +onsave) module reformats the whole buffer on every save:
with +lsp, apheleia delegates to textDocument/formatting, which has no
notion of “only the lines I touched”. In a project whose style does not match
the formatter, a one-line edit rewrites the entire file.
So format-on-save is gated: it runs only in projects that have declared a
formatter (treefmt.nix, .clang-format, rustfmt.toml, …). Everything
else – other people’s repos, stray clones, scratch files – is left alone.
Override per project with bmg/format-on-save in .dir-locals.el, or format
by hand with +format/region-or-buffer.
;;;
;;;
;;; BEGIN_FormatOnSave
;;;
;;;
;; .editorconfig is deliberately NOT in this list: it declares indentation and
;; line endings, not a formatter, and plenty of hand-formatted projects ship one
;; (e.g. microvm.nix) -- exactly the repos this gate exists to protect.
(defvar bmg/format-on-save-marker-files
'(".clang-format"
"treefmt.nix" "treefmt.toml" ".treefmt.toml"
".pre-commit-config.yaml"
"rustfmt.toml" ".rustfmt.toml"
".prettierrc" ".prettierrc.json" ".prettierrc.yml" ".prettierrc.yaml"
".prettierrc.js" ".prettierrc.mjs" ".prettierrc.cjs"
"prettier.config.js" ".prettierrc.toml"
"biome.json" "biome.jsonc"
"ruff.toml" ".ruff.toml" ".style.yapf"
".ocamlformat" ".scalafmt.conf" ".swiftformat" ".stylua.toml")
"Files whose presence at a project root opts that project into format-on-save.")
(defvar bmg/format-on-save-content-markers
'(("flake.nix" . "^[ \t]*formatter[ \t]*=\\|treefmt")
("pyproject.toml" . "^\\[tool\\.\\(black\\|ruff\\|yapf\\)"))
"Alist of (FILENAME . REGEXP) for markers that need a peek inside the file.
A project opts in when FILENAME exists at its root and REGEXP matches.")
(defvar bmg/format-on-save--cache (make-hash-table :test #'equal)
"Memoisation of project root -> whether that project declares a formatter.")
(defun bmg/format-on-save--content-marker-p (root)
"Return non-nil if any content marker in ROOT matches its regexp."
(cl-some
(lambda (marker)
(let ((file (expand-file-name (car marker) root)))
(and (file-readable-p file)
(with-temp-buffer
;; Only the head of the file; these markers always appear early
;; and flake.nix can be large.
(insert-file-contents file nil 0 16384)
(goto-char (point-min))
(re-search-forward (cdr marker) nil t)))))
bmg/format-on-save-content-markers))
(defun bmg/format-on-save-project-p (&optional dir)
"Return non-nil if the project containing DIR declares a code formatter.
DIR defaults to `default-directory'. Returns nil outside a project."
(when-let* ((root (doom-project-root (or dir default-directory))))
(let ((cached (gethash root bmg/format-on-save--cache 'missing)))
(if (not (eq cached 'missing))
cached
(puthash root
;; Markers are checked at the project root only. An upward
;; walk (`locate-dominating-file') would eventually find a
;; marker in $HOME and silently enable formatting everywhere.
(and (or (cl-some (lambda (f)
(file-exists-p (expand-file-name f root)))
bmg/format-on-save-marker-files)
(bmg/format-on-save--content-marker-p root))
t)
bmg/format-on-save--cache)))))
(defun bmg/format-on-save-reset-cache ()
"Forget which projects declare a formatter.
Call after adding a formatter config to a project already visited this session."
(interactive)
(clrhash bmg/format-on-save--cache)
(message "format-on-save project cache cleared"))
(defvar bmg/format-on-save 'auto
"Whether to reformat the buffer on save.
`auto' formats only in projects that declare a formatter (see
`bmg/format-on-save-project-p'); t forces formatting on; nil forces it off.
Set via .dir-locals.el to override for a single project.")
(put 'bmg/format-on-save 'safe-local-variable
(lambda (v) (memq v '(t nil auto))))
(defun bmg/format-on-save-inhibit-h ()
"Inhibit format-on-save outside projects that declare a formatter."
(pcase bmg/format-on-save
('t nil)
('nil t)
(_ (not (bmg/format-on-save-project-p)))))
;; Two hooks, because apheleia's extension points differ.
;; `apheleia-inhibit-functions' is consulted *once*, by `apheleia-mode-maybe',
;; to decide whether `apheleia-mode' attaches to a new buffer; it has no effect
;; on buffers already open, so alone it leaves every existing buffer still
;; formatting on save after a config reload. The advice below covers that gap:
;; `apheleia-format-after-save' is the `after-save-hook' entry point, so gating
;; it is evaluated on *every* save and takes effect immediately in already-open
;; buffers.
;;
;; Deliberately NOT `apheleia-skip-functions': that is checked inside
;; `apheleia-format-buffer' via `apheleia--disallowed-p', which is also on the
;; manual path, so it would break `+format/region-or-buffer' in exactly the
;; projects where hand-formatting is most wanted.
;;
;; `apheleia-inhibit-functions' is autoloaded by apheleia precisely so it can be
;; mutated before the package loads -- no `with-eval-after-load' needed, and
;; Doom's own format module adds to it the same way. Any function returning
;; non-nil inhibits, so this composes with Doom's `+format-maybe-inhibit-h'
;; rather than replacing it.
(add-hook 'apheleia-inhibit-functions #'bmg/format-on-save-inhibit-h)
(define-advice apheleia-format-after-save
(:around (fn &rest args) bmg/gate-to-formatter-projects)
"Skip format-on-save outside projects that declare a formatter.
Only guards the on-save path; explicit `+format/region-or-buffer' still works."
(unless (bmg/format-on-save-inhibit-h)
(apply fn args)))
;; Deliberate no-format save. (C-u C-x C-s already does this: the format module
;; remaps `basic-save-buffer' to `+format/save-buffer', whose prefix arg
;; suppresses apheleia.)
(map! "C-x M-s" #'+format/save-buffer-no-reformat)
;;;
;;;
;;; END_FormatOnSave
;;;
;;;Everything that talks to an LLM lives in llm-config.el: copilot for inline completion, gptel over the GitHub Copilot backend, agent-shell for Claude via ACP, and the knowledge-management commands built on top of them.
It is a self-contained unit with its own defgroup and defcustom=s, so it gets
its own file for the same reason =elfeed-config.el and remarkable-config.el
do, rather than adding ~470 lines here. Keybindings for its commands stay in the
Map section below.
(load! "llm-config.el");;;
;;;
;;; BEGIN_ORG
;;;
;;;
(defun bmg/switch-to-agenda ()
"Switch to the custom `o' overview in `org-agenda-custom-commands'."
(interactive)
(org-agenda nil "o"))
(defun bmg/archive-all-done ()
"Archive all DONE tasks in the current buffer to the archive file."
(interactive)
(let ((count 0))
(org-map-entries
(lambda ()
(org-archive-subtree)
(setq count (1+ count))
(setq org-map-continue-from (org-element-property :begin (org-element-at-point))))
"/DONE" 'file)
(message "Archived %d DONE tasks" count)))
;; `org-directory' must be set before org loads.
(setopt org-directory "~/Documents/org/"
org-archive-location (concat org-directory "/archive.org_archive::datetree/")
org-id-link-to-org-use-id t
org-ellipsis " ▾ "
org-src-fontify-natively t
org-hide-emphasis-markers t
org-modern-star 'replace)
;; Set AFTER org loads: Doom's +org-init-appearance-h runs on
;; org-load-hook and resets org-startup-folded to nil, silently
;; clobbering a value set at config time.
(with-eval-after-load 'org
(setopt org-startup-folded 'fold
org-return-follows-link t))
(setopt org-roam-directory (file-truename (concat org-directory "roam/"))
org-roam-extract-new-file-path "${slug}.org"
org-default-notes-file (expand-file-name (format "inbox-%s.org" (system-name)) org-roam-directory)
;; defcustom type is (repeat string) -- a bare string makes
;; org-noter's dolist crash with wrong-type-argument listp
org-noter-notes-search-path (list org-roam-directory))
;; Starting a session from the *document* buffer writes NOTER_DOCUMENT relative
;; to the notes file (org-noter.el:279-281), which here means ../../Papers/...
;; while the rest of the vault is ~-relative. org-noter matches documents with
;; a raw `string=', so a second form for the same PDF reads as a second
;; document. `file-relative-name' is called exactly once in that function, on
;; that line, so shadowing it is precise. Same `cl-letf' shape as the
;; citar-org-roam guard below.
(with-eval-after-load 'org-noter
(define-advice org-noter--create-session-from-document-file-default
(:around (fn &rest args) bmg/noter-doc-path-abbreviated)
"Record NOTER_DOCUMENT as a `~'-relative path rather than notes-relative."
(cl-letf (((symbol-function 'file-relative-name)
(lambda (filename &optional _dir)
(abbreviate-file-name (expand-file-name filename)))))
(apply fn args))))
(setq +org-capture-todo-file org-default-notes-file
+org-capture-notes-file org-default-notes-file)
;; org-protocol browser capture (Chrome/Firefox). Requires the desktop file
;; handler configured in NixOS/home-manager. "w" prompts and keeps the page
;; selection; "W" is the same capture with `:immediate-finish', i.e. a quick
;; bookmark.
(with-eval-after-load 'org
(require 'org-protocol)
(setopt org-protocol-default-template-key "w")
(add-to-list 'org-capture-templates
'("w" "Web Capture" entry
(file+headline org-default-notes-file "Inbox")
"* %?[[%:link][%:description]] :REFILE:web:\n:PROPERTIES:\n:CREATED: %U\n:SOURCE: %:link\n:END:\n%i"
:prepend t))
(add-to-list 'org-capture-templates
'("W" "Web Capture (quick)" entry
(file+headline org-default-notes-file "Inbox")
"* [[%:link][%:description]] :REFILE:web:\n:PROPERTIES:\n:CREATED: %U\n:SOURCE: %:link\n:END:\n%i"
:prepend t
:immediate-finish t))
;; Here rather than in `org-roam-capture-templates' because file+headline is
;; an org-capture target type, which org-roam templates do not accept.
(add-to-list 'org-capture-templates
'("f" "Fleeting note" entry
(file+headline org-default-notes-file "Inbox")
"* %? :REFILE:\n:PROPERTIES:\n:CREATED: %U\n:END:\n%i"
:prepend t)))
;; Capture subdirectories are pre-created here: org-roam v2 has no :mkdir
;; template keyword (the old one in these templates was silently ignored).
(with-eval-after-load 'org-roam
(dolist (d '("refs" "meetings" "projects"))
(make-directory (expand-file-name d org-roam-directory) t)))
;; Subdirectories organise, filetags filter -- the two are deliberately
;; independent, so a note's location never constrains how it is queried.
(setq org-roam-capture-templates
'(;; Default note - flat in roam directory
("d" "default" plain "%?"
:target (file+head "${slug}.org"
"#+title: ${title}\n#+created: %u\n#+last_modified: %U\n#+filetags:\n\n")
:unnarrowed t)
;; Literature/reference note
("r" "reference" plain "%?"
:target (file+head "refs/${slug}.org"
"#+title: ${title}\n#+created: %u\n#+roam_refs: ${ref}\n#+filetags: :reference:\n\n* Summary\n\n* Notes\n")
:unnarrowed t)
;; Meeting note
("m" "meeting" plain "%?"
:target (file+head "meetings/%<%Y%m%d>-${slug}.org"
"#+title: ${title}\n#+date: %<%Y-%m-%d>\n#+created: %u\n#+filetags: :meeting:\n\n* Attendees\n\n* Agenda\n\n* Notes\n\n* Action Items\n")
:unnarrowed t)))
(setopt org-roam-dailies-directory "daily/")
(with-eval-after-load 'org-roam-dailies
(make-directory (expand-file-name org-roam-dailies-directory org-roam-directory) t))
(setq org-roam-dailies-capture-templates
'(("d" "default" entry
"* %?"
:target (file+head "%<%Y-%m-%d>.org"
"#+title: %<%Y-%m-%d %A>\n#+created: %u\n#+filetags: :daily:\n\n"))
("t" "task" entry
"* TODO %?"
:target (file+head+olp "%<%Y-%m-%d>.org"
"#+title: %<%Y-%m-%d %A>\n#+created: %u\n#+filetags: :daily:\n\n"
("Tasks")))
("j" "journal" entry
"* %<%H:%M> %?"
:target (file+head+olp "%<%Y-%m-%d>.org"
"#+title: %<%Y-%m-%d %A>\n#+created: %u\n#+filetags: :daily:\n\n"
("Journal")))))
;; Per-project capture: files live under roam/projects/{project-name}/ so org
;; files stay out of shared code repositories.
(defun bmg/get-project-name ()
"Get current project name or prompt for one.
Uses projectile if in a project, otherwise offers existing project directories."
(or (and (fboundp 'projectile-project-p)
(projectile-project-p)
(projectile-project-name))
(completing-read "Project: "
(when (file-directory-p (expand-file-name "projects" org-roam-directory))
(directory-files
(expand-file-name "projects" org-roam-directory)
nil "^[^.]")))))
(defun bmg/project-org-file (filename)
"Return path to FILENAME in current project's org directory.
Creates directory and initializes file with proper headers if needed."
(let* ((project (bmg/get-project-name))
(dir (expand-file-name (concat "projects/" project) org-roam-directory))
(file (expand-file-name filename dir)))
(unless (file-directory-p dir)
(make-directory dir t))
(unless (file-exists-p file)
(with-temp-file file
(pcase filename
("todo.org"
(insert (format "#+title: %s Tasks\n#+created: %s\n#+filetags: :project:todo:\n\n* Inbox\n\n* Active\n\n* Completed\n"
project (format-time-string "[%Y-%m-%d %a]"))))
("notes.org"
(insert (format "#+title: %s Notes\n#+created: %s\n#+filetags: :project:notes:\n\n* Inbox\n\n* Notes\n"
project (format-time-string "[%Y-%m-%d %a]"))))
("changelog.org"
(insert (format "#+title: %s Changelog\n#+created: %s\n#+filetags: :project:changelog:\n\n* Unreleased\n\n"
project (format-time-string "[%Y-%m-%d %a]"))))
(_
(insert (format "#+title: %s - %s\n#+created: %s\n#+filetags: :project:\n\n"
project (file-name-sans-extension filename)
(format-time-string "[%Y-%m-%d %a]")))))))
file))
(defun bmg/capture-project-todo ()
"Capture a TODO for the current project.
Adds to Inbox heading with REFILE tag for GTD processing."
(interactive)
(let ((file (bmg/project-org-file "todo.org")))
(find-file file)
(goto-char (point-min))
(re-search-forward "^\\* Inbox" nil t)
(org-end-of-subtree)
;; Land between the two spaces so the typed title keeps a space before
;; :REFILE: (8 puts point flush against the tag, and typing destroys it).
(insert "\n** TODO :REFILE:")
(backward-char 9)))
(defun bmg/capture-project-note ()
"Capture a note for the current project.
Adds to Inbox heading with REFILE tag for GTD processing."
(interactive)
(let ((file (bmg/project-org-file "notes.org")))
(find-file file)
(goto-char (point-min))
(re-search-forward "^\\* Inbox" nil t)
(org-end-of-subtree)
(insert (format "\n** %s :REFILE:" (format-time-string "[%Y-%m-%d %a]")))
(backward-char 9)))
(defun bmg/capture-project-roam-note ()
"Capture a roam note for the current project.
Creates a new org-roam node in projects/{project-name}/ directory."
(interactive)
(let* ((project (bmg/get-project-name))
(org-roam-capture-templates
`(("p" "project note" plain "%?"
:target (file+head
,(format "projects/%s/${slug}.org" project)
,(format "#+title: ${title}\n#+created: %%u\n#+filetags: :project:%s:\n\n" project))
:unnarrowed t))))
;; Per-project dir cannot be pre-created at load time; do it here.
(make-directory (expand-file-name (format "projects/%s" project)
org-roam-directory)
t)
(org-roam-capture nil "p")))
(defun bmg/open-project-todo ()
"Open the TODO file for the current project."
(interactive)
(find-file (bmg/project-org-file "todo.org")))
(defun bmg/open-project-notes ()
"Open the notes file for the current project."
(interactive)
(find-file (bmg/project-org-file "notes.org")))
;; Redirect Doom's C-c n n → p → t/n/c project capture into roam/projects/.
(define-advice +org-capture-project-todo-file
(:override () bmg/use-roam-projects)
"Use roam/projects/{project}/todo.org instead of project-root."
(bmg/project-org-file "todo.org"))
(define-advice +org-capture-project-notes-file
(:override () bmg/use-roam-projects)
"Use roam/projects/{project}/notes.org instead of project-root."
(bmg/project-org-file "notes.org"))
(define-advice +org-capture-project-changelog-file
(:override () bmg/use-roam-projects)
"Use roam/projects/{project}/changelog.org instead of project-root."
(bmg/project-org-file "changelog.org"))
;; org-mem (https://github.com/meedstrom/org-mem) complements org-roam with fast
;; queries over the roam db -- any property, not just the `org-agenda-files'
;; derivation below.
(use-package org-mem
:after org
:config
(setopt org-mem-watch-dirs (list org-roam-directory)
;;org-mem-do-sync-with-org-id t
org-roam-db-update-on-save nil
org-mem-roamy-do-overwrite-real-db t)
;; `org-mem-roamy-do-try-async' spawns sqlite3 for the DELETE and runs the
;; matching INSERT from that process's sentinel, but never clears
;; `org-mem-roamy--async-new-rows' after using it. Two saves close together
;; leave two sentinels pending, and the second re-inserts the first's rows:
(setopt org-mem-roamy-do-try-async nil)
;; Cut GC during org-roam operations. setq, not setopt: the defcustom's
;; (int) widget type is broken.
(setq org-roam-db-gc-threshold most-positive-fixnum)
(defun bmg/org-mem-set-agenda-files (&rest _)
"Derive `org-agenda-files' from org-mem: files with TODOs or dates.
Runs on every org-mem full scan, so it uses the raw -int accessors
\(no string formatting/memoization churn) and only sets the
variable when the list actually changed. The archive filter only
looks at the file NAME -- a note like internet-archive.org must
not be dropped because of its path."
(let ((files (cl-loop
for file in (org-mem-all-files)
unless (string-search "archive" (file-name-nondirectory file))
when (seq-find (lambda (entry)
(or (org-mem-entry-todo-state entry)
(org-mem-entry-active-timestamps-int entry)
(org-mem-entry-scheduled-int entry)
(org-mem-entry-deadline-int entry)))
(org-mem-entries-in file))
collect file)))
(unless (equal files org-agenda-files)
(setopt org-agenda-files files))))
(add-hook 'org-mem-post-full-scan-functions #'bmg/org-mem-set-agenda-files)
(org-mem-updater-mode)
(org-mem-roamy-db-mode)
;; Doom's +roam module calls org-roam-db-autosync-enable, whose save hook
;; conflicts with org-mem-roamy-db-mode: both insert into the same SQLite
;; table, causing UNIQUE constraint errors.
(org-roam-db-autosync-mode -1)
;; Doom defers the org-roam DB sync to the session's first `org-roam-db-query'
;; via a one-shot :before advice (`+org-roam-try-init-db-a',
;; modules/lang/org/contrib/roam.el:39-45) that prints "Initializing org-roam
;; database..." and runs a full `org-roam-db-sync'. `with-eval-after-load'
;; because Doom installs the advice when org-roam loads; removing it any
;; earlier would be a no-op that Doom then undoes.
(with-eval-after-load 'org-roam
(advice-remove 'org-roam-db-query #'+org-roam-try-init-db-a))
;; A second, independent trigger for the same full sync: `citar-org-roam-setup'
;; opens with an unconditional (org-roam-db-sync) -- citar-org-roam.el:267.
;; It runs when `citar-org-activate' lazily loads citar-org-roam during
;; font-lock of a [cite:...], so it lands inside `redisplay', mid-typing.
(with-eval-after-load 'citar-org-roam
(define-advice citar-org-roam-setup (:around (fn &rest args) bmg/skip-db-sync)
"Skip the full `org-roam-db-sync' citar-org-roam runs on activation.
org-mem maintains the database; see `org-roam-db-autosync-mode' above."
(cl-letf (((symbol-function 'org-roam-db-sync) #'ignore))
(apply fn args))))
;; org-mem's DB insert uses `prin1', which preserves Emacs text properties
;; (e.g. ws-butler-chg) as #(...) syntax. EmacSQL's `read-from-string' chokes
;; on these, causing "unhandled condition" errors in org-roam-node-list.
(define-advice org-mem-roamy--mk-literal-input-quoted-like-emacsql
(:filter-args (args) strip-text-properties)
(list (mapcar (lambda (row)
(mapcar (lambda (val)
(if (stringp val)
(substring-no-properties val)
val))
row))
(car args)))))
(setopt org-log-done 'time
org-log-into-drawer t
org-log-state-notes-insert-after-drawers nil)
(use-package org-roam-ui
:after org-roam
:commands (org-roam-ui-mode org-roam-ui-open)
:config
(setopt org-roam-ui-sync-theme t
org-roam-ui-follow t
org-roam-ui-update-on-save t
org-roam-ui-open-on-start nil))
(setopt citar-bibliography (list (concat org-directory "emacs_lit.bib"))
citar-library-paths '("~/Documents/Papers/")
citar-notes-paths (list org-roam-directory))
(with-eval-after-load 'citar
(defvar bmg/my-citar-embark-become-map
(let ((map (make-sparse-keymap)))
;; embark-become only offers this map when the CURRENT minibuffer
;; command is bound in it, so the umbrella citar-open must be here
;; for the citar->biblio hand-over to activate at all.
(define-key map (kbd "o") 'citar-open)
(define-key map (kbd "f") 'citar-open-files)
(define-key map (kbd "x") 'biblio-arxiv-lookup)
(define-key map (kbd "c") 'biblio-crossref-lookup)
(define-key map (kbd "i") 'biblio-ieee-lookup)
(define-key map (kbd "h") 'biblio-hal-lookup)
(define-key map (kbd "s") 'biblio-dissemin-lookup)
(define-key map (kbd "b") 'biblio-dblp-lookup)
(define-key map (kbd "d") 'biblio-doi-insert-bibtex)
map)
"Citar Embark become keymap for biblio lookup."))
(with-eval-after-load 'embark
(add-to-list 'embark-become-keymaps 'bmg/my-citar-embark-become-map))
;; Tags in a :startgroup are mutually exclusive in fast tag selection, so only
;; genuinely exclusive @contexts go inside; modifier tags (Important, Emacs,
;; uni, PERSONAL) stay outside, or adding one silently removes the @context.
(setopt org-tag-alist '((:startgroup . nil)
("@Project" . ?p)
("@Reading" . ?r)
("@Someday" . ?s)
("@Training" . ?t)
("@Courses" . ?c)
("@Research" . ?R)
("@Issue" . ?i)
(:endgroup . nil)
("uni" . ?u)
("Emacs" . ?e)
("Important" . ?I)
("PERSONAL" . ?P)))
(use-package org-super-agenda
:after org-agenda
:init
(setopt
org-agenda-prefix-format
'((agenda . " %i %-20:c %?-12t %12s")
(todo . " %i %-20:c ")
(tags . " %i %-20:c ")
(search . " %i %-20:c "))
org-agenda-todo-keyword-format "%-6s"
org-agenda-tags-column -120)
(setopt org-agenda-time-grid '((daily today require-timed)
(800 1200 1600 2000)
"......"
"----------------")
org-agenda-skip-scheduled-if-done t
org-agenda-skip-deadline-if-done t
org-agenda-include-deadlines t
org-agenda-include-diary nil
org-agenda-block-separator nil
org-agenda-compact-blocks t
org-agenda-start-with-log-mode 'clockcheck
org-agenda-span 1
org-agenda-start-day nil) ;; i.e. today
;; GTD view: REFILE inbox first; groups match Doom's org-todo-keywords.
(setq org-agenda-custom-commands
'(("o" "Overview"
((agenda "" ((org-agenda-span 'week)
(org-agenda-start-on-weekday 0) ;; Sunday
(org-super-agenda-groups
'((:name "Today"
:time-grid t
:date today
:scheduled today
:order 1)))))
(alltodo "" ((org-agenda-overriding-header "")
;; Groups match in LIST order (:order only affects
;; display), so selective groups must precede the
;; broad TODO/STRT catch-alls or they never match.
(org-super-agenda-groups
'(;; Discard first, before any group can claim these.
;; NB: tag match is case-insensitive, so this also
;; drops items inheriting a :daily: filetag.
(:discard (:tag ("Chore" "Routine" "Daily")))
(:name "📥 Inbox - Process these first"
:tag "REFILE"
:order 0)
(:name "Due Today"
:deadline today
:order 2)
(:name "Overdue"
:deadline past
:face error
:order 7)
(:name "Due Soon"
:deadline future
:order 8)
(:name "Important"
:tag "Important"
:priority "A"
:order 6)
(:name "Personal"
:tag "PERSONAL"
:order 12)
(:name "Issues"
:tag "@Issue"
:order 12)
(:name "Emacs"
:tag "Emacs"
:order 13)
(:name "Projects"
:todo "PROJ"
:tag "@Project"
:order 14)
(:name "Research"
:tag "@Research"
:order 15)
(:name "Training/Courses"
:tag ("@Training" "@Courses")
:order 16)
(:name "To read"
:tag "@Reading"
:order 30)
(:name "Waiting"
:todo ("HOLD" "WAIT")
:order 20)
(:name "University"
:tag "uni"
:order 32)
(:name "Someday"
:priority<= "C"
:tag "@Someday"
:todo "IDEA"
:order 90)
(:name "Recurring"
:todo "LOOP"
:order 4)
(:name "Ongoing"
:todo "STRT"
:order 3)
(:name "Next to do"
:todo "TODO"
:order 3)))))))))
:config
(org-super-agenda-mode))
(use-package consult-org-roam
:after org-roam
:config
(consult-org-roam-mode 1)
(setopt consult-org-roam-grep-func #'consult-ripgrep))
;; Corpus search. One engine, two sets of roots. Both are consult async
;; commands: results narrow as you type, text hits preview live, and
;; `C-c C-;' exports to the `grep-mode' buffer these used to produce outright.
(defun bmg/kb--search-roots ()
"Roots for `bmg/search-knowledge-base': notes, bibliography, documents.
The trailing nil makes `append' copy its last argument, so `delq' cannot
splice `bmg/document-search-roots'."
(delq nil (append (list (bound-and-true-p org-roam-directory))
(bound-and-true-p citar-bibliography)
bmg/document-search-roots
nil)))
(defun bmg/search-knowledge-base (&optional paths initial)
"Search org-roam notes, papers and the bibliography in one pass.
Async consult search: text hits preview live and jump to the exact line.
`C-c C-;' exports to a `grep-mode' buffer, `C-c C-e' a wgrep-editable one.
The minibuffer is prefilled with `#': `neural#2024' sends `neural' to rga
and filters `2024' in Emacs. The filter only sees what rga returned, and
rga caps at 5 hits per file, so raise the cap before narrowing when a
search must be exhaustive.
Bare flags after the query go to rga -- `neural --glob *.pdf',
`neural --max-count=200', `neural -i'. A literal `--' ends the option
section instead, returning the rest to the search string; `\\-' escapes a
single dashed word. A prefix argument chooses the roots.
RET opens a PDF at the matching page; EPUB opens at the top. Neither
auto-previews; `C-SPC' does, but only under `consult-preview-partial-size'
(1MB) -- larger files abort on NUL bytes. PATHS and INITIAL are as for
`bmg/consult--rga'."
(interactive "P")
(bmg/consult--rga "Knowledge base"
(if (or (consp paths) (stringp paths)) paths (bmg/kb--search-roots))
initial))
(defun bmg/rga-search (&optional paths initial)
"Search PDFs, EPUBs and other documents with ripgrep-all.
`bmg/search-knowledge-base' over `bmg/document-search-roots' alone -- same
engine, narrower roots, so rga touches fewer files. See that command for
the input syntax. PATHS and INITIAL are as there."
(interactive "P")
(bmg/consult--rga "Documents"
(if (or (consp paths) (stringp paths)) paths bmg/document-search-roots)
initial))
(defun bmg/org-roam-review-week ()
"Open all daily notes from the past week for review."
(interactive)
(require 'org-roam-dailies)
(let ((files '()))
(dotimes (i 7)
(let* ((date (time-subtract (current-time) (days-to-time i)))
;; No org-roam-dailies--daily-note-path in org-roam;
;; mirror the dailies template's "%<%Y-%m-%d>.org" target.
(file (expand-file-name
(format-time-string "%Y-%m-%d.org" date)
(expand-file-name org-roam-dailies-directory
org-roam-directory))))
(when (file-exists-p file)
(push file files))))
(if files
(progn
(dolist (file (reverse files))
(find-file-other-window file))
(message "Opened %d daily notes from the past week" (length files)))
(message "No daily notes found for the past week"))))
;;;
;;;
;;; END_ORG
;;;
;;;Doom ships avy, but binds it only under evil’s gs prefix, so with evil
disabled there is no on-screen jumping at all. The rest of this section turns on
motion behaviour Emacs has but leaves off by default: key-repeating for motion
commands, a walkable mark ring, and an isearch that counts matches and can be
navigated without exiting.
The destination-first commands live under a jump prefix on the leader (C-c
j), with the ones worth a single chord also on M-j and the M-g goto map.
consult-lsp-symbols fires workspace/symbol from minibuffer-setup. Servers
that do not implement it – nixd and harper-ls among them – make lsp-mode
signal there, and the error surfaces as a misleading Wrong type argument:
overlayp, nil: consult’s async indicator never reached its own setup, so its
overlay was still nil when teardown ran delete-overlay. The real message is
lost, because the cleanup error is signalled last.
textDocument/documentSymbol is far more widely implemented (nixd has it), so
the key falls back to consult-imenu rather than failing.
;;;
;;;
;;; BEGIN_Navigation
;;;
;;;
;; Repeats motion on the last key alone, for commands carrying a `repeat-map'
;; property: M-g n n n, C-x [ [ [, C-x <left> <left>. Not C-x o -- that is
;; `ace-window', and only `other-window' has the repeat map.
(repeat-mode 1)
;; smartparens ships no repeat-map, so the C-M- sexp motions repeat only via
;; this one. `:repeat t' sets the property on every command in the map.
(with-eval-after-load 'smartparens
(defvar-keymap bmg/sexp-repeat-map
:repeat t
"f" #'sp-forward-sexp
"b" #'sp-backward-sexp
"n" #'sp-next-sexp
"p" #'sp-previous-sexp
"u" #'sp-up-sexp
"d" #'sp-down-sexp
"a" #'sp-beginning-of-sexp
"e" #'sp-end-of-sexp))
;; C-u C-SPC then bare C-SPC keeps walking back through `mark-ring'.
(setopt set-mark-command-repeat-pop t)
;; `isearch-allow-motion' rebinds M-< M-> C-v M-v inside a search to jump
;; between matches instead of exiting it.
(setopt isearch-lazy-count t
isearch-allow-motion t)
;; `default-indent-new-line' keeps its identical C-M-j binding.
(map! "M-j" #'avy-goto-char-timer)
(with-eval-after-load 'avy
(setopt avy-all-windows t
avy-timeout-seconds 0.4))
(map! "M-g a" #'avy-goto-line
"M-g o" #'consult-outline
"M-g m" #'consult-mark
"M-g M" #'consult-global-mark)
;; `lsp--find-workspaces-for' is the same private gate `lsp--send-request-async'
;; applies before signalling, so this predicate cannot disagree with it.
(defun bmg/lsp-workspace-symbols-available-p ()
"Return non-nil if an attached server implements `workspace/symbol'."
(and (bound-and-true-p lsp-mode)
(fboundp 'lsp--find-workspaces-for)
(lsp--find-workspaces-for "workspace/symbol")))
(defun bmg/lsp-symbols-dwim ()
"Jump to a symbol, workspace-wide when the server supports it.
Falls back to `consult-imenu', which needs only
`textDocument/documentSymbol'."
(interactive)
(call-interactively
(if (bmg/lsp-workspace-symbols-available-p)
#'consult-lsp-symbols
#'consult-imenu)))
;; Bare :prefix "c" -- re-labelling Doom's would wipe every binding under it.
(map! :leader
(:prefix "c"
:desc "Jump to symbol" "j" #'bmg/lsp-symbols-dwim))
;; The leader IS C-c, so a global C-c j binding would be shadowed by
;; `doom-leader-map'. New prefix, so :prefix-map is safe here.
(map! :leader
(:prefix-map ("j" . "jump")
:desc "Char (timer)" "j" #'avy-goto-char-timer
:desc "Line" "l" #'avy-goto-line
:desc "Word" "w" #'avy-goto-word-1
:desc "Symbol" "s" #'avy-goto-symbol-1
:desc "Visible link" "L" #'link-hint-open-link
:desc "Outline heading" "o" #'consult-outline
:desc "Symbol in buffer" "i" #'consult-imenu
:desc "Symbol in project" "I" #'consult-imenu-multi
:desc "Mark ring" "m" #'consult-mark
:desc "Global mark ring" "M" #'consult-global-mark))
;;;
;;;
;;; END_Navigation
;;;
;;;;;;
;;;
;;; BEGIN_Map
;;;
;;;
;; C-. follows the path at point (ffap), mirroring M-. for definitions.
;; Doom's C-c s f stays bound; this is an additional key, not a move.
(map! "C-." #'+lookup/file)
;; Swaps Doom's M-? with the global M-/ `dabbrev-expand'.
(map! "M-/" #'+lookup/references
"M-?" #'dabbrev-expand)
;; nixd never answers textDocument/formatting for documents over ~8 KiB (it
;; deadlocks piping to nixfmt), so format-on-save hung on every larger file.
;; Setting `+format-with' stops `+format-with-lsp-toggle-h' claiming the buffer,
;; leaving apheleia's own nixfmt entry in charge. "-" avoids nixfmt 1.4's
;; deprecation warning on stderr.
(with-eval-after-load 'apheleia
(setf (alist-get 'nixfmt apheleia-formatters) '("nixfmt" "-")))
(add-hook 'nix-ts-mode-hook
(defun bmg/nix-format-with-nixfmt ()
(setq-local +format-with 'nixfmt)))
;; uv-mode is a global minor mode whose keymap outranks git-commit's, so once a
;; Python file loads it, C-c C-s runs uv-mode-set everywhere instead of signing
;; off a commit. Both commands remain on M-x.
(with-eval-after-load 'uv-mode
(map! :map uv-mode-map
"C-c C-s" nil
"C-c C-u" nil))
;; Doom installs and configures vundo but binds no key to it. The two keys
;; reached for when going a long way back get the tree; C-_ and M-_ stay
;; linear undo-fu. These are plain global bindings, which beat Doom's
;; `[remap undo]' in `undo-fu-mode-map' -- the remap only fires when the key
;; still resolves to `undo'.
(map! "C-x u" #'vundo
"C-/" #'vundo)
;; Doom's spell module remapped `ispell-word' to +spell/correct and owned
;; <leader> t s; both are vacated with the module off, so jinx takes them.
(map! [remap ispell-word] #'jinx-correct)
(map! :leader
(:prefix "t"
:desc "Spell checker" "s" #'jinx-mode))
;; SPC z for org-roam -- shorter than Doom's M-SPC m m / C-c l m.
(with-eval-after-load 'org-roam
(map! :leader
(:prefix-map ("z" . "org-roam")
"c" #'org-roam-capture
"D" #'org-roam-demote-entire-buffer
"f" #'consult-org-roam-file-find ; Enhanced with preview
"F" #'org-roam-ref-find
"g" #'org-roam-graph
"i" #'org-roam-node-insert
"I" #'org-id-get-create
"t" #'org-roam-buffer-toggle
"T" #'org-roam-buffer-display-dedicated
"r" #'org-roam-refile
"R" #'bmg/find-related-notes ; AI: Find related notes
"s" #'consult-org-roam-search ; Full-text search
"S" #'bmg/suggest-tags-for-buffer ; AI: Suggest tags
"b" #'consult-org-roam-backlinks ; Interactive backlinks
"l" #'consult-org-roam-forward-links ; Forward links
"w" #'bmg/generate-weekly-review ; AI: Weekly review
(:prefix ("a" . "AI/analysis")
:desc "Suggest tags" "t" #'bmg/suggest-tags-for-buffer
:desc "Summarize paper" "s" #'bmg/summarize-paper
:desc "Find related" "r" #'bmg/find-related-notes
:desc "Weekly review" "w" #'bmg/generate-weekly-review
:desc "Check tags" "c" #'bmg/check-tag-consistency
:desc "Find orphans" "o" #'bmg/find-orphan-notes)
(:prefix ("d" . "dailies")
"b" #'org-roam-dailies-goto-previous-note
"d" #'org-roam-dailies-goto-date
"D" #'org-roam-dailies-capture-date
"f" #'org-roam-dailies-goto-next-note
"m" #'org-roam-dailies-goto-tomorrow
"M" #'org-roam-dailies-capture-tomorrow
"n" #'org-roam-dailies-capture-today
"t" #'org-roam-dailies-goto-today
"T" #'org-roam-dailies-capture-today
"y" #'org-roam-dailies-goto-yesterday
"Y" #'org-roam-dailies-capture-yesterday
"-" #'org-roam-dailies-find-directory)
(:prefix ("o" . "node properties")
"a" #'org-roam-alias-add
"A" #'org-roam-alias-remove
"t" #'org-roam-tag-add
"T" #'org-roam-tag-remove
"r" #'org-roam-ref-add
"R" #'org-roam-ref-remove)
(:prefix ("p" . "project")
:desc "Project todo" "t" #'bmg/capture-project-todo
:desc "Project note" "n" #'bmg/capture-project-note
:desc "Project roam note" "p" #'bmg/capture-project-roam-note
:desc "Open project todo" "T" #'bmg/open-project-todo
:desc "Open project notes" "N" #'bmg/open-project-notes)
(:prefix ("u" . "UI")
:desc "Open graph" "u" #'org-roam-ui-open
:desc "Toggle UI mode" "m" #'org-roam-ui-mode))))
(map!
(;;: org-agenda
(:leader
;;; <leader> n --- notes
(:prefix "n"
:desc "Org agenda" "a" #'bmg/switch-to-agenda))
(:map org-agenda-mode-map
"i" #'org-agenda-clock-in
"R" #'org-agenda-refile))
(;;: open submenu
(:leader
;;; <leader> o --- open
(:prefix "o"
:desc "Url" "u" #'browse-url)))
(;;: quit -- bare :prefix, never a re-labelled one (that wipes the map)
(:leader
(:prefix "q"
:desc "Kill Emacs (confirm)" "Q" #'bmg/kill-emacs-confirm)))
(;;: crux and stuff
(:leader
;;; <leader> b --- prelude
(:prefix-map ("b" . "prelude")
:desc "crux-cleanup-buffer-or-region" "c" #'crux-cleanup-buffer-or-region
:desc "crux-duplicate-current-line-or-region" "d" #'crux-duplicate-current-line-or-region
:desc "crux-delete-file-and-buffer" "D" #'crux-delete-file-and-buffer
:desc "crux-kill-other-buffers" "k" #'crux-kill-other-buffers
:desc "crux-open-with" "o" #'crux-open-with
;; the canonical autoloaded command; the -buffer-and-file alias is
;; void until crux happens to load
:desc "crux-rename-file-and-buffer" "r" #'crux-rename-file-and-buffer
:desc "crux-transpose-windows" "s" #'crux-transpose-windows
:desc "crux-view-url" "u" #'crux-view-url
:desc "crux-indent-defun" "TAB" #'crux-indent-defun
:desc "Elfeed RSS enter" "e" #'=rss))
(:leader
(:prefix "s"
:desc "Search papers (rga)" "P" #'bmg/rga-search
:desc "Ask knowledge base" "Q" #'bmg/ask-knowledge-base
:desc "Search knowledge base" "k" #'bmg/search-knowledge-base)))
(;;: grammar -- LanguageTool runs as a server (services.languagetool in
;;: ~/.dotfiles), reached over langtool-http-server-port. New prefix, so
;;: :prefix-map is safe here.
(:leader
(:prefix-map ("g" . "grammar")
:desc "Check buffer" "c" #'langtool-check
:desc "Clear results" "d" #'langtool-check-done
:desc "Correct buffer" "f" #'langtool-correct-buffer
:desc "Correct at point" "." #'langtool-correct-at-point
:desc "Message at point" "m" #'langtool-show-message-at-point
:desc "Next error" "n" #'langtool-goto-next-error
:desc "Previous error" "p" #'langtool-goto-previous-error
:desc "Toggle writegood" "w" #'writegood-mode)))
) ;; END MAP
;; Localleader bindings for org-mode AI functions (C-c l in org-mode)
(with-eval-after-load 'org
(map! :map org-mode-map
:localleader
;; "B" not "P": localleader P is Doom's org-publish prefix
;; (org-publish-all & friends) and must stay reachable.
:desc "Process inbox item" "B" #'bmg/process-inbox-item
:desc "Summarize paper" "S" #'bmg/summarize-paper))
;; AI prefix on the leader (C-c A / M-SPC A). NOT (global-set-key (kbd "C-c a")
;; ...): Doom's leader map wins for C-c sequences and already binds "a" to
;; embark-act, which fully shadowed the global binding.
(defvar bmg/ai-command-map
(let ((map (make-sparse-keymap)))
(define-key map (kbd "t") #'bmg/suggest-tags-for-buffer)
(define-key map (kbd "s") #'bmg/summarize-paper)
(define-key map (kbd "p") #'bmg/process-inbox-item)
(define-key map (kbd "r") #'bmg/find-related-notes)
(define-key map (kbd "q") #'bmg/ask-knowledge-base)
(define-key map (kbd "k") #'bmg/search-knowledge-base)
(define-key map (kbd "w") #'bmg/generate-weekly-review)
(define-key map (kbd "c") #'bmg/check-tag-consistency)
(define-key map (kbd "o") #'bmg/find-orphan-notes)
map)
"Keymap for AI-powered knowledge management commands.")
(define-key doom-leader-map (kbd "A") (cons "AI" bmg/ai-command-map))
(map! :map dirvish-mode-map
"<mouse-1>" #'dirvish-subtree-toggle-or-open
"<mouse-2>" #'dired-mouse-find-file-other-window
"<mouse-3>" #'dired-mouse-find-file
"?" #'dirvish-dispatch
"q" #'dirvish-quit
"b" #'dirvish-quick-access
"f" #'dirvish-file-info-menu
"S" #'dirvish-quicksort
"F" #'dirvish-layout-toggle
"z" #'dirvish-history-jump
"TAB" #'dirvish-subtree-toggle
"M-b" #'dirvish-history-go-backward
"M-f" #'dirvish-history-go-forward
"M-n" #'dirvish-narrow
"M-m" #'dirvish-mark-menu
"M-s" #'dirvish-setup-menu
"M-e" #'dirvish-emerge-menu
;; dirvish-yank lives under the y-prefix; a top-level "p" would
;; shadow dired-previous-line ("P" would shadow dired-do-print).
(:prefix ("y" . "yank")
"l" #'dirvish-copy-file-true-path
"n" #'dirvish-copy-file-name
"p" #'dirvish-copy-file-path
"P" #'dirvish-yank
"r" #'dirvish-copy-remote-path
"y" #'dired-do-copy)
(:prefix ("s" . "symlinks")
"s" #'dirvish-symlink
"S" #'dirvish-relative-symlink
"h" #'dirvish-hardlink))
;;;
;;;
;;; END_Map
;;;
;;;Watches which commands run and, when a slower route is used where a faster one
exists, says so once. The implementation lives in its own file,
coach.el, for the same reason elfeed-config.el and
remarkable-config.el do: the rule set grows, and it should not swell the
literate config.
The recorder is a post-command-hook that pushes one entry and returns
(measured at 0.22 µs/call); every detector runs on an idle timer instead, so
typing is never slowed and nudges land at a pause rather than mid-burst.
;;;
;;;
;;; BEGIN_Coach
;;;
;;;
(load! "coach.el")
(coach-mode 1)
;;;
;;;
;;; END_Coach
;;;
;;;;;;
;;;
;;; BEGIN_Elfeed_Enhancements
;;;
;;;
(defun bmg/elfeed-mark-all-as-read ()
"Mark all entries in current elfeed filter as read."
(interactive)
(when (yes-or-no-p "Mark all visible entries as read? ")
(let ((count 0))
(save-excursion
(goto-char (point-min))
(while (not (eobp))
(when-let* ((entry (elfeed-search-selected :ignore-region)))
(elfeed-untag entry 'unread)
(setq count (1+ count)))
(forward-line)))
(elfeed-search-update--force)
(message "Marked %d entries as read" count))))
;; arXiv capture: `C' and `a' both run bmg/elfeed-arxiv-intake
;; (elfeed-config.el), which fetches PDF + BibTeX and creates the roam note in
;; one pass. The old separate bmg/elfeed-capture-arxiv spliced feed-controlled
;; text into an org-capture template, which would execute %(elisp) from a
;; malicious feed -- do not reintroduce that pattern.
(with-eval-after-load 'elfeed
(map! :map elfeed-search-mode-map
"R" #'bmg/elfeed-mark-all-as-read)
(map! :map elfeed-show-mode-map
"C" #'bmg/elfeed-arxiv-intake))
;;;
;;;
;;; END_Elfeed_Enhancements
;;;
;;;Integration between reMarkable Paper Pro tablet and Emacs org-mode. Uses USB Web Interface (http://10.11.99.1) - NO developer mode required!
Setup:
- On reMarkable: Settings → Storage → Enable “USB web interface”
- Connect device via USB cable
- Test:
C-c r t(remarkable-test-connection)
The implementation lives in its own file, remarkable-config.el, rather
than inline. It is a self-contained pseudo-package (its own defgroup and
remarkable- prefix), and it generates a lot of org text: keeping it in a .el
file removes the tangling hazard where a column-0 * inside a template string
would silently terminate a src block (which previously dropped six functions).
;; Loaded here (end of config) so citar-library-paths and doom-leader-map,
;; referenced at load time, are already set.
(load! "remarkable-config.el")