init.el

neoemacs · annotated walkthrough

The config, explained side by side

This is a personal terminal-Emacs configuration (emacs -nw inside zellij) living at ~/.config/neoemacs. It bootstraps the package system (quickstart activation + use-package, without loading package.el itself), then configures one package per form. On the left are the relevant elisp blocks from init.el; on the right is the explanation, including the why and the terminal-specific gotchas.

Package system

Bootstrapping the package manager and use-package.

;;; --- Package system ---

(with-eval-after-load 'package
  (setq package-archives
        '(("gnu"    . "https://elpa.gnu.org/packages/")
          ("nongnu" . "https://elpa.nongnu.org/nongnu/")
          ("melpa"  . "https://melpa.org/packages/"))))
(setq package-quickstart t)
(unless (load (locate-user-emacs-file "package-quickstart") 'noerror 'nomessage)
  (package-initialize)
  (package-quickstart-refresh))

package.el is deliberately not loaded on a normal startup: requiring it costs ~90ms, almost all of it the url / browse-url / auth-source subtree it pulls in — and none of that is needed unless a package is actually being installed. The quickstart bundle performs the activation on its own.

package-archives declares the three repositories packages are fetched from: GNU ELPA (the official, copyright-assigned archive), NonGNU ELPA (free packages without copyright assignment), and MELPA (the large community archive). Wrapped in with-eval-after-load, they're in place whenever package.el does load — the first-run bootstrap, M-x package-install, or the :ensure fallback below installing something new.

package-quickstart makes package.el maintain a single quickstart file containing package autoloads, load paths, and the activated package list. Loading that compiled bundle is much faster than scanning every installed package directory on every startup.

load is given the suffix-less name so it appends load-suffixes itself — trying package-quickstart.elc then package-quickstart.el — which keeps the compiled-first preference and lets native-comp swap in a .eln when one exists. With NOERROR, load returns nil if neither file exists, and only then — a first run or after deleting the quickstart file — does it fall back to a full package-initialize followed by package-quickstart-refresh.

