diff options
| author | Craig Jennings <c@cjennings.net> | 2026-05-18 16:49:29 -0400 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-05-18 16:49:29 -0400 |
| commit | 0202a65825441562eb9903ccc28939367e29c274 (patch) | |
| tree | 9b9ec10782db71732050c1c9ac583b4dc6a05a8c | |
| parent | b3d41f9a0c63b13ad497a48677de933a3fb5a5cf (diff) | |
| download | dotemacs-0202a65825441562eb9903ccc28939367e29c274.tar.gz dotemacs-0202a65825441562eb9903ccc28939367e29c274.zip | |
feat(vterm): forward wheel events and route C-; x c into tmux copy-mode
vterm-mode-map binds only mouse-1 and mouse-yank-primary, so wheel events
fall through to Emacs scrolling and never reach the pty. tmux's `set -g
mouse on' never sees them. Bind wheel-up / wheel-down (and X11 mouse-4 /
mouse-5) to send SGR mouse-wheel escapes via vterm-send-string. tmux's
existing WheelUpPane / WheelDownPane bindings route into copy-mode from
there.
For keyboard parity, route C-; x c through cj/vterm-copy-mode-dwim, which
sends C-b [ when a tmux client is attached and falls back to vterm-copy-mode
otherwise. tmux's history-limit is now reachable from either entry point.
The matching copy-mode keys (M-w stays, C-g / q / Escape exit, Enter
unbound) land in the dotfiles repo alongside.
| -rw-r--r-- | modules/vterm-config.el | 84 | ||||
| -rw-r--r-- | tests/test-vterm-tmux-history.el | 143 |
2 files changed, 215 insertions, 12 deletions
diff --git a/modules/vterm-config.el b/modules/vterm-config.el index 954e096a..0817c3d9 100644 --- a/modules/vterm-config.el +++ b/modules/vterm-config.el @@ -9,14 +9,19 @@ ;; beginning-of-the-line in a shell, or the prefix key in a screen session. ;; Two ways to lift text out of a vterm, both with the same key story: -;; - C-; x c enters vterm-copy-mode (the buffer becomes a normal Emacs -;; buffer: navigation keys, rectangles, etc.). +;; - C-; x c enters copy-mode via `cj/vterm-copy-mode-dwim'. When a tmux +;; client is attached to the vterm (typical -- `cj/vterm-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 +;; (history-limit, default 100000 in this config's tmux.conf). Without +;; tmux, falls back to `vterm-copy-mode' against vterm's scrollback. ;; - C-; x h captures the current tmux pane's full history into a temporary ;; Emacs buffer. -;; In both, M-w copies the active region and stays open, so several pieces can -;; be grabbed in a row; C-g, <escape>, or q leaves (closing the history buffer -;; / resuming the live terminal) without copying. RET is left unbound -- no -;; special "copy and exit" shortcut. +;; In all three surfaces (vterm-copy-mode, tmux copy-mode, history buffer), +;; M-w copies the active region and stays open so several pieces can be +;; grabbed in a row; C-g, <escape>, or q leaves without copying; RET is +;; unbound -- no special "copy and exit" shortcut. The tmux-side bindings +;; live in ~/code/archsetup/dotfiles/common/.tmux.conf. ;; ANSI-TERM & TERM ;; I haven't yet found a need for term or ansi-term in my workflows, so I leave @@ -153,6 +158,63 @@ the live terminal back where it was." (user-error "This command is effective only in vterm-copy-mode")) (vterm-copy-mode -1)) +(defun cj/vterm--in-tmux-p () + "Return non-nil when the current vterm has a tmux client attached. +Errors from the pane-id lookup (not in vterm-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 'vterm-mode) + (condition-case _ + (and (cj/vterm--current-tmux-pane-id) t) + (error nil)))) + +(declare-function vterm-send-string "vterm" (string &optional paste-p)) + +(defun cj/vterm-copy-mode-dwim () + "Enter copy-mode using the engine appropriate to this vterm. + +When tmux is attached to the current vterm, 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 (`history-limit', default +100000) available. The matching tmux keys in +`~/code/archsetup/dotfiles/common/.tmux.conf' mirror this module's +Emacs story: M-w copies and stays, C-g / q / <escape> exit, Enter +is unbound. + +Without tmux, falls through to `vterm-copy-mode' which walks only +vterm's own scrollback (effectively just the visible screen, +because tmux redraws via cursor positioning rather than scrolling +new lines through vterm's buffer)." + (interactive) + (if (cj/vterm--in-tmux-p) + (vterm-send-string "\C-b[") + (vterm-copy-mode))) + +(defun cj/vterm--send-mouse-wheel (button) + "Forward a wheel event to the program running in the current vterm. + +BUTTON is the SGR mouse button code: 64 for wheel up, 65 for wheel +down. X / Y coordinates are placeholders (1,1); tmux dispatches +`WheelUpPane' / `WheelDownPane' on the button code and ignores the +position when there is only one pane. + +vterm's keymap binds only `mouse-1' and `mouse-yank-primary' -- +wheel events fall through to Emacs's default scroll behavior, which +moves the window over vterm's scrollback instead of reaching the +pty. Without this forwarding, tmux's `set -g mouse on' never fires +because tmux never sees the events." + (vterm-send-string (format "\e[<%d;1;1M" button))) + +(defun cj/vterm-mouse-wheel-up () + "Forward a wheel-up event to the program running in this vterm." + (interactive) + (cj/vterm--send-mouse-wheel 64)) + +(defun cj/vterm-mouse-wheel-down () + "Forward a wheel-down event to the program running in this vterm." + (interactive) + (cj/vterm--send-mouse-wheel 65)) + (use-package vterm :defer .5 :commands (vterm vterm-other-window) @@ -189,7 +251,11 @@ ai-vterm.el is loaded." ("<f10>" . nil) ("<f12>" . nil) ("C-c C-t" . nil) - ("C-y" . vterm-yank)) + ("C-y" . vterm-yank) + ("<wheel-up>" . cj/vterm-mouse-wheel-up) + ("<wheel-down>" . cj/vterm-mouse-wheel-down) + ("<mouse-4>" . cj/vterm-mouse-wheel-up) + ("<mouse-5>" . cj/vterm-mouse-wheel-down)) :custom (vterm-kill-buffer-on-exit t) (vterm-max-scrollback 100000) @@ -354,7 +420,7 @@ C-F9 / M-F9 dispatch via `cj/ai-vterm'." (keymap-global-set "<f12>" #'cj/vterm-toggle) -(keymap-set cj/vterm-map "c" #'vterm-copy-mode) +(keymap-set cj/vterm-map "c" #'cj/vterm-copy-mode-dwim) (keymap-set cj/vterm-map "h" #'cj/vterm-tmux-history) (keymap-set cj/vterm-map "l" #'vterm-clear-scrollback) (keymap-set cj/vterm-map "N" #'vterm) @@ -414,7 +480,7 @@ cursor-visibility tracking resumes." (with-eval-after-load 'which-key (which-key-add-key-based-replacements "C-; x" "vterm menu" - "C-; x c" "vterm copy mode" + "C-; x c" "copy mode (tmux/vterm)" "C-; x h" "tmux scrollback history" "C-; x l" "clear vterm scrollback" "C-; x N" "new vterm" diff --git a/tests/test-vterm-tmux-history.el b/tests/test-vterm-tmux-history.el index c0a71421..be654905 100644 --- a/tests/test-vterm-tmux-history.el +++ b/tests/test-vterm-tmux-history.el @@ -158,14 +158,16 @@ and C-g quit back to the vterm; RET is left unbound (no special exit)." (should-not (keymap-lookup cj/vterm-tmux-history-mode-map "RET"))) (ert-deftest test-vterm-keymap-includes-history-and-copy-bindings () - "Normal: personal vterm map owns the high-level vterm UX commands." + "Normal: personal vterm map owns the high-level vterm UX commands. +`C-; x c' resolves to `cj/vterm-copy-mode-dwim' so the binding can pick +the right copy-mode engine (tmux when attached, vterm otherwise)." (should (member "C-;" vterm-keymap-exceptions)) (should-not (eq (keymap-lookup cj/custom-keymap "X c") #'vterm-copy-mode)) (should (eq (keymap-lookup cj/custom-keymap "x h") #'cj/vterm-tmux-history)) - (should (eq (keymap-lookup cj/custom-keymap "x c") #'vterm-copy-mode)) + (should (eq (keymap-lookup cj/custom-keymap "x c") #'cj/vterm-copy-mode-dwim)) (should (equal (keymap-lookup vterm-mode-map "C-;") cj/custom-keymap)) (should (eq (keymap-lookup vterm-mode-map "C-; x h") #'cj/vterm-tmux-history)) - (should (eq (keymap-lookup vterm-mode-map "C-; x c") #'vterm-copy-mode)) + (should (eq (keymap-lookup vterm-mode-map "C-; x c") #'cj/vterm-copy-mode-dwim)) (should-not (keymap-lookup vterm-mode-map "C-c C-t"))) (ert-deftest test-vterm-keymap-prompt-navigation () @@ -227,5 +229,140 @@ AI-vterm name neither helps nor blocks resolution." (when (buffer-live-p agent) (kill-buffer agent))))) +(ert-deftest test-vterm-in-tmux-p-true-when-client-attached () + "Normal: predicate returns t when tmux reports a client for our tty." + (let ((agent (cj/test--make-fake-vterm-buffer "agent [emacs.d]"))) + (unwind-protect + (with-current-buffer agent + (cl-letf (((symbol-function 'get-buffer-process) + (lambda (_buffer) 'fake-process)) + ((symbol-function 'process-tty-name) + (lambda (_process) "/dev/pts/8"))) + (test-vterm-tmux-history--with-tmux-mock + '((("list-clients" "-F" "#{client_tty}\t#{pane_id}") 0 + "/dev/pts/8\t%8\n")) + (should (cj/vterm--in-tmux-p))))) + (when (buffer-live-p agent) + (kill-buffer agent))))) + +(ert-deftest test-vterm-in-tmux-p-nil-when-no-matching-client () + "Boundary: predicate returns nil when tmux runs but our tty has no client." + (let ((agent (cj/test--make-fake-vterm-buffer "agent [emacs.d]"))) + (unwind-protect + (with-current-buffer agent + (cl-letf (((symbol-function 'get-buffer-process) + (lambda (_buffer) 'fake-process)) + ((symbol-function 'process-tty-name) + (lambda (_process) "/dev/pts/8"))) + (test-vterm-tmux-history--with-tmux-mock + '((("list-clients" "-F" "#{client_tty}\t#{pane_id}") 0 + "/dev/pts/1\t%1\n")) + (should-not (cj/vterm--in-tmux-p))))) + (when (buffer-live-p agent) + (kill-buffer agent))))) + +(ert-deftest test-vterm-in-tmux-p-nil-when-tmux-fails () + "Error: predicate swallows tmux failures and returns nil." + (let ((agent (cj/test--make-fake-vterm-buffer "agent [emacs.d]"))) + (unwind-protect + (with-current-buffer agent + (cl-letf (((symbol-function 'get-buffer-process) + (lambda (_buffer) 'fake-process)) + ((symbol-function 'process-tty-name) + (lambda (_process) "/dev/pts/8"))) + (test-vterm-tmux-history--with-tmux-mock + '((("list-clients" "-F" "#{client_tty}\t#{pane_id}") 1 + "no server running")) + (should-not (cj/vterm--in-tmux-p))))) + (when (buffer-live-p agent) + (kill-buffer agent))))) + +(ert-deftest test-vterm-in-tmux-p-nil-when-not-vterm-mode () + "Boundary: predicate refuses non-vterm buffers without calling tmux." + (with-temp-buffer + (let ((tmux-called nil)) + (cl-letf (((symbol-function 'process-file) + (lambda (&rest _) (setq tmux-called t) 0))) + (should-not (cj/vterm--in-tmux-p)) + (should-not tmux-called))))) + +(ert-deftest test-vterm-copy-mode-dwim-sends-tmux-prefix-when-attached () + "Normal: with tmux attached, dwim writes C-b [ into the pty. +The literal control-B + open-bracket bytes reach tmux which then enters +its own copy-mode against the full pane history." + (let ((agent (cj/test--make-fake-vterm-buffer "agent [emacs.d]")) + (sent nil) + (copy-mode-called nil)) + (unwind-protect + (with-current-buffer agent + (cl-letf (((symbol-function 'get-buffer-process) + (lambda (_buffer) 'fake-process)) + ((symbol-function 'process-tty-name) + (lambda (_process) "/dev/pts/8")) + ((symbol-function 'vterm-send-string) + (lambda (s &optional _paste-p) (push s sent))) + ((symbol-function 'vterm-copy-mode) + (lambda (&optional _arg) (setq copy-mode-called t)))) + (test-vterm-tmux-history--with-tmux-mock + '((("list-clients" "-F" "#{client_tty}\t#{pane_id}") 0 + "/dev/pts/8\t%8\n")) + (cj/vterm-copy-mode-dwim) + (should (equal sent '("\C-b["))) + (should-not copy-mode-called)))) + (when (buffer-live-p agent) + (kill-buffer agent))))) + +(ert-deftest test-vterm-copy-mode-dwim-falls-back-without-tmux () + "Boundary: without tmux, dwim calls `vterm-copy-mode' and sends nothing." + (let ((agent (cj/test--make-fake-vterm-buffer "agent [emacs.d]")) + (sent nil) + (copy-mode-called nil)) + (unwind-protect + (with-current-buffer agent + (cl-letf (((symbol-function 'get-buffer-process) + (lambda (_buffer) 'fake-process)) + ((symbol-function 'process-tty-name) + (lambda (_process) "/dev/pts/8")) + ((symbol-function 'vterm-send-string) + (lambda (s &optional _paste-p) (push s sent))) + ((symbol-function 'vterm-copy-mode) + (lambda (&optional _arg) (setq copy-mode-called t)))) + (test-vterm-tmux-history--with-tmux-mock + '((("list-clients" "-F" "#{client_tty}\t#{pane_id}") 1 + "no server running")) + (cj/vterm-copy-mode-dwim) + (should-not sent) + (should copy-mode-called)))) + (when (buffer-live-p agent) + (kill-buffer agent))))) + +(ert-deftest test-vterm-mouse-wheel-up-sends-sgr-button-64 () + "Normal: wheel-up emits the SGR mouse-wheel-up sequence (button 64)." + (let ((sent nil)) + (cl-letf (((symbol-function 'vterm-send-string) + (lambda (s &optional _paste-p) (push s sent)))) + (cj/vterm-mouse-wheel-up) + (should (equal sent '("\e[<64;1;1M")))))) + +(ert-deftest test-vterm-mouse-wheel-down-sends-sgr-button-65 () + "Normal: wheel-down emits the SGR mouse-wheel-down sequence (button 65)." + (let ((sent nil)) + (cl-letf (((symbol-function 'vterm-send-string) + (lambda (s &optional _paste-p) (push s sent)))) + (cj/vterm-mouse-wheel-down) + (should (equal sent '("\e[<65;1;1M")))))) + +(ert-deftest test-vterm-wheel-bindings-installed-on-vterm-mode-map () + "Normal: wheel-up / wheel-down (and X11 mouse-4 / mouse-5) route to the +forwarding commands so tmux can see them via `set -g mouse on'." + (should (eq (keymap-lookup vterm-mode-map "<wheel-up>") + #'cj/vterm-mouse-wheel-up)) + (should (eq (keymap-lookup vterm-mode-map "<wheel-down>") + #'cj/vterm-mouse-wheel-down)) + (should (eq (keymap-lookup vterm-mode-map "<mouse-4>") + #'cj/vterm-mouse-wheel-up)) + (should (eq (keymap-lookup vterm-mode-map "<mouse-5>") + #'cj/vterm-mouse-wheel-down))) + (provide 'test-vterm-tmux-history) ;;; test-vterm-tmux-history.el ends here |
