The config, explained side by side
This is a personal terminal-Emacs configuration (emacs -nw inside
zellij) living at ~/.config/neoemacs. It bootstraps
package.el + use-package, 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 ---
(require 'package)
(setq package-archives
'(("gnu" . "https://elpa.gnu.org/packages/")
("melpa" . "https://melpa.org/packages/")))
(setq package-quickstart t)
(unless (load (locate-user-emacs-file "package-quickstart") 'noerror 'nomessage)
(package-initialize)
(package-quickstart-refresh))
require 'package loads the built-in package manager.
package-archives declares the two repositories packages are fetched
from: GNU ELPA (the official, copyright-assigned archive) and
MELPA (the large community archive). Each entry is a
(name . url) cons cell.
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.(unless (package-installed-p 'use-package) (package-refresh-contents) (package-install 'use-package)) (require 'use-package) (setq use-package-always-ensure t)
Self-bootstrapping: if use-package isn't installed yet (first ever
launch), download the archive contents index (package-refresh-contents)
and install it. On every subsequent launch the unless is a no-op.
require then loads it, and
use-package-always-ensure t makes every
use-package form auto-install its package from ELPA if missing.
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.
(if (boundp 'use-short-answers)
(setq use-short-answers t)
(defalias 'yes-or-no-p #'y-or-n-p))
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.
Modern Emacs (28+) exposes the use-short-answers variable, so when it's
boundp just set it. On older Emacs the variable doesn't exist, so the
else branch falls back to aliasing yes-or-no-p straight to the
already-short y-or-n-p.
(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-step 1
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-step 1— scroll one line at a time for keyboard motion, never recenter with a jump.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.
(use-package recentf
:ensure nil
:custom
(recentf-max-saved-items 100)
:init
(recentf-mode 1)
:config
(defun neoemacs--recentf-merge-on-save (&rest _)
(let ((mem recentf-list))
(recentf-load-list)
(setq recentf-list
(seq-take (delete-dups (append mem recentf-list))
recentf-max-saved-items))))
(advice-add 'recentf-save-list :before #'neoemacs--recentf-merge-on-save))
recentf tracks recently opened files (consumed by
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 on exit, so the
last one to quit would erase the other's history.
memsnapshots this instance's list.recentf-load-listre-reads what's currently on disk.append+delete-dupsmerges both, thenseq-takecaps it at the max.
:before The advice runs
right before every recentf-save-list, so the merged list is what
gets written — the last writer wins without discarding the other instance's entries.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-echo-state nil)
:config
(evil-mode 1))
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-echo-state nil— don't print-- INSERT --etc. in the echo area (the modeline already shows the state).
:config (evil-mode 1) turns it on globally.
(use-package evil-collection :after evil :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, …). :after evil
orders it after Evil.
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
:after evil
: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.
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 ()
"Open a vertical split, move focus into it, and launch ghostel there."
(interactive)
(neoemacs/vsplit-window-follow)
(evil-buffer-new)
(let ((placeholder (window-buffer))
(ghostel-buffer (ghostel '(4))))
(when (and (buffer-live-p placeholder)
(not (eq placeholder ghostel-buffer)))
(kill-buffer placeholder))))
Opens a terminal in a fresh split (bound to s-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.
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/dired-quick-look ()
"Preview the file under point in dired/dirvish via macOS Quick Look."
(interactive)
(let ((file (dired-get-filename nil t)))
(unless file
(user-error "No file on this line"))
(start-process "ql" nil "qlmanage" "-p" file)))
SPC f i previews the dired/dirvish file at point with macOS Quick
Look. It delegates to qlmanage -p asynchronously so terminal Emacs
does not block or need to render media itself.
(defun neoemacs/open-in-finder ()
"Reveal the current directory in macOS Finder."
(interactive)
(let ((dir (cond ((derived-mode-p 'dired-mode) (dired-current-directory))
(t default-directory))))
(start-process "open-finder" nil "open" (expand-file-name dir))))
SPC f o opens the relevant directory in Finder. In dired/dirvish
it follows the listed directory; elsewhere it uses default-directory.
(defun neoemacs/open-in-obsidian ()
"Open the current file in Obsidian."
(interactive)
(let ((file ...))
(let ((root (locate-dominating-file file ".obsidian")))
(unless root
(user-error "Not inside an Obsidian vault ..."))
(start-process "open-obsidian" nil "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")
"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" '(consult-recent-file :which-key "recent file")
"fi" '(neoemacs/dired-quick-look :which-key "quick look (dired)")
"fo" '(neoemacs/open-in-finder :which-key "open dir in Finder")
"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")
"p" '(:ignore t :which-key "project")
"pp" '(consult-projectile :which-key "switch project")
"pf" '(projectile-find-file :which-key "find file in project")
"pb" '(projectile-switch-to-buffer :which-key "project buffer")
"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-in-obsidian :which-key "open file in Obsidian")
"u" '(vundo :which-key "undo tree")
"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. Two top-level shortcuts:
SPC SPC → find file in project, SPC , → switch buffer.
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,fiQuick Look,foFinder. - b buffers — switch / kill / ibuffer / next / prev.
- p project —
ppswitch project,pffind file,pbproject buffer. - g git — status, blame, file log, and diff-hl hunk navigation/stage/revert.
- o open —
ooopens the current file in Obsidian. - u →
vundo, a visual undo tree. - 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 consult-projectile 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) (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-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").s-h/j/k/l— move between windows (the Super/Cmd key + hjkl).s-n— vertical split and follow;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.
(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 u, and the Unicode glyphs make the tree clearer in the terminal.
(use-package which-key :ensure nil :config (setq which-key-idle-delay 0.5) (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. which-key-idle-delay 0.5 waits
half a second before showing the popup.
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)))
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.
completion-styles) affects how all of them
behave.(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.
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" . magit-ediff-show-working-tree))
:custom
(magit-display-buffer-function
#'magit-display-buffer-same-window-except-diff-v1))
Magit is the Git interface. C-x g opens status.
Inside the status buffer, e is rebound to
magit-ediff-show-working-tree — diffing the working tree against HEAD in
ediff (overriding magit's default magit-ediff-dwim on that key).
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 . "!")))
: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
:after (nerd-icons general)
:init
(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))
Dirvish upgrades dired with previews and icons.
dirvish-override-dired-mode makes it the default for all dired.
:after (nerd-icons general) ensures icons and the keybinding DSL are
ready.
- 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.
--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 :config (global-kkp-mode 1))
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.
(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)
: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 runs the
vsplit-and-launch helper from earlier. Terminal buffers turn line numbers off
locally because the gutter is not useful there.
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, C-c/C-x
passthrough, and two advices that tame the redraw anchor on an animated terminal.
(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.
(define-advice ghostel--anchor-window
(:around (orig &optional window force) evil-ghostel-roam)
(if (and (bound-and-true-p evil-ghostel-mode)
(not force)
(memq evil-state '(normal visual operator motion))
ghostel--cursor-char-pos
(/= (point) ghostel--cursor-char-pos))
nil
(funcall orig window force)))
Roam advice. Each redraw, ghostel--redraw-now re-anchors any
window following the live viewport via ghostel--anchor-window, whose final
set-window-point snaps point back to the terminal cursor. On a static
terminal redraws are rare so it's invisible; on an animated one (~30fps) it fights
every hjkl in normal state.
This :around advice skips the anchor exactly when point is
parked off the live cursor ((/= (point) ghostel--cursor-char-pos)) in a
motion-capable Evil state. Auto-follow resumes the moment you return to insert state or land
back on the cursor row.
force non-nil, so they always call through to the original — only the
automatic, every-frame re-anchor is suppressed. (define-advice mwheel-scroll
(:before (event &rest _) evil-ghostel-wheel-normal)
(let ((buf (window-buffer (posn-window (event-start event)))))
(when (buffer-live-p buf)
(with-current-buffer buf
(when (and (bound-and-true-p evil-ghostel-mode)
(memq evil-state '(insert emacs)))
(evil-normal-state)))))))
Wheel scroll. In insert state the redraw anchor is still live, so a mouse-wheel scroll into scrollback gets immediately yanked back to the live cursor. Normal state is exactly where the roam advice above suppresses that anchor — so this flips the buffer to normal state on any wheel event.
- ghostel redispatches the wheel to
mwheel-scrollwhen it scrolls the Emacs buffer (mouse tracking off), so that's the advised function. - It only switches from
insert/emacs— normal/visual already roam, and flipping out of visual would drop a selection.
Project navigation & languages
Projectile and markdown.
(use-package projectile
:defer t
:bind-keymap ("C-c p" . projectile-command-map)
:config
(projectile-mode 1))
(use-package consult-projectile
:after (consult projectile))
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.
consult-projectile wires projectile into the consult/vertico UI;
it's reached at SPC p p through the leader.
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)))
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.
Environment — direnv
Per-directory environment, plus the C-g abort fix.
(use-package envrc
:hook (after-init . envrc-global-mode)
:config
(defun neoemacs--envrc-export-restore-quit (orig-fn &rest args)
"Run ORIG-FN with kkp disabled so C-g aborts the direnv `call-process'."
(if (and (fboundp 'kkp--this-terminal-has-active-kkp-p)
(kkp--this-terminal-has-active-kkp-p))
(let ((terminal (kkp--selected-terminal)))
(unwind-protect
(progn
(kkp--terminal-teardown terminal)
(apply orig-fn args))
(kkp-enable-in-terminal terminal)))
(apply orig-fn args)))
(advice-add 'envrc--export :around #'neoemacs--envrc-export-restore-quit))
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. The :around advice tears kkp down for the duration (restoring the
raw byte), runs the export, and an unwind-protect re-enables kkp
afterward no matter what. When kkp isn't active (e.g. GUI), it just calls through.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 (custom-set-variables ;; custom-set-variables was added by Custom. ) (custom-set-faces)
provide 'init registers the feature so require works.
The custom-set-variables / custom-set-faces blocks are
written by Emacs's Custom system — edit config by hand above
them, never inside. They are currently empty placeholders, but Emacs keeps them at
the end of the file.