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.
.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.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.
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.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.
(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-stepis intentionally omitted — it's ignored wheneverscroll-conservativelyis > 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.
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.
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.
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.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.
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 nil— required so thatevil-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-styleevil-ex-searchfor/and?(incremental, n/N repeat, search highlighting,:soffsets) instead of the default isearch backend.evil-symbol-word-search t—*/#(and/) match the whole symbol under point, so a name likeevil-ex-searchis 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.
(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.
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.$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-followmakes the split and moves into it.evil-buffer-newshows 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.
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 x → M-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 —
fffind,fpconfig file,frrecent,fdswitch directory (consult-dir). - b buffers — switch / kill / ibuffer / next / prev, plus
bu→vundo(the visual undo tree). - p project —
ppswitch project,pffind file,pbproject buffer,psripgrep search. - g git — status, blame, file log, and diff-hl hunk navigation/stage/revert.
- o open —
ooopens the current file in Obsidian,ofopens it in its default macOS app,odreveals the current directory in Finder. - c code — lsp-mode code actions / rename / format and flymake diagnostics,
consult-lsp symbol/diagnostic pickers (
csfile symbols,cSworkspace symbols,cDworkspace diagnostics), pluscc→consult-claude-sessions(the live Claude Code session switcher). - u ghostel —
utopens a ghostel terminal in the current directory (thetvariant uses the project root). - h → the whole
help-commandmap.
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");ffin normal state →neoemacs/consult-recent-file;fdin normal state →neoemacs/consult-dir;fcin 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 inemacs-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 ageneral-simulate-keyalias that replays the realC-cprefix through the live keymaps, so, xruns whateverC-c xis bound to in the current buffer (including major-mode maps:, C-c→C-c C-c). Restricted to normal/visual/motion so a literal comma still types in insert — it shadows evil'srepeat-find-backwardsin those states.j/kmove by visual line, so navigation follows wrapped text instead of jumping a whole logical line;gj/gkkeep 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.
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.
(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-s—consult-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.?inconsult-narrow-map— list the narrowing keys (consult-narrow-help) while a multi-source prompt is open.
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.
: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.
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.
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.
(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.
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 bydiff-hl-dired-modeinstead because it is visible in terminal margins.dirvish-hide-details nil— keep the fullls -ldetail columns visible.dired-listing-switches—-A"almost all" shows dotfiles but hides./..;-llong format. When GNUls(Homebrew coreutilsgls) is present,--group-directories-firstis added to sort directories ahead of files, andinsert-directory-programis pointed atgls.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.
--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.
~/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-keyinsidewith-timeoutwaits up toneoemacs/ghostel-escape-timeout(0.25s) for a second key.read-keyis used because it decodes KKP/input-decode-map sequences; rawread-eventcan 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-eventsso it isn't lost, and forward a realescapeto ghostel.
(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.
~/.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.
: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.
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-modeship with Emacs (:ensure nil), for.ts/.tsx.astro-ts-modefor.astro— needs the css + tsx grammars too, since Astro injects other languages into a single file.clojure-ts-modefamily 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).
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.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.
server-switch-hook — C-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/neoemacs →
config/neoemacs.
directory-file-name+expand-file-namenormalize the path (strip trailing slash, make absolute).nameis the last component;parentis 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:
- Inside a projectile project → the project root.
- Else a dired buffer → the listed directory.
- Else a file-visiting buffer → the file's directory.
- 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.
(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.
(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.