diff options
Diffstat (limited to 'modules/term-config.el')
| -rw-r--r-- | modules/term-config.el | 396 |
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. |
