aboutsummaryrefslogtreecommitdiff
path: root/modules/term-config.el
blob: 84ba7b3b7076a3d6ecb15a9d2bfdd589ea7fb382 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
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.