aboutsummaryrefslogtreecommitdiff
path: root/modules/term-config.el
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-06-05 05:28:58 -0500
committerCraig Jennings <c@cjennings.net>2026-06-05 05:28:58 -0500
commitebdf9e466b0e1f86e9b7d76650ac32408273e7a7 (patch)
treedab9b453f3a93c324b5388b3843502a088c7ed46 /modules/term-config.el
parentc094b2e4e64530379a9cb273303308a9affcabf6 (diff)
downloaddotemacs-ebdf9e466b0e1f86e9b7d76650ac32408273e7a7.tar.gz
dotemacs-ebdf9e466b0e1f86e9b7d76650ac32408273e7a7.zip
feat(term): replace vterm with ghostel as the terminal engine
I swapped the terminal engine from vterm to ghostel (libghostty-vt) everywhere. term-config replaces vterm-config (the F12 terminal, the C-; x menu, tmux history capture), and ai-term replaces ai-vterm (the F9 Claude-agent launcher). ghostel renders the agent TUI without vterm's flicker under heavy streaming, and one engine now covers every terminal workflow. Two behavior changes fall out of the swap. F9 launches in a terminal frame now: ghostel renders in TTY frames, so the old GUI-only guard is gone. Terminal windows no longer dim when unfocused: ghostel resolves its palette into the native module per-terminal, so there's no per-window color hook to dim through the way vterm had. auto-dim drops its vterm color-advice path, the dashboard Terminal button launches ghostel, and the vterm and vterm-toggle packages are removed. The tmux pane-history and copy-mode machinery carried over unchanged. It keys on the pty tty, which ghostel exposes.
Diffstat (limited to 'modules/term-config.el')
-rw-r--r--modules/term-config.el396
1 files changed, 396 insertions, 0 deletions
diff --git a/modules/term-config.el b/modules/term-config.el
new file mode 100644
index 00000000..84ba7b3b
--- /dev/null
+++ b/modules/term-config.el
@@ -0,0 +1,396 @@
+;;; term-config.el --- Settings for ghostel and the F12 toggle -*- lexical-binding: t; coding: utf-8; -*-
+;; author Craig Jennings <c@cjennings.net>
+
+;;; Commentary:
+;;
+;; Layer: 3 (Domain Workflow).
+;; Category: D/P.
+;; Load shape: eager.
+;; Eager reason: registers terminal keymaps and the F12 toggle.
+;; Top-level side effects: defines two keymaps (one under cj/custom-keymap), one
+;; global key, two add-hook, package config.
+;; Runtime requires: keybindings, seq, subr-x, cj-window-geometry-lib,
+;; cj-window-toggle-lib.
+;; Direct test load: yes (requires keybindings explicitly).
+;;
+;; GHOSTEL
+;; ghostel is a native Emacs terminal emulator over libghostty-vt (the Ghostty
+;; engine). Like a real terminal, in its default semi-char mode most keys are
+;; sent to the running program; `ghostel-keymap-exceptions' lists the keys that
+;; reach Emacs instead. We add C-; so the personal prefix keymap works inside
+;; ghostel buffers.
+;;
+;; The module degrades gracefully when ghostel is unavailable (D6 of the
+;; migration spec): the package installs via use-package, the native module
+;; auto-downloads on first use, and ghostel emits its own warning if the module
+;; cannot load. A machine without a prebuilt binary needs Zig to build it; the
+;; terminal commands stay defined either way.
+;;
+;; Two ways to lift text out of a terminal, both with the same key story:
+;; - C-; x c enters copy-mode via `cj/term-copy-mode-dwim'. When a tmux
+;; client is attached (typical -- `cj/term-launch-tmux' auto-starts tmux),
+;; sends tmux's prefix C-b [ so the user lands in tmux's own copy-mode with
+;; the full pane history available. Without tmux, falls back to
+;; `ghostel-copy-mode' (read-only standard-Emacs navigation over the
+;; scrollback; M-w copies and stays, q / C-g exit).
+;; - C-; x h captures the current tmux pane's full history into a temporary
+;; Emacs buffer.
+;; In both copy surfaces, M-w copies the active region and stays open so several
+;; pieces can be grabbed in a row; C-g / q leave without copying.
+
+;;; Code:
+
+(require 'keybindings)
+(require 'seq)
+(require 'subr-x)
+(require 'cj-window-geometry-lib)
+(require 'cj-window-toggle-lib)
+
+(declare-function ghostel "ghostel" (&optional directory))
+(declare-function ghostel-send-string "ghostel" (string))
+(declare-function ghostel-copy-mode "ghostel" ())
+(declare-function ghostel-clear-scrollback "ghostel" ())
+(declare-function ghostel-next-prompt "ghostel" (&optional n))
+(declare-function ghostel-previous-prompt "ghostel" (&optional n))
+(declare-function ghostel-send-next-key "ghostel" ())
+(defvar ghostel-mode-map)
+(defvar ghostel-keymap-exceptions)
+(defvar ghostel-buffer-name)
+
+(defvar-keymap cj/term-map
+ :doc "Personal terminal command map.")
+;; Lowercase x picked over T for fewer Shift presses; t is the toggle leaf.
+(cj/register-prefix-map "x" cj/term-map)
+
+;; ----------------------------- tmux history ----------------------------------
+
+(defvar-local cj/term-tmux-history--origin-buffer nil
+ "Buffer active before opening the tmux history buffer.")
+
+(defvar-local cj/term-tmux-history--origin-window nil
+ "Window active before opening the tmux history buffer.")
+
+(defvar-local cj/term-tmux-history--origin-point nil
+ "Point in the origin buffer before opening the tmux history buffer.")
+
+(defun cj/term--tmux-output (&rest args)
+ "Run tmux with ARGS and return its stdout.
+Signal `user-error' when tmux exits with a non-zero status."
+ (with-temp-buffer
+ (let ((exit-code (apply #'process-file "tmux" nil t nil args)))
+ (unless (zerop exit-code)
+ (user-error "tmux failed: %s" (string-trim (buffer-string))))
+ (buffer-string))))
+
+(defun cj/term--tmux-pane-id-for-tty (tty)
+ "Return the tmux pane id for client TTY."
+ (let* ((output (cj/term--tmux-output
+ "list-clients" "-F" "#{client_tty}\t#{pane_id}"))
+ (lines (split-string output "\n" t))
+ (match (seq-find
+ (lambda (line)
+ (let ((fields (split-string line "\t")))
+ (equal (car fields) tty)))
+ lines)))
+ (unless match
+ (user-error "No tmux client found for terminal tty %s" tty))
+ (cadr (split-string match "\t"))))
+
+(defun cj/term--tmux-capture-pane (pane-id)
+ "Return full joined tmux history for PANE-ID."
+ (cj/term--tmux-output
+ "capture-pane" "-p" "-J" "-S" "-" "-E" "-" "-t" pane-id))
+
+(defun cj/term--current-tmux-pane-id ()
+ "Return the tmux pane id for the current ghostel buffer."
+ (unless (eq major-mode 'ghostel-mode)
+ (user-error "Current buffer is not a ghostel buffer"))
+ (let* ((proc (get-buffer-process (current-buffer)))
+ (tty (and proc (process-tty-name proc))))
+ (unless (and tty (not (string-empty-p tty)))
+ (user-error "Could not determine terminal tty"))
+ (cj/term--tmux-pane-id-for-tty tty)))
+
+(defvar-keymap cj/term-tmux-history-mode-map
+ :doc "Keymap for `cj/term-tmux-history-mode'.
+M-w copies the active region without leaving the buffer; C-g, <escape>, or q
+returns to the terminal without copying. RET is left unbound."
+ "M-w" #'kill-ring-save
+ "C-g" #'cj/term-tmux-history-quit
+ "<escape>" #'cj/term-tmux-history-quit
+ "q" #'cj/term-tmux-history-quit)
+
+(define-derived-mode cj/term-tmux-history-mode special-mode "Tmux History"
+ "Mode for copying captured tmux pane history with normal Emacs keys."
+ (setq-local truncate-lines t)
+ (goto-address-mode 1))
+
+(defun cj/term-tmux-history-quit ()
+ "Quit tmux history and return to its origin buffer."
+ (interactive)
+ (let ((history-buffer (current-buffer))
+ (origin-buffer cj/term-tmux-history--origin-buffer)
+ (origin-window cj/term-tmux-history--origin-window)
+ (origin-point cj/term-tmux-history--origin-point))
+ (when (buffer-live-p origin-buffer)
+ (if (window-live-p origin-window)
+ (progn
+ (set-window-buffer origin-window origin-buffer)
+ (select-window origin-window))
+ (pop-to-buffer origin-buffer))
+ (with-current-buffer origin-buffer
+ (when (integer-or-marker-p origin-point)
+ (goto-char origin-point))))
+ (when (buffer-live-p history-buffer)
+ (kill-buffer history-buffer))))
+
+(defun cj/term-tmux-history ()
+ "Open full tmux pane history in a temporary Emacs buffer.
+
+The history buffer uses normal Emacs navigation and selection. `M-w'
+copies the active region and stays open, so several pieces can be
+copied in a row; `q', `<escape>', or `C-g' returns point to the
+terminal buffer that launched it.
+
+The history view replaces the origin terminal buffer in the same window
+\(via `switch-to-buffer'), not a split or a popped-up window."
+ (interactive)
+ (let* ((origin-buffer (current-buffer))
+ (origin-window (selected-window))
+ (origin-point (point))
+ (pane-id (cj/term--current-tmux-pane-id))
+ (history (cj/term--tmux-capture-pane pane-id))
+ (buffer (get-buffer-create
+ (format "*terminal tmux history: %s*" (buffer-name origin-buffer)))))
+ (with-current-buffer buffer
+ (let ((inhibit-read-only t))
+ (erase-buffer)
+ (insert history))
+ (cj/term-tmux-history-mode)
+ (setq-local cj/term-tmux-history--origin-buffer origin-buffer)
+ (setq-local cj/term-tmux-history--origin-window origin-window)
+ (setq-local cj/term-tmux-history--origin-point origin-point)
+ (goto-char (point-max)))
+ (switch-to-buffer buffer)))
+
+;; ----------------------------- copy mode -------------------------------------
+
+(defun cj/term--in-tmux-p ()
+ "Return non-nil when the current ghostel buffer has a tmux client attached.
+Errors from the pane-id lookup (not in ghostel-mode, no tty, no matching
+client, tmux not installed) are treated as nil so callers can use this as a
+cheap boolean predicate."
+ (and (eq major-mode 'ghostel-mode)
+ (condition-case _
+ (and (cj/term--current-tmux-pane-id) t)
+ (error nil))))
+
+(defun cj/term-copy-mode-dwim ()
+ "Enter copy-mode using the engine appropriate to this terminal.
+
+When tmux is attached, write tmux's default prefix sequence (C-b [) into the
+pty so the user lands in tmux's copy-mode with the full pane history. Without
+tmux, falls through to `ghostel-copy-mode', a read-only standard-Emacs view of
+the scrollback (M-w copies and stays, q / C-g exit)."
+ (interactive)
+ (if (cj/term--in-tmux-p)
+ (ghostel-send-string "\C-b[")
+ (ghostel-copy-mode)))
+
+;; ----------------------------- ghostel package -------------------------------
+
+(defun cj/turn-off-chrome-for-term ()
+ "Turn off line numbers and hl-line in a terminal buffer."
+ (hl-line-mode -1)
+ (display-line-numbers-mode -1))
+
+(defun cj/term-launch-tmux ()
+ "Auto-launch tmux in a ghostel buffer unless already inside tmux.
+
+Skipped when `cj/--ai-term-suppress-tmux' is non-nil so the AI-agent flow can
+run its own project-named tmux session instead of a bare, auto-named one.
+`bound-and-true-p' keeps this safe whether or not ai-term.el is loaded."
+ (let ((proc (get-buffer-process (current-buffer))))
+ (when (and proc
+ (not (getenv "TMUX"))
+ (not (bound-and-true-p cj/--ai-term-suppress-tmux)))
+ (ghostel-send-string "tmux\n"))))
+
+(use-package ghostel
+ :ensure t
+ :commands (ghostel)
+ :init
+ ;; C-; must reach Emacs so the personal prefix keymap works in terminals.
+ (with-eval-after-load 'ghostel
+ (add-to-list 'ghostel-keymap-exceptions "C-;"))
+ :hook
+ ((ghostel-mode . cj/turn-off-chrome-for-term)
+ (ghostel-mode . cj/term-launch-tmux))
+ :custom
+ (ghostel-kill-buffer-on-exit t)
+ ;; Byte analog of the prior 100000-line vterm setting (~100 bytes/line) -- D7.
+ (ghostel-max-scrollback (* 10 1024 1024)))
+
+;; ----------------------- F12 toggle (custom) -----------------------
+;;
+;; Mirrors the geometry-preservation pattern shared with ai-term.el: capture
+;; direction + body size at toggle-off, replay them via a custom display action
+;; using frame-edge directions and body-relative sizes so the result is
+;; divider-independent and layout-stable. Excludes agent-prefixed buffers,
+;; which ai-term.el owns via F9.
+
+(defcustom cj/term-toggle-window-height 0.7
+ "Default fraction of frame height for the F12 terminal window."
+ :type 'number
+ :group 'term)
+
+(defvar cj/--term-toggle-last-direction nil
+ "Last user-chosen direction for the F12 terminal display.
+Symbol: right, left, or below. `above' is never stored. nil means use the
+default `below' for F12's traditional bottom split.")
+
+(defvar cj/--term-toggle-last-size nil
+ "Last user-chosen body size for the F12 terminal display.
+Positive integer: body-cols (right/left) or body-lines (below/above).
+nil means fall back to `cj/term-toggle-window-height' as a fraction.")
+
+(defun cj/--term-toggle-buffer-p (buffer)
+ "Return non-nil when BUFFER is a terminal buffer F12 should manage.
+
+Qualifies when BUFFER is alive and has `ghostel-mode' (or its name starts with
+the ghostel buffer-name prefix), AND its name does NOT start with the agent
+prefix used by ai-term.el."
+ (and (bufferp buffer)
+ (buffer-live-p buffer)
+ (with-current-buffer buffer
+ (and (or (eq major-mode 'ghostel-mode)
+ (string-prefix-p (or (bound-and-true-p ghostel-buffer-name)
+ "*ghostel*")
+ (buffer-name buffer)))
+ (not (string-prefix-p "agent [" (buffer-name buffer)))))))
+
+(defun cj/--term-toggle-buffers ()
+ "Return live F12-managed terminal buffers in `buffer-list' (MRU) order."
+ (seq-filter #'cj/--term-toggle-buffer-p (buffer-list)))
+
+(defun cj/--term-toggle-displayed-window (&optional frame)
+ "Return a window in FRAME currently displaying an F12 terminal buffer, or nil.
+FRAME defaults to the selected frame. Minibuffer is excluded."
+ (seq-find (lambda (w)
+ (cj/--term-toggle-buffer-p (window-buffer w)))
+ (window-list (or frame (selected-frame)) 'never)))
+
+(defun cj/--term-toggle-capture-state (window)
+ "Capture WINDOW's direction + body size into module-level state.
+Default direction is `below' to match F12's traditional bottom split."
+ (cj/window-toggle-capture-state
+ window 'below
+ 'cj/--term-toggle-last-direction
+ 'cj/--term-toggle-last-size
+ '(right below left)))
+
+(defun cj/--term-toggle-display-saved (buffer alist)
+ "Display-buffer action: split per saved direction and body size.
+Delegates to `cj/window-toggle-display-saved' against the F12 state vars,
+falling back to `below' and `cj/term-toggle-window-height'."
+ (cj/window-toggle-display-saved
+ buffer alist
+ 'cj/--term-toggle-last-direction 'below
+ 'cj/--term-toggle-last-size cj/term-toggle-window-height))
+
+(defun cj/--term-toggle-display-rule-list ()
+ "Return the `display-buffer-alist' entry list installed by F12.
+Routes any terminal buffer satisfying `cj/--term-toggle-buffer-p' through
+reuse-window then the saved-geometry action. Excludes agent buffers."
+ '(((lambda (buffer-or-name _)
+ (cj/--term-toggle-buffer-p (get-buffer buffer-or-name)))
+ (display-buffer-reuse-window
+ cj/--term-toggle-display-saved)
+ (inhibit-same-window . t))))
+
+(dolist (entry (cj/--term-toggle-display-rule-list))
+ (add-to-list 'display-buffer-alist entry))
+
+(defun cj/--term-toggle-dispatch ()
+ "Compute the F12 (`cj/term-toggle') action without performing it.
+
+Returns one of:
+- (toggle-off . WINDOW) -- terminal displayed in WINDOW; hide it.
+- (show-recent . BUFFER) -- terminal alive but not shown; redisplay.
+- (create-new) -- no terminal buffer alive; create one."
+ (let ((win (cj/--term-toggle-displayed-window)))
+ (cond
+ (win (cons 'toggle-off win))
+ (t
+ (let ((buffers (cj/--term-toggle-buffers)))
+ (cond
+ (buffers (cons 'show-recent (car buffers)))
+ (t '(create-new))))))))
+
+(defun cj/term-toggle ()
+ "Toggle a normal (non-agent) ghostel terminal buffer.
+
+- If an F12-managed terminal is displayed in this frame, capture its geometry
+ and delete its window (toggle off). Falls back to burying when it is the
+ only window in the frame.
+- Otherwise, if any F12-managed terminal buffer is alive, display the most
+ recent one via the saved-geometry action.
+- Otherwise, create a new terminal via `(ghostel)' which routes through the
+ same display action.
+
+Excludes agent-prefixed buffers; those have their own F9 dispatch via
+`cj/ai-term'."
+ (interactive)
+ (pcase (cj/--term-toggle-dispatch)
+ (`(toggle-off . ,win)
+ (cj/--term-toggle-capture-state win)
+ (if (one-window-p)
+ (bury-buffer (window-buffer win))
+ (delete-window win))
+ nil)
+ (`(show-recent . ,buf)
+ (display-buffer buf)
+ (let ((w (get-buffer-window buf)))
+ (when w (select-window w)))
+ buf)
+ (`(create-new)
+ (ghostel))))
+
+(keymap-global-set "<f12>" #'cj/term-toggle)
+
+;; ----------------------------- prefix menu -----------------------------------
+
+(keymap-set cj/term-map "c" #'cj/term-copy-mode-dwim)
+(keymap-set cj/term-map "h" #'cj/term-tmux-history)
+(keymap-set cj/term-map "l" #'ghostel-clear-scrollback)
+(keymap-set cj/term-map "N" #'ghostel)
+(keymap-set cj/term-map "n" #'ghostel-next-prompt)
+(keymap-set cj/term-map "p" #'ghostel-previous-prompt)
+(keymap-set cj/term-map "q" #'ghostel-send-next-key)
+(keymap-set cj/term-map "t" #'cj/term-toggle)
+
+(defun cj/term-install-keys ()
+ "Make `C-;' resolve as the personal keymap inside ghostel buffers, and bind
+the F-key toggles so they reach Emacs from inside a terminal buffer."
+ (when (boundp 'ghostel-mode-map)
+ (keymap-set ghostel-mode-map "C-;" cj/custom-keymap)
+ (keymap-set ghostel-mode-map "<f12>" #'cj/term-toggle)))
+
+(cj/term-install-keys)
+(with-eval-after-load 'ghostel
+ (cj/term-install-keys))
+
+(with-eval-after-load 'which-key
+ (which-key-add-key-based-replacements
+ "C-; x" "terminal menu"
+ "C-; x c" "copy mode (tmux/ghostel)"
+ "C-; x h" "tmux scrollback history"
+ "C-; x l" "clear scrollback"
+ "C-; x N" "new terminal"
+ "C-; x n" "next prompt"
+ "C-; x p" "previous prompt"
+ "C-; x q" "send next key to terminal"
+ "C-; x t" "toggle terminal"))
+
+(provide 'term-config)
+;;; term-config.el ends here.