Why no .elc passing an explicit .elc would force the slower byte-code: the C loader sets its no_native flag from the .elc suffix, and maybe_swap_for_eln then returns before the eln lookup and marks the file no-native (lread.c). Suffix-less is the native-comp path.
Why explicit early-init.el sets package-enable-at-startup to nil to skip Emacs's automatic startup activation, so package activation is deliberately owned here.
(require 'use-package)
(setq use-package-always-ensure t)

(defun neoemacs--use-package-ensure (name args state &optional no-refresh)
  "Ensure NAME is installed, without loading package.el when already active."
  (or (memq name (bound-and-true-p package-activated-list))
      (use-package-ensure-elpa name args state no-refresh)))
(setq use-package-ensure-function #'neoemacs--use-package-ensure)

use-package ships with Emacs 29+, so it's require-able directly — no install step, no package.el needed.

use-package-always-ensure t makes every use-package form auto-install its package from ELPA if missing.

Why the shim the stock use-package-ensure-elpa begins with (require 'package), so with always-ensure every single form would drag package.el (and its ~90ms url subtree) back onto the startup path just to conclude the package is already installed. The shim consults package-activated-list — already populated by the quickstart bundle — and only falls through to the real ensure machinery for a package that isn't active yet, i.e. exactly the "new use-package form was just added" case.
Gotcha Because of always-ensure, packages that ship with Emacs (like recentf, which-key, ediff) must add :ensure nil — otherwise use-package tries to fetch them from ELPA and fails.

Core editor settings

Backups, mouse-wheel scrolling, line numbers, recent files.

;; Disable backup files (the `filename~' clutter).
(setq make-backup-files nil)

Turns off Emacs's automatic file~ backup copies, which otherwise litter every directory you edit in. Pure preference.

(setq use-short-answers t)

(setq custom-file (locate-user-emacs-file "custom.el"))
(load custom-file 'noerror 'nomessage)

use-short-answers t lets you answer the long yes-or-no-p prompts ("Quit? (yes or no)") with a single y/n instead of typing the whole word plus RET.

The custom-file lines keep Emacs's Custom system out of init.el. Left unset, custom-file defaults to user-init-file and Custom rewrites its machine-generated blocks into init.el (which is how stale entries crept in before). Pointing it at a separate, gitignored custom.el — then loading it with NOERROR so a missing file is harmless — keeps init.el hand-edited only.

(xterm-mouse-mode 1)

Enables Emacs's own mouse reporting in the terminal, so the wheel arrives as real mouse-4/mouse-5 events that route to mwheel-scroll.

The bug this fixes Without it, the terminal's "alternate scroll" translates the wheel into Up/Down arrow keys, which move point (the cursor) instead of scrolling the view. Trade-off: with mouse mode on, text selection now uses Emacs's mouse, not the terminal's — hold Shift/Fn for native terminal selection.
(setq mouse-wheel-follow-mouse t
      mouse-wheel-progressive-speed nil
      mouse-wheel-scroll-amount '(2 ((shift) . 1) ((control) . text-scale))
      scroll-conservatively 101
      scroll-margin 0
      make-cursor-line-fully-visible nil)

Tunes scrolling so the view moves, not point:

  • mouse-wheel-follow-mouse — scroll the window under the pointer.
  • mouse-wheel-progressive-speed nil — constant speed; don't accelerate on fast spins.
  • mouse-wheel-scroll-amount — 2 lines per notch; Shift+wheel = 1 line; Ctrl+wheel = text zoom.
  • scroll-conservatively 101 — scroll one line at a time for keyboard motion, never recenter with a jump. (scroll-step is intentionally omitted — it's ignored whenever scroll-conservatively is > 100.)
  • scroll-margin 0 — let the cursor reach the very top/bottom edge before the view is dragged.
  • make-cursor-line-fully-visible nil — don't force a recenter after a wheel scroll.
Deliberately omitted scroll-preserve-screen-position is left nil on purpose — setting it pins point to a screen row so the cursor tracks the scroll, the opposite of what's wanted. Inherent limit: point must stay visible, so scrolling far enough still drags the cursor along at the window edge.
(setq display-line-numbers-type t)
(global-display-line-numbers-mode 1)
(global-hl-line-mode 1)

display-line-numbers-type t selects absolute line numbers (switch to 'relative or 'visual for Vim-style relative numbering).

global-display-line-numbers-mode shows them in the gutter everywhere; global-hl-line-mode highlights the line point is on.

(defvar-local neoemacs--hl-line-suspended nil)

(add-hook 'activate-mark-hook
          (defun neoemacs--hl-line-suspend-h ()
            (when (and global-hl-line-mode (not neoemacs--hl-line-suspended))
              (setq neoemacs--hl-line-suspended t)
              (setq-local global-hl-line-mode nil)
              (global-hl-line-unhighlight))))

(add-hook 'deactivate-mark-hook
          (defun neoemacs--hl-line-restore-h ()
            (when neoemacs--hl-line-suspended
              (setq neoemacs--hl-line-suspended nil)
              (kill-local-variable 'global-hl-line-mode)
              (global-hl-line-highlight))))

The current-line highlight obscures the bounds of a selection under some themes, so it's suspended while a selection is active — an Evil visual state or a vanilla region.

The supported lever The buffer-local hl-line-mode minor mode is never on here; the global highlight is driven by global-hl-line-highlight on post-command-hook, gated on the variable global-hl-line-mode. So suspending it means making that variable buffer-local and binding it to nil (then clearing the overlay the current command already drew), and restoring it means kill-local-variable. Keyed off Emacs's generic activate-mark-hook/deactivate-mark-hook rather than anything Evil-specific, so it covers both region kinds.
(use-package recentf
  :ensure nil
  :defer t
  :custom
  (recentf-max-saved-items 100)
  :init
  (add-hook 'emacs-startup-hook (lambda () (recentf-mode 1)))
  :config
  ;; Re-read the on-disk list and merge it into the in-memory one so the last
  ;; writer wins without discarding what a concurrent instance recorded.
  (defun neoemacs--recentf-merge-from-disk (&rest _)
    (let ((mem recentf-list))
      (recentf-load-list)               ; reloads `recentf-list' from disk
      (setq recentf-list
            (seq-take (delete-dups (append mem recentf-list))
                      recentf-max-saved-items))
      ;; Drop entries whose files are gone (or now excluded), here so one
      ;; instance's deletion can't be resurrected by another's re-merge.
      (let ((inhibit-message t))
        (recentf-cleanup))))
  (advice-add 'recentf-save-list :before #'neoemacs--recentf-merge-from-disk)

  ;; Guarded quiet save: skip the whole load-merge-write when the list is
  ;; byte-for-byte what we last persisted, so an idle window switch does no IO.
  (defvar neoemacs--recentf-last-saved nil)
  (defun neoemacs--recentf-save-quietly (&rest _)
    (unless (equal recentf-list neoemacs--recentf-last-saved)
      (let ((save-silently t) (inhibit-message t))
        (recentf-save-list))
      (setq neoemacs--recentf-last-saved (copy-sequence recentf-list))))

  ;; Persist when this Emacs stops being focused. Two triggers, because
  ;; terminal Emacs only gets frame focus events when the terminal forwards
  ;; focus reporting -- which isn't guaranteed.
  (defun neoemacs--recentf-save-on-focus-loss ()
    (unless (frame-focus-state)
      (neoemacs--recentf-save-quietly)))
  (add-function :after after-focus-change-function
                #'neoemacs--recentf-save-on-focus-loss)
  (add-hook 'window-selection-change-functions
            #'neoemacs--recentf-save-quietly))

;; `SPC f r' merges the on-disk list in first so other instances' entries show.
(defun neoemacs/consult-recent-file ()
  "Merge the on-disk `recentf' list in, then pick a recent file."
  (interactive)
  (neoemacs--recentf-merge-from-disk)
  (consult-recent-file))

recentf tracks recently opened files (consumed by neoemacs/consult-recent-file at SPC f r). :ensure nil because it ships with Emacs. :custom raises the remembered count to 100; :init (recentf-mode 1) turns it on.

The :config block fixes a multi-instance race. Each Emacs holds its own in-memory recentf-list and overwrites the shared save file, so a naive save would erase another instance's history. neoemacs--recentf-merge-from-disk re-reads the on-disk list and merges it (append + delete-dups, then seq-take caps it and recentf-cleanup prunes dead entries), and it's installed both as :before advice on every recentf-save-list and in the SPC f r wrapper so reads see what others recorded too.

When it saves Rather than an exit hook (the terminal closing kills Emacs with SIGHUP, which skips kill-emacs-hook), the list is persisted on focus loss: both after-focus-change-function (losing the OS window) and window-selection-change-functions (moving to another Emacs window), so it's saved even when the terminal never forwards focus events.
No idle churn window-selection-change-functions fires on every window switch, so the quiet save short-circuits when recentf-list is unchanged from neoemacs--recentf-last-saved — an idle window switch does zero disk IO.
(use-package autorevert
  :ensure nil
  :defer t
  :custom
  (global-auto-revert-non-file-buffers t)
  (auto-revert-verbose nil)
  :init
  (add-hook 'emacs-startup-hook (lambda () (global-auto-revert-mode 1))))

autorevert reloads a buffer whose backing file changed on disk, as long as the buffer has no unsaved edits. global-auto-revert-non-file-buffers extends this to dired/dirvish (and other non-file buffers), so directory listings refresh too. Reverts are silent (auto-revert-verbose nil). Enabled from emacs-startup-hook — no file buffer can exist before startup finishes, so the load stays off the pre-paint path.

Appearance

Theme, icon fonts, modeline.

(use-package doom-themes
  :config
  (setq doom-themes-enable-bold t
        doom-themes-enable-italic t)
  (load-theme 'doom-one t))

Installs the doom-themes pack, enables bold and italic face variants, and loads doom-one (the dark theme from Doom Emacs). The t second argument to load-theme means "no confirmation, trust this theme."

(use-package nerd-icons)

(use-package doom-modeline
  :after nerd-icons
  :init
  (doom-modeline-mode 1))

nerd-icons supplies the glyph fonts that the modeline and dirvish draw file-type icons from.

doom-modeline is the rich status bar matching the theme. :after nerd-icons guarantees the icon library loads first; :init activates the mode.

One-time setup Run M-x nerd-icons-install-fonts once after first launch, or the icons render as tofu boxes.

Evil — Vim emulation

The editing model, plus collection bindings and terminal cursor shapes.

(use-package evil
  :init
  (setq evil-want-integration t
        evil-want-keybinding nil
        evil-want-C-u-scroll t
        evil-search-module 'evil-search
        evil-symbol-word-search t
        evil-move-beyond-eol t
        evil-echo-state nil)
  :config
  (evil-mode 1)
  (defun neoemacs/escape-clear-search ()
    "Clear search highlighting, then run `evil-force-normal-state'."
    (interactive)
    (evil-ex-nohighlight)
    (evil-force-normal-state))
  (define-key evil-normal-state-map (kbd "<escape>")
              #'neoemacs/escape-clear-search))

Evil brings modal Vim editing. Settings are in :init because they must be set before the package loads:

  • evil-want-integration t — load Evil's integration layer.
  • evil-want-keybinding nilrequired so that evil-collection (next) provides the keybindings instead of Evil's own defaults; the two would clash otherwise.
  • evil-want-C-u-scroll t — restore Vim's C-u half-page scroll (Emacs normally uses C-u as a prefix arg).
  • evil-search-module 'evil-search — use Vim-style evil-ex-search for / and ? (incremental, n/N repeat, search highlighting, :s offsets) instead of the default isearch backend.
  • evil-symbol-word-search t*/# (and /) match the whole symbol under point, so a name like evil-ex-search is one unit instead of stopping at the first -.
  • evil-move-beyond-eol t — let point move one column past the end of the line.
  • evil-echo-state nil — don't print -- INSERT -- etc. in the echo area (the modeline already shows the state).

:config turns Evil on, then rebinds normal-state Esc to neoemacs/escape-clear-search: it clears the evil-ex-search highlight (Vim's :nohlsearch) before falling back to evil-force-normal-state.

(use-package evil-collection
  :defer t
  :init
  (add-hook 'emacs-startup-hook (lambda () (require 'evil-collection)))
  :config
  (setq evil-collection-mode-list (delq 'magit evil-collection-mode-list))
  (evil-collection-init))

evil-collection supplies consistent Evil bindings for hundreds of built-in and third-party modes (dired, help, magit, …). It loads from emacs-startup-hook: evil-collection-init immediately requires the per-mode binding file for every feature that's already loaded (simple, help, info, …) — a long tail of small loads that would otherwise sit on the pre-paint path. The hook still runs before any user input is processed, so the bindings are in place for the first keystroke.

delq 'magit … removes magit from the list of modes it will touch before evil-collection-init applies the rest.

Why drop magit Magit's native keymap is carefully designed; layering Evil bindings on top would override its single-key commands. Keeping it out preserves magit's own keys.
(use-package evil-terminal-cursor-changer
  :after evil
  :init
  (setq evil-normal-state-cursor   'box
        evil-visual-state-cursor   'box
        evil-motion-state-cursor   'box
        evil-insert-state-cursor   'bar
        evil-replace-state-cursor  'hbar
        evil-operator-state-cursor 'hbar
        evil-emacs-state-cursor    'hollow
        ;; workaround for Ghostty
        visible-cursor             nil
        etcc-use-blink             nil)
  :config
  (evil-terminal-cursor-changer-activate))

In GUI Emacs, cursor-type alone changes the cursor shape — but in emacs -nw it does nothing. This package emits DECSCUSR escape sequences on each Evil state change so the host terminal's cursor reflects the mode:

  • normal / visual / motion → block
  • insert → vertical bar
  • replace / operator → underline (hbar)
  • emacs state → hollow box

etcc-use-blink nil forces the steady DECSCUSR codes (ESC [ 2/4/6 q) in every state — no blinking.

Ghostty workaround visible-cursor nil tells Emacs not to use the terminal's "very visible" (blinking) cursor. Under Ghostty this is needed so the steady DECSCUSR shapes actually stick instead of being overridden back to a blinking cursor.
Why no passthrough Running inside zellij, which forwards DECSCUSR to the real terminal natively. Because $TMUX is unset, the package sends plain sequences with no tmux-style DCS wrapping.
(use-package evil-surround
  :after evil
  :config
  (global-evil-surround-mode 1))

(use-package evil-commentary
  :after evil
  :config
  (evil-commentary-mode))

evil-surround ports Vim surround operations: ys adds, cs changes, and ds deletes surrounding pairs; visual-state S surrounds the selected region.

evil-commentary adds comment operators: gcc toggles the current line, gc{motion} comments a motion, and gc works on a visual selection using the major mode's comment syntax.

(use-package evil-goggles
  :defer t
  :init
  (add-hook 'emacs-startup-hook (lambda () (require 'evil-goggles)))
  :config
  (setq evil-goggles-duration 0.1
        evil-goggles-pulse nil)
  (evil-goggles-use-diff-faces)
  (evil-goggles-mode))

evil-goggles briefly highlights the text affected by edits such as yank, delete, change, paste, and indent. Diff faces make additions/deletions easy to spot, while pulse animation is disabled to keep terminal redraws cheap. Loaded from emacs-startup-hook: the flash only matters once you edit, and evil-goggles-use-diff-faces drags in all of diff-mode for its faces.

(use-package evil-anzu
  :defer t
  :init
  (add-hook 'emacs-startup-hook (lambda () (require 'evil-anzu)))
  :config
  (global-anzu-mode +1))

evil-anzu shows the match count of the active evil-ex-search (/ and ?) in the mode line as current/total (e.g. 1/3). It pulls in anzu, whose global-anzu-mode installs the indicator, and wires anzu's counter into Evil's search so the count updates as you type and on n/N. The indicator clears when the search highlight is cleared (the normal-state Esc binding above).

(use-package avy
  :defer t
  :custom
  (avy-background t)   ; dim the screen while hints are up
  :init
  (with-eval-after-load 'evil
    (define-key evil-normal-state-map (kbd "s") #'evil-avy-goto-char-timer)))

avy jumps to any visible position: s in normal state runs evil-avy-goto-char-timer — type the target characters, pause, then hit the overlay label to jump. avy-background dims everything but the candidates while the hints are up so the labels pop. The evil motion wrapper is defined eagerly by evil-integration.el when Evil loads and avy autoloads on first invocation, so this form only installs the package. Bound in evil-normal-state-map rather than the override map so mode-local s bindings (dired's sort, magit) still win; it shadows evil-substitute (cl is equivalent).

Window helpers & keybindings

Custom commands, then the SPC leader and state-scoped keys via general.

(defun neoemacs/vsplit-window-follow ()
  "Split the window horizontally and move focus into the new split."
  (interactive)
  (evil-window-vsplit)
  (evil-window-right 1))

A vertical split that also follows focus into the new pane. Evil's evil-window-vsplit by itself leaves point in the original window; the evil-window-right 1 moves into the freshly created one. interactive makes it a callable command. Bound to s-n below.

(defun neoemacs/vsplit-ghostel (&optional here)
  "Open a vertical split, move focus into it, and launch ghostel there.
With a prefix arg (or non-nil HERE), start in the current
`default-directory' rather than the project root."
  (interactive "P")
  (unless here (require 'projectile))
  (let ((root (and (not here) (projectile-project-root))))
    (neoemacs/vsplit-window-follow)
    (evil-buffer-new)
    (let* ((placeholder (window-buffer))
           (default-directory (or root default-directory))
           (ghostel-buffer (ghostel '(4))))
      (when (and (buffer-live-p placeholder)
                 (not (eq placeholder ghostel-buffer)))
        (kill-buffer placeholder)))))

(defun neoemacs/vsplit-ghostel-here ()
  "Like `neoemacs/vsplit-ghostel' but ignore the project root."
  (interactive)
  (neoemacs/vsplit-ghostel t))

Opens a terminal in a fresh split (bound to s-t and SPC t):

  • vsplit-window-follow makes the split and moves into it.
  • evil-buffer-new shows an empty *new* buffer there.
  • (ghostel '(4)) launches the terminal. The non-numeric prefix arg '(4) forces a new terminal rather than reusing an existing one.

By default the terminal is rooted at the originating buffer's project root, captured into root before the split (the placeholder buffer can carry a different default-directory), then bound around the ghostel call via let*. Outside a project it falls back to the current directory. A prefix arg — or neoemacs/vsplit-ghostel-here (SPC u t) — skips the root and starts in the current default-directory. projectile is deferred, so it's required on demand.

The cleanup dance evil-buffer-new puts its placeholder in the window via set-window-buffer without making it current — so it's grabbed back with (window-buffer). Ghostel then swaps in its own buffer; the when kills the leftover placeholder, but only if it's still alive and genuinely different from the ghostel buffer.
(defun neoemacs/describe-symbol-at-point ()
  "Describe the symbol under point without prompting in the minibuffer."
  (interactive)
  (let ((sym (symbol-at-point)))
    (if sym
        (progn
          (helpful-symbol sym)
          (when-let ((win (seq-find
                           (lambda (w)
                             (provided-mode-derived-p
                              (buffer-local-value 'major-mode (window-buffer w))
                              'helpful-mode))
                           (window-list))))
            (select-window win)))
      (user-error "No symbol at point"))))

A no-prompt help command (bound to K in elisp buffers, Vim-style). symbol-at-point grabs the symbol under the cursor; if there is one, helpful-symbol opens the richer Helpful buffer without the usual minibuffer prompt.

The when-let scans live windows for one whose buffer is derived from helpful-mode, then selects it. Focus lands in the help window, so you can immediately scroll it and press q to dismiss. If there's no symbol, user-error reports it cleanly (no stack trace).

(defun neoemacs/find-file-in-config ()
  "Find a file under the Emacs config directory (`user-emacs-directory')."
  (interactive)
  (let ((default-directory user-emacs-directory))
    (call-interactively #'find-file)))

Opens a normal find-file prompt rooted at this config directory. It is bound to SPC f p, so editing the private config never depends on the current project or buffer.

(defun neoemacs--start-gui-process (name &rest program-args)
  "Like `start-process' but survives zellij's session boundary."
  (let ((reattach (and (getenv "ZELLIJ")
                       (executable-find "reattach-to-user-namespace"))))
    (apply #'start-process name nil
           (if reattach (cons reattach program-args) program-args))))

The shared launcher for the macOS app helpers below. Under zellij Emacs is a child of zellij's detached background server, whose Mach bootstrap namespace is cut off from the GUI login session — so a plain open silently fails (the classic tmux pbcopy/open bug). When $ZELLIJ is set it prepends reattach-to-user-namespace to re-enter the user namespace and restore GUI access; otherwise the args run directly.

(defun neoemacs--current-file ()
  "Return the file at point (dired) or the visited file, as an absolute path."
  ...)

(defun neoemacs/open-file-in-default-app ()
  "Open the current file in its default macOS app, as if double-clicked."
  (interactive)
  (neoemacs--start-gui-process "open-default" "open" (neoemacs--current-file)))

SPC o f opens the current file in whatever app macOS has registered for that file type. In dired/dirvish it's the file under point; elsewhere the visited file. It hands the file to open with no -a, so LaunchServices routes it exactly like a Finder double-click. (This replaced an earlier Quick Look command: qlmanage draws its panel from the calling process and just blocks under zellij, whereas open hands the request to LaunchServices, which launches the app inside the GUI session.)

(defun neoemacs/open-dir-in-finder ()
  "Reveal the current directory in macOS Finder."
  (interactive)
  (let ((dir (cond ((derived-mode-p 'dired-mode) (dired-current-directory))
                   (t default-directory))))
    (neoemacs--start-gui-process "open-finder" "open" (expand-file-name dir))))

SPC o d opens the relevant directory in Finder. In dired/dirvish it follows the listed directory; elsewhere it uses default-directory.

(defun neoemacs/open-file-in-obsidian ()
  "Open the current file in Obsidian."
  (interactive)
  (let* ((file (neoemacs--current-file))
         (root (locate-dominating-file file ".obsidian")))
    (unless root
      (user-error "Not inside an Obsidian vault ..."))
    (neoemacs--start-gui-process "open-obsidian" "open" obsidian-url)))

SPC o o opens the current file in Obsidian. It detects the nearest parent directory containing .obsidian, uses that directory name as the vault name, builds an obsidian://open URL for the file relative to the vault root, and hands it to macOS open.

(use-package general
  :after evil
  :config
  (general-create-definer neoemacs/leader
    :states '(normal visual motion)
    :keymaps 'override
    :prefix "SPC"
    :global-prefix "M-SPC")

general is the keybinding DSL. general-create-definer builds a reusable leader command, neoemacs/leader:

  • :states — active in normal, visual, and motion Evil states.
  • :keymaps 'override — bind in an override map so nothing shadows the leader.
  • :prefix "SPC"Space is the leader.
  • :global-prefix "M-SPC"M-Space works as a fallback in insert/emacs states where Space inserts text.
  (neoemacs/leader
    "SPC" '(projectile-find-file :which-key "find file in project")
    ","  '(consult-buffer :which-key "switch buffer")
    ":"  '(eval-expression :which-key "eval expression")
    "x"  '(execute-extended-command :which-key "M-x")
    "f"  '(:ignore t :which-key "files")
    "ff" '(find-file :which-key "find file")
    "fp" '(neoemacs/find-file-in-config :which-key "find file in private config")
    "fr" '(neoemacs/consult-recent-file :which-key "recent file")
    "fd" '(neoemacs/consult-dir :which-key "switch dir (consult-dir)")
    "b"  '(:ignore t :which-key "buffers")
    "bb" '(consult-buffer :which-key "switch buffer")
    "bd" '(kill-current-buffer :which-key "kill buffer")
    "bi" '(ibuffer :which-key "ibuffer")
    "bn" '(next-buffer :which-key "next buffer")
    "bp" '(previous-buffer :which-key "previous buffer")
    "bu" '(vundo :which-key "undo tree")
    "p"  '(:ignore t :which-key "project")
    "pp" '(projectile-switch-project :which-key "switch project")
    "pf" '(projectile-find-file :which-key "find file in project")
    "pb" '(projectile-switch-to-buffer :which-key "project buffer")
    "ps" '(consult-ripgrep :which-key "search in project")
    "g"  '(:ignore t :which-key "git")
    "gg" '(magit-status :which-key "status")
    "gb" '(magit-blame :which-key "blame")
    "gl" '(magit-log-buffer-file :which-key "log (this file)")
    "gj" '(diff-hl-next-hunk :which-key "next hunk")
    "gk" '(diff-hl-previous-hunk :which-key "prev hunk")
    "gs" '(diff-hl-stage-current-hunk :which-key "stage hunk")
    "gx" '(diff-hl-revert-hunk :which-key "revert hunk")
    "o"  '(:ignore t :which-key "open")
    "oo" '(neoemacs/open-file-in-obsidian :which-key "open file in Obsidian")
    "of" '(neoemacs/open-file-in-default-app :which-key "open file in default app")
    "od" '(neoemacs/open-dir-in-finder :which-key "open dir in Finder")
    "c"  '(:ignore t :which-key "code")
    "ca" '(lsp-execute-code-action :which-key "code actions")
    "cr" '(lsp-rename :which-key "rename symbol")
    "cf" '(lsp-format-buffer :which-key "format buffer")
    "cd" '(flymake-show-buffer-diagnostics :which-key "diagnostics")
    "cD" '(consult-lsp-diagnostics :which-key "workspace diagnostics")
    "cs" '(consult-lsp-file-symbols :which-key "file symbols")
    "cS" '(consult-lsp-symbols :which-key "workspace symbols")
    "n"  '(neoemacs/vsplit-window-follow :which-key "vsplit & follow")
    "s"  '(save-buffer :which-key "save buffer")
    "t"  '(neoemacs/vsplit-ghostel :which-key "ghostel (project root)")
    "w"  '(evil-window-delete :which-key "delete window")
    "u"  '(:ignore t :which-key "ghostel")
    "ut" '(neoemacs/vsplit-ghostel-here :which-key "ghostel here (current dir)")
    "/"  '(consult-ripgrep :which-key "search in project")
    "h"  '(help-command :which-key "help"))

The leader menu. Each entry maps a key sequence to a command plus a :which-key label shown in the popup. Top-level shortcuts: SPC SPC → find file in project, SPC , → switch buffer, SPC : → eval expression, SPC xM-x, SPC / → search the project with ripgrep, SPC s → save buffer, SPC w → delete window, SPC n → vsplit & follow, SPC t → ghostel terminal at the project root.

Mnemonic groups, where :ignore t defines a prefix that only carries a which-key label (no command of its own):

  • f files — ff find, fp config file, fr recent, fd switch directory (consult-dir).
  • b buffers — switch / kill / ibuffer / next / prev, plus buvundo (the visual undo tree).
  • p project — pp switch project, pf find file, pb project buffer, ps ripgrep search.
  • g git — status, blame, file log, and diff-hl hunk navigation/stage/revert.
  • o open — oo opens the current file in Obsidian, of opens it in its default macOS app, od reveals the current directory in Finder.
  • c code — lsp-mode code actions / rename / format and flymake diagnostics, consult-lsp symbol/diagnostic pickers (cs file symbols, cS workspace symbols, cD workspace diagnostics), plus ccconsult-claude-sessions (the live Claude Code session switcher).
  • u ghostel — ut opens a ghostel terminal in the current directory (the t variant uses the project root).
  • h → the whole help-command map.
Why SPC p p Projectile's command map is reached at C-c p, which isn't a real prefix until projectile loads — so projectile-switch-project is exposed through the leader instead.
(define-key help-map "t" #'emacs-init-time)

With the dashboard gone, startup time is exposed through the normal help map: SPC h t and C-h t both call emacs-init-time.

  (general-define-key
   :states 'normal
   :keymaps 'override
   "-" 'dired-jump
   "ff" 'neoemacs/consult-recent-file
   "fd" 'neoemacs/consult-dir
   "fc" 'consult-claude-sessions)
  (general-define-key
   :keymaps 'override
   "s-h" 'evil-window-left
   "s-j" 'evil-window-down
   "s-k" 'evil-window-up
   "s-l" 'evil-window-right
   "s-n" 'neoemacs/vsplit-window-follow
   "s-s" 'save-buffer
   "s-w" 'evil-window-delete
   "S-s-[" 'evil-window-rotate-downwards
   "S-s-]" 'delete-other-windows)
  (general-define-key
   :states 'normal
   :keymaps '(emacs-lisp-mode-map lisp-interaction-mode-map)
   "K" 'neoemacs/describe-symbol-at-point))

State- and keymap-scoped bindings (layer 2 of the keybinding architecture):

  • - in normal state → dired-jump (vim-vinegar-style "jump to the directory of this file"); ff in normal state → neoemacs/consult-recent-file; fd in normal state → neoemacs/consult-dir; fc in normal state → consult-claude-sessions.
  • s-h/j/k/l — move between windows (the Super/Cmd key + hjkl).
  • s-n — vertical split and follow; s-s — save buffer; s-w — delete window.
  • S-s-[ — rotate windows; S-s-] — maximize (delete others).
  • K — only in emacs-lisp-mode / lisp-interaction-mode, describe the symbol under point.
  (general-define-key
   :states '(normal visual motion)
   :keymaps 'override
   "," (general-simulate-key "C-c"))
  (general-define-key
   :states '(normal visual motion)
   "j"  'evil-next-visual-line
   "k"  'evil-previous-visual-line
   "gj" 'evil-next-line
   "gk" 'evil-previous-line)

Two editing-model tweaks, also via general:

  • , is a general-simulate-key alias that replays the real C-c prefix through the live keymaps, so , x runs whatever C-c x is bound to in the current buffer (including major-mode maps: , C-cC-c C-c). Restricted to normal/visual/motion so a literal comma still types in insert — it shadows evil's repeat-find-backwards in those states.
  • j/k move by visual line, so navigation follows wrapped text instead of jumping a whole logical line; gj/gk keep the logical-line motions one keystroke away.
(use-package expand-region
  :after (evil general)
  :commands (er/expand-region er/contract-region)
  :init
  (general-define-key
   :states 'visual
   "v" 'er/expand-region
   "V" 'er/contract-region))

expand-region grows/shrinks the selection by semantic units (word → string → sexp → defun …). In visual state, v expands the region and V contracts it — keep tapping v to widen the selection one syntactic level at a time.

Deferred The visual-state bindings are created during init, but the package itself loads only when er/expand-region or er/contract-region is first used.
(use-package vundo
  :commands (vundo)
  :config
  (setq vundo-glyph-alist vundo-unicode-symbols))

vundo visualizes the built-in undo history as a tree. It does not replace Emacs undo and does not create persistent undo-history files. It is reached via SPC b u, and the Unicode glyphs make the tree clearer in the terminal.

(use-package which-key
  :ensure nil
  :defer t
  :init
  (add-hook 'emacs-startup-hook (lambda () (require 'which-key)))
  :config
  (setq which-key-sort-order #'which-key-key-order-alpha
        which-key-sort-uppercase-first nil
        which-key-add-column-padding 1
        which-key-max-display-columns nil
        which-key-min-display-lines 6
        which-key-side-window-slot -10)
  (which-key-setup-side-window-bottom)
  (add-hook 'which-key-init-buffer-hook
            (lambda () (setq-local line-spacing 3)))
  (which-key-mode 1))

which-key shows a popup of the available follow-up keys after you start a prefix (this is what renders the leader menu labels). :ensure nil because it's built in to modern Emacs. Loaded from emacs-startup-hook — the popup only appears about a second after a held prefix key, so it can never be needed before startup finishes.

The settings mirror Doom's which-key tuning for readability: alphabetical key ordering (lowercase first), one column of padding, no cap on the number of columns, at least six display lines, and the popup pinned to the bottom side window (which-key-setup-side-window-bottom) with extra line-spacing in its buffer.

Completion stack

Vertico, Orderless, Marginalia, icons, Consult, Embark, Helpful, and ibuffer.

(use-package vertico
  :init
  (vertico-mode 1))

Vertico is the UI layer: it turns minibuffer completion into a clean vertical list of candidates instead of the default horizontal one.

(use-package vertico-directory
  :ensure nil
  :after vertico
  :bind (:map vertico-map
         ("RET"   . vertico-directory-enter)
         ("DEL"   . vertico-directory-delete-char)
         ("M-DEL" . vertico-directory-delete-word))
  :hook (rfn-eshadow-update-overlay . vertico-directory-tidy))

vertico-directory ships with Vertico and makes file-name completion behave like a path editor. RET enters directories, DEL deletes one character, and M-DEL deletes a whole path component. The tidy hook removes shadowed path prefixes like stale ~/ text when an absolute path is typed.

(use-package orderless
  :init
  (setq completion-styles '(orderless basic)
        completion-category-overrides '((file (styles partial-completion)))))

Orderless is the matching layer: type space-separated fragments in any order (e.g. buf swi matches "switch-buffer"). completion-styles tries orderless first, falling back to basic.

The file category override uses partial-completion so path completion keeps the familiar /u/s/b/usr/share/bin shorthand.

(use-package marginalia
  :init
  (marginalia-mode 1))

Marginalia is the annotation layer: it adds rich notes in the right margin of each candidate — docstrings for commands, file sizes and permissions, variable values, and so on.

(use-package nerd-icons-completion
  :after (marginalia nerd-icons)
  :config
  (defun neoemacs--completion-color-dirs (orig metadata prop)
    ...
    (wrap file affixation candidates ending in "/"))
  (nerd-icons-completion-mode 1)
  (advice-add 'completion-metadata-get
              :around #'neoemacs--completion-color-dirs))

nerd-icons-completion adds colored icons to file, directory, and buffer candidates. A local advice then post-processes file completion affixations and tints directory names with the same face as the folder icon, while regular file names keep their default text face.

Advice order The icon mode is enabled first, then the local advice is added so it wraps outside the icon advice and can safely post-process its output.
(use-package consult
  :bind (("C-s"   . consult-line)
         ("C-x b" . consult-buffer)
         ("M-y"   . consult-yank-pop)
         ("M-g g" . consult-goto-line)
         ("M-g i" . consult-imenu))
  :bind (:map consult-narrow-map
              ("?" . consult-narrow-help)))

Consult is the commands layer — enhanced search/navigation that feeds candidates into Vertico. Bound via plain global chords:

  • C-sconsult-line (live in-buffer search).
  • C-x b — buffer switcher with previews.
  • M-y — browse the kill ring on paste.
  • M-g g — go to line; M-g i — jump via imenu.
  • ? in consult-narrow-map — list the narrowing keys (consult-narrow-help) while a multi-source prompt is open.
They're a set These four packages are coupled: changing one (e.g. completion-styles) affects how all of them behave.
(use-package consult-dir
  :bind (("C-x C-d" . consult-dir)
         :map vertico-map
         ("C-x C-d" . consult-dir)
         ("C-x C-j" . consult-dir-jump-file)))

consult-dir switches the directory context from the minibuffer. C-x C-d globally jumps to a directory (recent dirs, projectile roots, bookmarks); the same chord inside an active find-file/consult prompt re-roots it at the chosen directory without restarting, and C-x C-j fuzzy-jumps to any file beneath it. Also on the leader at SPC f d and as fd in normal state.

Pure autoload deferral No :after (consult vertico) — that would force-load it the moment both are up. The :bind autoloads the commands and installs the bindings; the package itself loads on first use, and the vertico-map binding still resolves once vertico-mode has run.
(use-package embark
  :bind (("s-]" . embark-act)
         ("M-." . embark-dwim)
         :map help-map
         ("b" . embark-bindings))
  :init
  (setq prefix-help-command #'embark-prefix-help-command))

(use-package embark-consult
  :after (embark consult)
  :hook (embark-collect-mode . consult-preview-at-point-mode))

embark is the context-action layer: s-] opens actions for the current target or minibuffer candidate, and M-. runs the default action. C-h b / SPC h b are upgraded to embark-bindings, a searchable active-keybinding view.

embark-consult connects Embark exports and previews to Consult candidate buffers, so search results can be exported into editable or actionable buffers.

(use-package wgrep
  :commands (wgrep-change-to-wgrep-mode)
  :custom (wgrep-auto-save-buffer t))

wgrep makes grep result buffers editable. A typical flow is consult-ripgrep, embark-export, then C-c C-p to enter wgrep mode; edits are saved back to the touched files with C-c C-c. Auto-save is enabled so changed buffers are written when the wgrep edit is committed.

(use-package helpful
  :bind (:map help-map
         ("f" . helpful-callable)
         ("v" . helpful-variable)
         ("k" . helpful-key)
         ("x" . helpful-command)
         ("o" . helpful-symbol))
  :config
  (advice-add 'helpful--calculate-references :override #'ignore))

helpful replaces the common help commands under both C-h and SPC h. It shows source, values, callers, keybindings, and richer symbol context than the built-in help buffers.

Performance trade-off The references section is disabled because elisp-refs scans large source files and can make help for core symbols take seconds. Other Helpful sections stay intact.
(use-package ibuffer
  :ensure nil
  :bind (("C-x C-b" . ibuffer))
  :hook (ibuffer-mode . ibuffer-auto-mode)
  :custom
  (ibuffer-expert t)
  (ibuffer-show-empty-filter-groups nil))

(use-package ibuffer-projectile
  :hook (ibuffer-mode . ibuffer-projectile-set-filter-groups))

ibuffer is the bulk buffer-management view, bound to C-x C-b and SPC b i. Evil collection supplies familiar navigation and marking keys. ibuffer-auto-mode keeps the list live, and ibuffer-expert removes repetitive kill confirmations.

ibuffer-projectile groups buffers by Projectile project. Embark can also export a narrowed consult-buffer candidate set directly into ibuffer for bulk actions.

In-buffer completion — corfu & cape

The at-point completion popup, the counterpart to the vertico minibuffer stack.

(use-package corfu
  :defer t
  :init
  (add-hook 'emacs-startup-hook #'global-corfu-mode)
  :bind (:map corfu-map
              ("SPC" . corfu-insert-separator)
              ("RET" . corfu-insert)
              ("TAB" . corfu-next)
              ([tab] . corfu-next)
              ("S-TAB" . corfu-previous)
              ([backtab] . corfu-previous))
  :custom
  (corfu-auto t)
  (corfu-auto-prefix 2)
  (corfu-auto-delay 0.1)
  (corfu-cycle t)
  (corfu-preview-current nil)
  (corfu-quit-at-boundary nil)
  (corfu-preselect 'valid))

(use-package corfu-terminal
  :after corfu
  :config
  (unless (display-graphic-p)
    (corfu-terminal-mode 1)))

(use-package cape
  :after corfu
  :init
  (add-hook 'completion-at-point-functions #'cape-file)
  (add-hook 'completion-at-point-functions #'cape-dabbrev))

corfu is the at-point completion popup — vertico handles M-x/find-file prompts, corfu handles completion inside a buffer (e.g. the candidates lsp-mode produces while typing code). It pops up automatically after two characters, and corfu-preselect 'valid preselects the typed prefix only when it is itself a valid match — otherwise the first candidate is selected, so RET completes to a real candidate. While the popup is open, SPC inserts an orderless separator (to keep narrowing), RET completes, and TAB/S-TAB cycle candidates; corfu-preview-current nil stops the candidate showing as inserted-but-uncommitted text, and corfu-quit-at-boundary nil keeps the popup open across completion boundaries like / in paths.

corfu-terminal re-renders corfu's child-frame popup as a buffer overlay so it works under emacs -nw; the display-graphic-p guard makes it a no-op in a GUI frame. cape adds file-path and in-buffer-word (dabbrev) completion-at-point backends as fallbacks; in lsp-mode-managed buffers the LSP capf supplies code completion.

Off the critical path global-corfu-mode is armed on emacs-startup-hook, so the package loads only after the first frame paints — nothing can trigger completion before then anyway.

Git — magit, ediff, diff-hl

The Git porcelain, diff-session tweaks, and terminal hunk indicators.

(use-package magit
  :bind (("C-x g" . magit-status)
         :map magit-status-mode-map
         ("e" . neoemacs/magit-ediff-working-vs-head))
  :custom
  (magit-display-buffer-function
   #'magit-display-buffer-same-window-except-diff-v1)
  :config
  (defun neoemacs/magit-ediff-working-vs-head ()
    "Ediff the file at point's working-tree version against its HEAD version."
    (interactive)
    (let ((file (magit-current-file)))
      (if file
          (magit-ediff-compare "HEAD" nil file file)
        (call-interactively #'magit-ediff-dwim)))))

Magit is the Git interface. C-x g opens status. Inside the status buffer, e is rebound to neoemacs/magit-ediff-working-vs-head — a plain two-buffer ediff of the file-at-point's working-tree version against its HEAD version (magit-ediff-compare "HEAD" nil …, where the nil REVB picks the working tree; the index is never involved, unlike the three-way magit-ediff-show-working-tree). With no file at point it falls back to magit's default magit-ediff-dwim.

magit-display-buffer-function = the …same-window-except-diff-v1 variant: magit-status opens in the current window, while diffs and other secondary buffers still pop to another window.

(use-package transient
  :ensure nil
  :defer t
  :config
  (define-key transient-map (kbd "<escape>") #'transient-quit-one))

Transient is the popup-menu engine behind magit (and many other packages) — :ensure nil because it ships with Emacs, and :defer t keeps it off the startup path. This makes Esc an alias for C-g (transient-quit-one), so pressing Escape backs out of any open transient one level. Bound in transient-map, so it applies to every transient, not just magit's.

The Meta trade-off In a terminal, Esc is also the Meta prefix, so this slightly gives up Meta chords inside an open transient — acceptable here since transient popups rarely need them.
(use-package ediff
  :ensure nil
  :defer t
  :custom
  (ediff-split-window-function #'split-window-horizontally)
  (ediff-window-setup-function #'ediff-setup-windows-plain)
  :config
  (defun neoemacs--ediff-quit-no-confirm (orig-fn &rest args)
    "Run ORIG-FN with `y-or-n-p' auto-confirmed so ediff quits silently."
    (cl-letf (((symbol-function 'y-or-n-p) (lambda (&rest _) t)))
      (apply orig-fn args)))
  (advice-add 'ediff-quit :around #'neoemacs--ediff-quit-no-confirm))

Built-in ediff (:ensure nil), deferred until a diff session starts, and tuned two ways:

  • ediff-split-window-function = horizontal split → the two diff buffers sit side by side with a vertical divider, not stacked.
  • ediff-window-setup-function = plain → the control panel stays in the same frame instead of spawning a popup frame.
The quit hack ediff-quit hard-codes a y-or-n-p "Quit this Ediff session?" prompt. The :around advice temporarily rebinds y-or-n-p (via cl-letf, which restores it afterward) to always return t, so pressing q quits immediately — no confirmation.
(use-package diff-hl
  :defer t
  :init
  (add-hook 'emacs-startup-hook
            (lambda ()
              (global-diff-hl-mode 1)
              (diff-hl-margin-mode 1)))
  :custom
  (diff-hl-margin-symbols-alist '((insert    . "+")
                                  (delete    . "-")
                                  (change    . "!")
                                  (unknown   . "?")
                                  (ignored   . "i")
                                  (reference . " ")))
  :config
  (add-hook 'magit-pre-refresh-hook  #'diff-hl-magit-pre-refresh)
  (add-hook 'magit-post-refresh-hook #'diff-hl-magit-post-refresh)
  (add-hook 'dired-mode-hook #'diff-hl-dired-mode-unless-remote))

diff-hl shows version-control changes in the terminal margin with text glyphs, because fringe indicators are invisible in emacs -nw. It is enabled on emacs-startup-hook so it is ready after init without costing startup time.

The Magit hooks refresh indicators around stage/commit operations, and the dired hook shows per-file VC status in dirvish/dired buffers. The actual config also strips theme-applied background colors from diff-hl faces so the terminal shows readable +, -, and ! glyphs instead of solid color blocks.

Dired — dirvish

A polished file manager replacing dired globally.

(use-package dirvish
  :defer t
  :init
  (add-hook 'emacs-startup-hook (lambda () (dirvish-override-dired-mode 1)))
  (add-hook 'dired-mode-hook (lambda () (display-line-numbers-mode -1)))
  :custom
  (dirvish-attributes '(nerd-icons subtree-state))
  (dirvish-hide-details nil)
  (insert-directory-program (if (executable-find "gls") "gls" "ls"))
  (dired-listing-switches (if (executable-find "gls")
                              "-Al --group-directories-first"
                            "-Al"))
  (dirvish-hide-cursor nil)
  (dired-dwim-target t)
  :bind ("C-c f" . dirvish)
  :config
  (general-define-key
   :states 'normal
   :keymaps 'dired-mode-map
   "h" 'dired-up-directory
   "l" 'dired-find-file
   "TAB" 'dirvish-subtree-toggle
   "y" '(:ignore t :which-key "yank")
   "yl" 'dirvish-copy-file-true-path
   "yn" 'dirvish-copy-file-name
   "yp" 'dirvish-copy-file-path
   "yr" 'dirvish-copy-remote-path
   "yy" 'dired-do-copy))

Dirvish upgrades dired with previews and icons. dirvish-override-dired-mode makes it the default for all dired. It's armed from emacs-startup-hook: the autoload pulls in dirvish and dired (~70ms) that isn't needed until the first dired buffer, and the hook runs after the first paint but before any input — with nerd-icons and general (which the :config keybindings need) both guaranteed loaded by then.

  • The dired hook disables line numbers in file-manager buffers.
  • dirvish-attributes — show icons and subtree-expansion state. VC state is handled by diff-hl-dired-mode instead because it is visible in terminal margins.
  • dirvish-hide-details nil — keep the full ls -l detail columns visible.
  • dired-listing-switches-A "almost all" shows dotfiles but hides ./..; -l long format. When GNU ls (Homebrew coreutils gls) is present, --group-directories-first is added to sort directories ahead of files, and insert-directory-program is pointed at gls.
  • dirvish-hide-cursor nil — keep a real block cursor visible (dirvish normally hides it and relies on hl-line; here etcc renders a proper terminal block).
  • dired-dwim-target t — with two dired/dirvish panes, copy and rename default to the directory in the other pane.

C-c f opens dirvish. In normal state inside dired: h goes up a directory, l enters the file/dir, TAB toggles a subtree inline, and y is a "yank" prefix that copies the entry's name/path to the kill ring (yl true path, yn name, yp path, yr remote path), with yy kept as the classic dired-do-copy.

macOS / BSD ls --group-directories-first is a GNU ls extension; the BSD ls that ships with macOS rejects it. The executable-find "gls" guard keeps directory-first grouping where coreutils is installed and falls back to plain -Al otherwise, so dired never errors out.
(use-package diredfl
  :hook (dired-mode . diredfl-mode))

(use-package dired-x
  :ensure nil
  :hook (dired-mode . dired-omit-mode)
  :config
  (setq dired-omit-verbose nil))

diredfl colorizes the long-listing columns such as permissions, owner, group, size, and modification time. Since dirvish buffers derive from dired, the hook covers both.

dired-x enables dired-omit-mode, hiding uninteresting files such as lock/autosave entries and common compiled artifacts. The omit message is silenced with dired-omit-verbose nil.

Terminal integration

Keyboard protocol, clipboard, embedded terminal.

(use-package kkp
  :ensure nil
  :load-path "~/code/kkp"
  :config
  (global-kkp-mode 1))

(define-key key-translation-map (kbd "M-S-]") (kbd "M-}"))
(define-key key-translation-map (kbd "M-S-[") (kbd "M-{"))
(define-key key-translation-map (kbd "M-S-9") (kbd "M-("))
(define-key key-translation-map (kbd "M-S-0") (kbd "M-)"))
(define-key key-translation-map (kbd "M-S-j") (kbd "M-J"))
(define-key key-translation-map (kbd "M-S-s") (kbd "M-S"))
(define-key key-translation-map (kbd "M-S-r") (kbd "M-R"))
(define-key key-translation-map (kbd "M-S-l") (kbd "M-L"))
(define-key key-translation-map (kbd "M-S-h") (kbd "M-H"))

kkp enables the Kitty Keyboard Protocol in terminal Emacs, so chords the terminal would otherwise swallow (e.g. C-S-x, distinguishing C-i from Tab) actually reach Emacs.

Under kkp the terminal delivers a shifted Meta chord as a distinct event rather than folding Shift into the base key, so without help M-}, M-(, M-J, etc. would be unreachable. The key-translation-map entries re-map each M-S-… form to the symbol/upper-case key the commands actually bind.

Side effect (see direnv) kkp re-encodes C-g as an escape sequence instead of the raw byte 7, which breaks Emacs's low-level quit detection during blocking calls — handled later by the envrc advice.
Temporary local clone kkp is loaded from ~/code/kkp (:ensure nil + :load-path) instead of ELPA: the MELPA build restores KKP after the envrc advice with a bare stack pop, which zellij — implementing the protocol without the flag stack — treats as a plain disable, leaving kkp dead after the first envrc--export. The clone carries the fix (PR #36); revert to the plain ELPA package once it's merged and on MELPA.
(use-package clipetty
  :hook (after-init . global-clipetty-mode))

clipetty sends kills (copies) to the host system clipboard via the OSC 52 escape sequence, so yanking in terminal Emacs works even over SSH and through tmux. Enabled on after-init.

(use-package ghostel
  :commands (ghostel)
  :bind ("s-t" . neoemacs/vsplit-ghostel)
  :init
  (add-hook 'ghostel-pre-spawn-hook #'neoemacs--ghostel-tag-env)
  :hook (ghostel-mode . (lambda () (display-line-numbers-mode -1))))

(use-package evil-ghostel
  :after (ghostel evil)
  :hook (ghostel-mode . evil-ghostel-mode)
  :config
  ...)

ghostel is a terminal emulator powered by libghostty; its native module is a prebuilt binary that auto-downloads on first use. :commands (ghostel) sets up the autoload, and s-t (and SPC t) run the vsplit-and-launch helper from earlier, rooting the terminal at the buffer's project root (SPC u t uses the current directory instead). Terminal buffers turn line numbers off locally because the gutter is not useful there, and the :init hook tags every spawned terminal for Claude Code session tracking (see the Claude section below).

evil-ghostel keeps the terminal cursor in sync with Emacs point across Evil state changes, so normal-state hjkl navigation works inside the terminal buffer. It hooks onto ghostel-mode. Its :config block (broken out in the rows below) adds Escape routing and C-c/C-x passthrough; the redraw-anchor and wheel-scroll advices that used to live here moved upstream (last row of this section).

  (defvar neoemacs/ghostel-escape-timeout 0.25
    "Seconds to wait for a second ESC in ghostel insert state.")

  (defun neoemacs/ghostel--escape-event-p (event)
    "Return non-nil when EVENT is an Escape key event."
    (or (eq event 'escape)
        (and (integerp event) (= event ?\e))))

  (defun neoemacs/ghostel--evil-insert-escape ()
    "Run Evil's insert-state Escape binding."
    (let ((cmd (lookup-key evil-insert-state-map (kbd "<escape>"))))
      (call-interactively (if (commandp cmd) cmd #'evil-force-normal-state))))

  (defun neoemacs/ghostel-escape-dwim ()
    "Send a single ESC to ghostel, but let double ESC leave insert state."
    (interactive)
    (let ((event (with-timeout (neoemacs/ghostel-escape-timeout nil)
                   (read-key nil t))))
      (if (neoemacs/ghostel--escape-event-p event)
          (neoemacs/ghostel--evil-insert-escape)
        (when event
          (setq unread-command-events (cons event unread-command-events)))
        (ghostel-send-key "escape"))))

Escape DWIM. A single Esc is something a terminal program legitimately wants (vi, less, menus), but Esc is also how you leave Evil's insert state. This disambiguates by timing.

  • read-key inside with-timeout waits up to neoemacs/ghostel-escape-timeout (0.25s) for a second key. read-key is used because it decodes KKP/input-decode-map sequences; raw read-event can leak bytes to ghostel as control characters.
  • If that second key is itself an Escape (--escape-event-p), the user meant "leave insert" → run Evil's normal insert-state Esc binding and send nothing to the terminal.
  • Otherwise it was a lone Esc (or Esc followed by another key): push the stray key back onto unread-command-events so it isn't lost, and forward a real escape to ghostel.
Esc vs Esc Esc So: Esc = "send Escape to the terminal", Esc Esc = "exit insert state." This replaces the plain insert-state Esc binding that evil-ghostel installs.
  (defun neoemacs/ghostel-send-current-control ()
    "Send the current Ctrl+letter key to ghostel."
    (interactive)
    (let ((base (event-basic-type last-command-event)))
      (unless (and (integerp base) (<= ?a base) (<= base ?z))
        (user-error "Not a Ctrl+letter key: %S" last-command-event))
      (ghostel-send-key (string base) "ctrl")))

  (evil-define-key* 'insert evil-ghostel-mode-map
                    (kbd "<escape>") #'neoemacs/ghostel-escape-dwim
                    (kbd "C-c") #'neoemacs/ghostel-send-current-control
                    (kbd "C-x") #'neoemacs/ghostel-send-current-control)

Ctrl passthrough. C-c and C-x are precious Emacs prefixes, but inside a terminal you usually want them to reach the program running there (C-c to interrupt, etc.). ghostel-send-current-control recovers the base letter from last-command-event via event-basic-type and forwards it as a real Ctrl chord with ghostel-send-key; the guard user-errors if it's somehow not a Ctrl+letter.

The evil-define-key* wires up the insert-state map: Esc → the DWIM handler, C-c/C-x → the passthrough.

Roaming & wheel scroll (now upstream). Letting normal-state motion roam over animated output, and keeping a mouse-wheel scroll into scrollback from snapping point back to the live cursor, both used to be local advices here (evil-ghostel-roam around ghostel--anchor-window, and evil-ghostel-wheel-normal before mwheel-scroll). The upgraded evil-ghostel subsumes them: it registers evil-ghostel--anchor-inhibit on ghostel's ghostel-inhibit-anchor-functions hook so the per-redraw anchor stands down in a motion-capable Evil state, and ghostel itself intercepts the wheel and gates point-sync on ghostel--window-anchored-p. So both advices were removed from init.el.

Claude Code session tracking

A live status switcher for Claude Code sessions running inside ghostel terminals.

(defvar neoemacs--ghostel-buffers-by-id
  (make-hash-table :test 'equal)
  "Map ghostel ids to their buffers for Claude Code status side effects.")

(defun neoemacs--ghostel-tag-env ()
  "`ghostel-pre-spawn-hook': tag this terminal and export its id to the child."
  (let ((id (format "ghostel-%d-%d" (emacs-pid)
                    (cl-incf neoemacs--ghostel-id-counter))))
    (setq neoemacs--ghostel-id id)
    (puthash id (current-buffer) neoemacs--ghostel-buffers-by-id)
    (add-hook 'kill-buffer-hook #'neoemacs--ghostel-unregister-id nil t)
    (setenv "NEOEMACS_GHOSTEL_ID" id)
    ;; Register as `spawned' so a later SessionStart has an entry to flip.
    (consult-claude-register id (current-buffer) default-directory)))

The live session switcher itself lives in the separate, terminal-agnostic consult-claude package (below): it owns the in-memory registry, the status RPC (consult-claude-status), the marginalia annotator, and the consult-claude-sessions picker reached at fc in normal state. Only the ghostel-specific glue stays here.

neoemacs--ghostel-tag-env runs on ghostel-pre-spawn-hook: it stamps each new terminal with a unique id, exports it as $NEOEMACS_GHOSTEL_ID (so the shell and its children — including Claude Code — can echo it back), records the buffer in a hash table, and pre-registers the entry with consult-claude-register so a later status report has something to flip. A buffer-local kill-buffer-hook forgets the id when the terminal dies.

How status flows in Claude Code hooks in ~/.claude/settings.json shell out through the per-PID $EDITOR socket (see the Server section) to call consult-claude-status back into this Emacs, flipping each entry between idle/working/ waiting/done as the session progresses.
(use-package consult-claude
  :ensure nil
  :load-path "~/code/consult-claude"
  :commands (consult-claude-sessions consult-claude-register
             consult-claude-status))

consult-claude is the terminal-agnostic package that owns the registry, the status RPC, the marginalia annotator, and the consult-claude-sessions picker. The ghostel glue above (tag-env + register) is all that init.el needs to feed it.

Deferred :commands keeps consult-claude (loaded from ~/code/consult-claude via :load-path) unloaded until the first registration, status RPC, or picker call.

Project navigation & languages

Projectile, markdown, tree-sitter modes, lsp-mode (LSP), formatting, and Clojure tooling.

(use-package projectile
  :defer t
  :bind-keymap ("C-c p" . projectile-command-map)
  :config
  (projectile-mode 1))

projectile provides project-aware navigation. :defer t keeps it off the startup path, and :bind-keymap installs an autoloaded C-c p prefix that loads projectile-command-map on demand. projectile-mode turns on once the package is actually loaded.

The prefix trap Don't bind a sub-key like C-c p SPC globally from another package — at bind time C-c p isn't yet a real prefix and Emacs errors with "starts with non-prefix key." Bind into projectile-command-map or go through the leader.
(use-package markdown-mode
  :mode (("README\\.md\\'" . gfm-mode)
         ("\\.md\\'"       . markdown-mode)
         ("\\.markdown\\'" . markdown-mode))
  :custom
  (markdown-enable-wiki-links t)
  (markdown-wiki-link-search-subdirectories t)
  (markdown-link-space-sub-char " "))

markdown-mode with file-pattern associations. README.md gets gfm-mode (GitHub-Flavored Markdown); other .md / .markdown files get plain markdown-mode. The \\' anchors match the end of the filename.

Wiki links ([[note]]) are enabled and tuned for Obsidian vaults: markdown-wiki-link-search-subdirectories resolves a bare name to a file anywhere under the tree (Obsidian flattens names), and markdown-link-space-sub-char " " keeps link text matching real filenames with spaces. Follow the link under point with C-c C-o.

(setq treesit-language-source-alist
      '((astro      "https://github.com/virchau13/tree-sitter-astro")
        (css        "https://github.com/tree-sitter/tree-sitter-css")
        (clojure    "https://github.com/sogaiu/tree-sitter-clojure")
        (typescript "https://github.com/tree-sitter/tree-sitter-typescript" nil "typescript/src")
        (tsx        "https://github.com/tree-sitter/tree-sitter-typescript" nil "tsx/src")))

(defun neoemacs--ensure-treesit-grammars (&rest langs)
  (dolist (lang langs)
    (unless (treesit-language-available-p lang)
      (treesit-install-language-grammar lang))))

(use-package typescript-ts-mode
  :ensure nil
  :mode (("\\.ts\\'"  . typescript-ts-mode)
         ("\\.tsx\\'" . tsx-ts-mode))
  :config
  (neoemacs--ensure-treesit-grammars 'typescript 'tsx))

(use-package astro-ts-mode
  :mode "\\.astro\\'"
  :config
  (neoemacs--ensure-treesit-grammars 'astro 'css 'tsx))

(use-package clojure-ts-mode
  :mode (("\\.clj\\'"  . clojure-ts-mode)
         ("\\.cljs\\'" . clojure-ts-clojurescript-mode)
         ("\\.cljc\\'" . clojure-ts-clojurec-mode)
         ("\\.edn\\'"  . clojure-ts-mode))
  :config
  (neoemacs--ensure-treesit-grammars 'clojure))

Tree-sitter major modes. treesit-language-source-alist is populated eagerly (just an alist) so each grammar's fetch/build recipe is known; neoemacs--ensure-treesit-grammars runs the slow git-clone + C compile lazily from a mode's :config, and only when a grammar is missing — never on the startup path.

  • typescript-ts-mode / tsx-ts-mode ship with Emacs (:ensure nil), for .ts / .tsx.
  • astro-ts-mode for .astro — needs the css + tsx grammars too, since Astro injects other languages into a single file.
  • clojure-ts-mode family for .clj / .cljs / .cljc / .edn.
(use-package lsp-mode
  :defer t
  :hook (((astro-ts-mode typescript-ts-mode tsx-ts-mode
           clojure-ts-mode clojure-ts-clojurescript-mode
           clojure-ts-clojurec-mode)
          . lsp-deferred)
         (lsp-completion-mode . neoemacs/lsp-completion-orderless))
  :init
  (setq lsp-keymap-prefix "C-c l")
  :custom
  (lsp-auto-guess-root t)              ; no "import project root?" prompt
  (lsp-lens-enable t)                  ; "N references | M tests" overlays
  (lsp-completion-provider :none)      ; corfu owns the capf, no company
  (lsp-diagnostics-provider :flymake)  ; flycheck isn't installed
  (lsp-headerline-breadcrumb-enable nil)
  :config
  (evil-define-key 'normal lsp-mode-map
    "gd" #'lsp-find-definition
    "gr" #'lsp-find-references)
  (setq read-process-output-max (* 1024 1024)))

(use-package consult-lsp
  :after lsp-mode
  :bind (:map lsp-mode-map
              ([remap xref-find-apropos] . consult-lsp-symbols)))

lsp-mode is fully deferred: lsp-deferred on the language hooks arms LSP without loading lsp-mode at startup — it loads the first time one of those modes turns on, and (unlike plain lsp) waits until the buffer is displayed before starting a server. Leader actions live under SPC c (ca code actions, cr rename, cf format, cd diagnostics); in LSP buffers gd and gr jump to definitions and references; the long tail of LSP commands is on C-c l.

All three servers ship as built-in lsp-mode clients: astro-ls (lsp-astro points tsdk at the project's own node_modules/typescript), typescript-language-server for TS/TSX, and clojure-lsp for the tree-sitter Clojure modes. clojure-lsp bundles clj-kondo, so linting arrives over flymake with no separate linter. Code lenses show a grey "N references | M tests" overlay above each definition — the test/reference split is clojure-lsp's :lens-segregate-test-references?, set in ~/.config/clojure-lsp/config.edn. Completion goes through the plain LSP capf (lsp-completion-provider :none) so corfu consumes it directly, matched with orderless like everything else. lsp-auto-guess-root skips the interactive "import project root?" prompt by taking the workspace root from projectile/project.el. Requires the matching server binaries on PATH.

consult-lsp surfaces LSP data through the consult minibuffer UI, with live preview as you move: SPC c s file symbols, SPC c S workspace-wide symbol search, SPC c D workspace diagnostics (the buffer-local flymake list stays on SPC c d). In lsp buffers xref-find-apropos (C-M-.) is remapped to consult-lsp-symbols.

(use-package apheleia
  :defer t
  :init
  (add-hook 'emacs-startup-hook #'apheleia-global-mode)
  :config
  (add-to-list 'apheleia-mode-alist '(astro-ts-mode . prettier)))

;; Structural editing on the Lisp-family modes: the tree-sitter Clojure modes
;; AND emacs-lisp-mode / lisp-interaction-mode.
(use-package smartparens
  :hook ((emacs-lisp-mode lisp-interaction-mode
          clojure-ts-mode clojure-ts-clojurescript-mode
          clojure-ts-clojurec-mode)
         . smartparens-strict-mode)
  :config
  (require 'smartparens-config))

(use-package evil-cleverparens
  :hook ((emacs-lisp-mode lisp-interaction-mode
          clojure-ts-mode clojure-ts-clojurescript-mode
          clojure-ts-clojurec-mode)
         . evil-cleverparens-mode)
  :bind (:map evil-cleverparens-mode-map
              ("M-5" . evil-cp-wrap-next-square)
              ("M-]" . evil-cp-wrap-previous-square))
  :init
  (setq evil-cleverparens-use-additional-bindings t)
  (setq evil-cleverparens-use-s-and-S nil)   ; keep `s' free for avy
  :config
  (evil-define-key 'normal evil-cleverparens-mode-map
    "S" 'evil-cp-change-whole-line))

(use-package rainbow-delimiters
  :hook ((emacs-lisp-mode lisp-interaction-mode
          clojure-ts-mode clojure-ts-clojurescript-mode
          clojure-ts-clojurec-mode)
         . rainbow-delimiters-mode))

(use-package cider
  :after clojure-ts-mode
  :custom
  (cider-repl-display-help-banner nil)
  (cider-repl-pop-to-buffer-on-connect 'display-only)
  (cider-save-file-on-load t)
  (cider-font-lock-dynamically '(macro core function var)))

apheleia reformats on save asynchronously — it diffs the formatter output back in, so point/scroll are preserved and the UI never blocks (preferable to lsp-format-buffer on save in a terminal). Armed via apheleia-global-mode on emacs-startup-hook. Astro maps to prettier; TS/TSX use apheleia's defaults.

Lisp structural editing. Three layers ride on the Lisp-family modes — the tree-sitter Clojure modes and emacs-lisp-mode / lisp-interaction-mode: smartparens in strict mode refuses edits that would unbalance a sexp (smartparens-config loads the default pairs); evil-cleverparens adds paredit-style slurp/barf/wrap through evil motions (M-5 / M-] wrap the next/previous form in square brackets); and rainbow-delimiters depth-colors the parens. evil-cleverparens-use-s-and-S is nil because minor-mode maps beat evil-normal-state-map — cleverparens' s would shadow the avy jump in every Lisp buffer; the paren-safe S (evil-cp-change-whole-line) is re-added on its own.

Clojure REPL. cider is the nREPL runtime half — REPL, inline eval, test runner — complementary to clojure-lsp's static analysis (they run together); :after clojure-ts-mode keeps it deferred, and C-c C-j jacks in a REPL (needs clojure/clj or lein on PATH).

Environment — direnv

Per-directory environment, plus the C-g abort fix.

(use-package envrc
  :hook (after-init . envrc-global-mode)
  :config
  ;; kkp re-encodes C-g as an escape sequence, so it can't abort the blocking
  ;; direnv `call-process'. `kkp-restore-legacy-keys' restores the raw C-g
  ;; byte for the duration of the call (no-op when kkp isn't active).
  (advice-add 'envrc--export :around #'kkp-restore-legacy-keys))

envrc applies each buffer's directory .envrc via direnv (needs the direnv executable on PATH).

Why after-init The global mode must layer on top of the other global modes, so it's enabled late and deliberately — don't move it earlier.
The C-g fix envrc--export runs direnv through a synchronous call-process and advertises "C-g to abort." That abort relies on Emacs seeing the raw C-g byte (ASCII 7) — but kkp re-encodes it as ESC [ 103;5 u, so the blocking call never sees the quit. kkp's own kkp-restore-legacy-keys :around advice restores the raw byte for the duration of the export and puts kkp back afterward no matter what. When kkp isn't active (e.g. GUI), it just calls through.

Server / $EDITOR

Hand work to this running Emacs via emacsclient instead of spawning a nested one.

(use-package server
  :ensure nil
  :defer t
  :init
  (add-hook 'emacs-startup-hook
            (lambda ()
              (require 'server)
              (setq server-name (format "neoemacs-%d" (emacs-pid)))
              (unless (server-running-p server-name)
                (server-start))
              (setenv "EDITOR" (format "emacsclient -s %s" server-name))))
  :config
  (defun neoemacs--server-buffer-keys ()
    "Bind client finish/abort keys locally in a plain emacsclient buffer."
    (unless (bound-and-true-p with-editor-mode)
      (local-set-key (kbd "C-c C-c") #'server-edit)
      (local-set-key (kbd "C-c C-k") #'server-edit-abort)
      (when (fboundp 'evil-local-set-key)
        (evil-local-set-key 'normal (kbd "ZZ") #'server-edit)
        (evil-local-set-key 'normal (kbd "ZQ") #'server-edit-abort))))
  (add-hook 'server-switch-hook #'neoemacs--server-buffer-keys))

An Emacs server lets emacsclient hand work to this running Emacs (git commit messages, anything shelling out to $EDITOR from the ghostel terminal) instead of spawning a nested Emacs. The server name is made unique per process by appending the PID (neoemacs-<pid>), so concurrent instances each get their own socket rather than colliding on the default server name. $EDITOR is then pointed at that socket. It's all deferred to emacs-startup-hook, off the critical path.

Buffer-local finish/abort Keys are bound only in each emacsclient buffer via server-switch-hookC-c C-c / ZZ finish, C-c C-k / ZQ abort — so evil's global ZZ/ZQ stay intact everywhere else. The C-c chords fire from insert and normal alike; ZZ/ZQ are normal-only to match Vim. The with-editor-mode guard skips magit commit buffers (opened through this same server), which already bind their own finishers.

Zellij tab name

Keep the focused tab named after the current buffer's location.

(defun neoemacs--parent-and-dir (dir)
  "Return \"<parent>/<dir>\" for absolute DIR (just the dir name if no parent)."
  (let* ((dir (directory-file-name (expand-file-name dir)))
         (name (file-name-nondirectory dir))
         (parent (file-name-nondirectory
                  (directory-file-name (file-name-directory dir)))))
    (if (string-empty-p parent) name (concat parent "/" name))))

A formatting helper. Given an absolute directory, it returns <parent>/<dir> — e.g. ~/.config/neoemacsconfig/neoemacs.

  • directory-file-name + expand-file-name normalize the path (strip trailing slash, make absolute).
  • name is the last component; parent is the one above.
  • If there's no parent (root), just return the name.
(defun neoemacs--zellij-tab-name ()
  "Compute the zellij tab name for the current buffer, or nil to leave it."
  (cond
   ((and (fboundp 'projectile-project-root) (projectile-project-root))
    (neoemacs--parent-and-dir (projectile-project-root)))
   ((derived-mode-p 'dired-mode)
    (neoemacs--parent-and-dir default-directory))
   (buffer-file-name
    (neoemacs--parent-and-dir (file-name-directory buffer-file-name)))
   (t nil)))

Picks which directory names the tab, in precedence order:

  1. Inside a projectile project → the project root.
  2. Else a dired buffer → the listed directory.
  3. Else a file-visiting buffer → the file's directory.
  4. Otherwise nil → leave the tab name unchanged.

fboundp guards the projectile call in case it isn't loaded yet.

(defun neoemacs--zellij-update-tab-name (&rest _)
  "Rename the focused zellij tab to reflect the selected window's buffer.
The last name is remembered per-frame ..."
  (when (getenv "ZELLIJ")
    (with-current-buffer (window-buffer (selected-window))
      (let ((name (neoemacs--zellij-tab-name))
            (last (frame-parameter nil 'neoemacs--zellij-last-tab-name)))
        (when (and name (not (equal name last)))
          (set-frame-parameter nil 'neoemacs--zellij-last-tab-name name)
          (when (executable-find "zellij")
            (start-process "zellij-rename-tab" nil
                           "zellij" "action" "rename-tab" name)))))))

The worker. Gated on the $ZELLIJ env var, so it's a no-op outside zellij. It looks at the selected window's buffer (the hooks may fire with a different current buffer), computes the name, and compares it to the last name stored per frame (each Emacs frame maps to its own zellij pane).

Only when the name actually changed does it update the frame parameter and shell out. The executable-find guard avoids errors if the env var is present but the binary is unavailable, and start-process runs asynchronously with output discarded, so buffer switches never block on the subprocess.

Why per-frame dedup Two frames don't clobber each other's "last name" state, and redundant hook firings are cheap — no zellij process is spawned unless the computed name differs.
(dolist (hook '(window-selection-change-functions
                window-buffer-change-functions
                dired-after-readin-hook
                dirvish-setup-hook))
  (add-hook hook #'neoemacs--zellij-update-tab-name))

Registers the worker on the full range of context changes:

  • window-selection-change-functions — window focus moved.
  • window-buffer-change-functions — a window's buffer changed (e.g. switch-to-buffer).
  • dired-after-readin-hook / dirvish-setup-hook — directory navigation.
Why the dired hooks too In-place directory navigation changes the buffer/directory without changing the selected window, so the two window hooks alone would miss it.
(provide 'init)
;;; init.el ends here

provide 'init registers the feature so require works, and the trailing comment is the conventional Emacs Lisp file footer.

There are no custom-set-variables / custom-set-faces blocks here: custom-file is pointed at a separate custom.el (see the core settings above), so Emacs's Custom system writes its machine-generated blocks there instead of appending them to init.el.