aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-18 16:49:29 -0400
committerCraig Jennings <c@cjennings.net>2026-05-18 16:49:29 -0400
commit0202a65825441562eb9903ccc28939367e29c274 (patch)
tree9b9ec10782db71732050c1c9ac583b4dc6a05a8c
parentb3d41f9a0c63b13ad497a48677de933a3fb5a5cf (diff)
downloaddotemacs-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.el84
-rw-r--r--tests/test-vterm-tmux-history.el143
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