init.el

neoemacs · annotated walkthrough

The config, explained side by side

This is a personal terminal-Emacs configuration (emacs -nw inside zellij) living at ~/.config/neoemacs. It bootstraps 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.

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

Gotcha Because of always-ensure, packages that ship with Emacs (like recentf, which-key, ediff) must add :ensure nil — otherwise use-package tries to fetch them from ELPA and fails.

Core editor settings

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

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

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

(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.

The bug this fixes Without it, the terminal's "alternate scroll" translates the wheel into Up/Down arrow keys, which move point (the cursor) instead of scrolling the view. Trade-off: with mouse mode on, text selection now uses Emacs's mouse, not the terminal's — hold Shift/Fn for native terminal selection.
(setq mouse-wheel-follow-mouse t
      mouse-wheel-progressive-speed nil
      mouse-wheel-scroll-amount '(2 ((shift) . 1) ((control) . text-scale))
      scroll-conservatively 101
      scroll-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.
Deliberately omitted scroll-preserve-screen-position is left nil on purpose — setting it pins point to a screen row so the cursor tracks the scroll, the opposite of what's wanted. Inherent limit: point must stay visible, so scrolling far enough still drags the cursor along at the window edge.
(setq display-line-numbers-type t)
(global-display-line-numbers-mode 1)
(global-hl-line-mode 1)

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

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

(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.

  • mem snapshots this instance's list.
  • recentf-load-list re-reads what's currently on disk.
  • append + delete-dups merges both, then seq-take caps it at the max.
Why :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.

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

Evil — Vim emulation

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

(use-package evil
  :init
  (setq evil-want-integration t
        evil-want-keybinding nil
        evil-want-C-u-scroll t
        evil-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 nilrequired so that evil-collection (next) provides the keybindings instead of Evil's own defaults; the two would clash otherwise.
  • evil-want-C-u-scroll t — restore Vim's C-u half-page scroll (Emacs normally uses C-u as a prefix arg).
  • evil-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.

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

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

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

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

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

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

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

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

(use-package evil-goggles
  :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-follow makes the split and moves into it.
  • evil-buffer-new shows an empty *new* buffer there.
  • (ghostel '(4)) launches the terminal. The non-numeric prefix arg '(4) forces a new terminal rather than reusing an existing one.
The cleanup dance evil-buffer-new puts its placeholder in the window via set-window-buffer without making it current — so it's grabbed back with (window-buffer). Ghostel then swaps in its own buffer; the when kills the leftover placeholder, but only if it's still alive and genuinely different from the ghostel buffer.
(defun neoemacs/describe-symbol-at-point ()
  "Describe the symbol under point without prompting in the minibuffer."
  (interactive)
  (let ((sym (symbol-at-point)))
    (if sym
        (progn
          (helpful-symbol sym)
          (when-let ((win (seq-find
                           (lambda (w)
                             (provided-mode-derived-p
                              (buffer-local-value 'major-mode (window-buffer w))
                              'helpful-mode))
                           (window-list))))
            (select-window win)))
      (user-error "No symbol at point"))))

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

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

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

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

(defun neoemacs/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 — ff find, fp config file, fr recent, fi Quick Look, fo Finder.
  • b buffers — switch / kill / ibuffer / next / prev.
  • p project — pp switch project, pf find file, pb project buffer.
  • g git — status, blame, file log, and diff-hl hunk navigation/stage/revert.
  • o open — oo opens the current file in Obsidian.
  • uvundo, a visual undo tree.
  • h → the whole help-command map.
Why SPC p p Projectile's command map is reached at C-c p, which isn't a real prefix until projectile loads — so 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 in emacs-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.

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

vundo visualizes the built-in undo history as a tree. It does not replace Emacs undo and does not create persistent undo-history files. It is reached via SPC 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.

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

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

  • C-sconsult-line (live in-buffer search).
  • C-x b — buffer switcher with previews.
  • M-y — browse the kill ring on paste.
  • M-g g — go to line; M-g i — jump via imenu.
They're a set These four packages are coupled: changing one (e.g. 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.

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

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

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

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

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.

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

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

  • ediff-split-window-function = horizontal split → the two diff buffers sit side by side with a vertical divider, not stacked.
  • ediff-window-setup-function = plain → the control panel stays in the same frame instead of spawning a popup frame.
The quit hack ediff-quit hard-codes a y-or-n-p "Quit this Ediff session?" prompt. The :around advice temporarily rebinds y-or-n-p (via cl-letf, which restores it afterward) to always return t, so pressing q quits immediately — no confirmation.
(use-package diff-hl
  :defer t
  :init
  (add-hook 'emacs-startup-hook
            (lambda ()
              (global-diff-hl-mode 1)
              (diff-hl-margin-mode 1)))
  :custom
  (diff-hl-margin-symbols-alist '((insert . "+")
                                  (delete . "-")
                                  (change . "!")))
  :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 by diff-hl-dired-mode instead because it is visible in terminal margins.
  • dirvish-hide-details nil — keep the full ls -l detail columns visible.
  • dired-listing-switches-A "almost all" shows dotfiles but hides ./..; -l long format. When GNU ls (Homebrew coreutils gls) is present, --group-directories-first is added to sort directories ahead of files, and insert-directory-program is pointed at gls.
  • dirvish-hide-cursor nil — keep a real block cursor visible (dirvish normally hides it and relies on hl-line; here etcc renders a proper terminal block).
  • dired-dwim-target t — with two dired/dirvish panes, copy and rename default to the directory in the other pane.

C-c f opens dirvish. In normal state inside dired: h goes up a directory, l enters the file/dir, TAB toggles a subtree inline.

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

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

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

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

Terminal integration

Keyboard protocol, clipboard, embedded terminal.

(use-package kkp
  :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.

Side effect (see direnv) kkp re-encodes C-g as an escape sequence instead of the raw byte 7, which breaks Emacs's low-level quit detection during blocking calls — handled later by the envrc advice.
(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-key inside with-timeout waits up to neoemacs/ghostel-escape-timeout (0.25s) for a second key. read-key is used because it decodes KKP/input-decode-map sequences; raw read-event can leak bytes to ghostel as control characters.
  • If that second key is itself an Escape (--escape-event-p), the user meant "leave insert" → run Evil's normal insert-state Esc binding and send nothing to the terminal.
  • Otherwise it was a lone Esc (or Esc followed by another key): push the stray key back onto unread-command-events so it isn't lost, and forward a real escape to ghostel.
Esc vs Esc Esc So: Esc = "send Escape to the terminal", Esc Esc = "exit insert state." This replaces the plain insert-state Esc binding that evil-ghostel installs.
  (defun neoemacs/ghostel-send-current-control ()
    "Send the current Ctrl+letter key to ghostel."
    (interactive)
    (let ((base (event-basic-type last-command-event)))
      (unless (and (integerp base) (<= ?a base) (<= base ?z))
        (user-error "Not a Ctrl+letter key: %S" last-command-event))
      (ghostel-send-key (string base) "ctrl")))

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

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

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

  (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 is honored Forced anchors (paste/yank) pass 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-scroll when 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.
Getting back Press i/a to return to insert and resume live auto-follow.

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.

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

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).

Why after-init The global mode must layer on top of the other global modes, so it's enabled late and deliberately — don't move it earlier.
The C-g fix envrc--export runs direnv through a synchronous call-process and advertises "C-g to abort." That abort relies on Emacs seeing the raw C-g byte (ASCII 7) — but kkp re-encodes it as ESC [ 103;5 u, so the blocking call never sees the quit. 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/neoemacsconfig/neoemacs.

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

Picks which directory names the tab, in precedence order:

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

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

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

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

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

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

Registers the worker on the full range of context changes:

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