diff options
| -rw-r--r-- | modules/term-config.el | 117 | ||||
| -rw-r--r-- | tests/test-term-tmux-history.el | 138 |
2 files changed, 121 insertions, 134 deletions
diff --git a/modules/term-config.el b/modules/term-config.el index d8e9c05d0..7fb02af2c 100644 --- a/modules/term-config.el +++ b/modules/term-config.el @@ -59,6 +59,7 @@ (defvar ghostel-mode-map) (defvar ghostel-keymap-exceptions) (defvar ghostel-buffer-name) +(defvar ghostel--input-mode) (defvar-keymap cj/term-map :doc "Personal terminal command map.") @@ -206,62 +207,43 @@ start of the line for the same column-0 reason." (ghostel-copy-mode) (beginning-of-line))) -;; --------------------------- directional copy mode --------------------------- +;; ----------------------------- copy-mode scroll ------------------------------ ;; -;; A modified arrow both enters copy-mode and carries its own movement, so one -;; stroke scrolls back in the intended direction. C-<arrow> and M-<arrow> bind -;; to these in ghostel buffers (and join `ghostel-keymap-exceptions' so they -;; reach Emacs instead of the pty). - -(defconst cj/--term-copy-mode-tmux-arrows - '((up . "\e[A") (down . "\e[B") (left . "\e[D") (right . "\e[C")) - "Per-direction arrow escape sequence written into tmux copy-mode. -After `cj/term-copy-mode-dwim' lands in tmux's copy-mode, writing the matching -sequence into the pty moves the copy cursor one step; tmux binds the cursor -keys in both its emacs and vi copy-mode tables.") - -(defun cj/--term-copy-mode-move-step (direction in-tmux) - "Take one copy-mode movement step in DIRECTION. -DIRECTION is one of `up', `down', `left', `right'. When IN-TMUX is non-nil, -write the matching arrow escape sequence into the pty so tmux's copy-mode -cursor follows it; otherwise move point in the `ghostel-copy-mode' buffer. -An unknown DIRECTION is a no-op." - (if in-tmux - (let ((seq (cdr (assq direction cj/--term-copy-mode-tmux-arrows)))) - (when seq (ghostel-send-string seq))) - (pcase direction - ('up (forward-line -1)) - ('down (forward-line 1)) - ('left (backward-char)) - ('right (forward-char))))) - -(defun cj/term-copy-mode-move (direction) - "Enter copy-mode (see `cj/term-copy-mode-dwim') then step one DIRECTION. -DIRECTION is one of `up', `down', `left', `right'. The in-tmux engine is -resolved once up front so the entry and the step pick the same surface." - (let ((in-tmux (cj/term--in-tmux-p))) - (cj/term-copy-mode-dwim) - (cj/--term-copy-mode-move-step direction in-tmux))) +;; C-<up> both enters copy-mode and scrolls up one line, so a single stroke +;; lands in the scrollback already moving the right way. It joins +;; `ghostel-keymap-exceptions' so it reaches Emacs instead of the pty. Only the +;; up gesture is bound: C-<left>/<right> are readline word-motion at the shell +;; prompt and must pass through, and the other directions have no copy-mode use. +;; Pressed again while already in copy-mode it just moves up -- re-entering would +;; reset the cursor (tmux's prefix-[ + C-a, or ghostel's toggle exiting). + +(defun cj/term--tmux-pane-in-copy-mode-p (pane-id) + "Return non-nil when tmux PANE-ID is currently displaying a mode. +tmux's `pane_in_mode' is 1 while a pane is in any mode; copy-mode is the only +mode this config enters. tmux failures are treated as nil." + (condition-case nil + (equal "1" (string-trim + (cj/term--tmux-output + "display-message" "-p" "-t" pane-id "#{pane_in_mode}"))) + (error nil))) (defun cj/term-copy-mode-up () - "Enter the terminal's copy-mode and move up one step." + "Enter copy-mode if needed, then scroll up one line. +A single C-<up> lands in the terminal's copy-mode already moving up. Pressed +again while already in copy-mode it just moves up another line, so it never +re-enters and resets the cursor. In tmux, writes the up-arrow escape sequence +into the pty; without tmux, moves point up in the `ghostel-copy-mode' buffer." (interactive) - (cj/term-copy-mode-move 'up)) - -(defun cj/term-copy-mode-down () - "Enter the terminal's copy-mode and move down one step." - (interactive) - (cj/term-copy-mode-move 'down)) - -(defun cj/term-copy-mode-left () - "Enter the terminal's copy-mode and move left one step." - (interactive) - (cj/term-copy-mode-move 'left)) - -(defun cj/term-copy-mode-right () - "Enter the terminal's copy-mode and move right one step." - (interactive) - (cj/term-copy-mode-move 'right)) + (let ((pane (ignore-errors (cj/term--current-tmux-pane-id)))) + (cond + (pane + (unless (cj/term--tmux-pane-in-copy-mode-p pane) + (cj/term-copy-mode-dwim)) + (ghostel-send-string "\e[A")) + (t + (unless (eq (bound-and-true-p ghostel--input-mode) 'copy) + (cj/term-copy-mode-dwim)) + (forward-line -1))))) ;; ----------------------------- ghostel package ------------------------------- @@ -303,19 +285,19 @@ run its own project-named tmux session instead of a bare, auto-named one. ;; rebuild is what actually lets the key through to `ghostel-mode-map' / the ;; global map. C-; and F12 are the prefix + toggle; the modified arrows are ;; windmove (S-arrows, focus), buffer-move (C-M-arrows, swap), and copy-mode - ;; entry (C-arrows and M-arrows, each entering copy-mode and carrying its - ;; direction via `cj/term-copy-mode-up' & friends), which the ai-term workflow - ;; expects to work from inside an agent buffer. F8 and F10 are global - ;; bindings (org agenda, music-playlist toggle) that reach Emacs by falling - ;; through to the global map once the semi-char map stops forwarding them. - ;; (Server shutdown moved off C-F10 to C-x C, which is deliberately left - ;; forwarding to the terminal program inside an agent buffer.) + ;; entry (C-<up> only, via `cj/term-copy-mode-up'), which the ai-term workflow + ;; expects to work from inside an agent buffer. C-<left>/<right> deliberately + ;; stay forwarding so readline word-motion works at the shell prompt. F8 and + ;; F10 are global bindings (org agenda, music-playlist toggle) that reach + ;; Emacs by falling through to the global map once the semi-char map stops + ;; forwarding them. (Server shutdown moved off C-F10 to C-x C, which is + ;; deliberately left forwarding to the terminal program inside an agent + ;; buffer.) (with-eval-after-load 'ghostel (dolist (key '("C-;" "<f8>" "<f12>" "<f10>" "S-<up>" "S-<down>" "S-<left>" "S-<right>" "C-M-<up>" "C-M-<down>" "C-M-<left>" "C-M-<right>" - "C-<up>" "C-<down>" "C-<left>" "C-<right>" - "M-<up>" "M-<down>" "M-<left>" "M-<right>")) + "C-<up>")) (add-to-list 'ghostel-keymap-exceptions key)) (ghostel--rebuild-semi-char-keymap)) :hook @@ -514,21 +496,12 @@ Forwarding NUL makes C-Space behave like a terminal key." (defun cj/term-install-keys () "Make `C-;' resolve as the personal keymap inside ghostel buffers, bind the F12 toggle, forward C-SPC so it reaches the terminal (see -`cj/term-send-C-SPC'), and bind C-/M-<arrow> to enter copy-mode and step in -that direction." +`cj/term-send-C-SPC'), and bind C-<up> to enter copy-mode and scroll up." (when (boundp 'ghostel-mode-map) (keymap-set ghostel-mode-map "C-;" cj/custom-keymap) (keymap-set ghostel-mode-map "<f12>" #'cj/term-toggle) (keymap-set ghostel-mode-map "C-SPC" #'cj/term-send-C-SPC) - (dolist (binding '(("C-<up>" . cj/term-copy-mode-up) - ("M-<up>" . cj/term-copy-mode-up) - ("C-<down>" . cj/term-copy-mode-down) - ("M-<down>" . cj/term-copy-mode-down) - ("C-<left>" . cj/term-copy-mode-left) - ("M-<left>" . cj/term-copy-mode-left) - ("C-<right>" . cj/term-copy-mode-right) - ("M-<right>" . cj/term-copy-mode-right))) - (keymap-set ghostel-mode-map (car binding) (cdr binding))))) + (keymap-set ghostel-mode-map "C-<up>" #'cj/term-copy-mode-up))) (cj/term-install-keys) (with-eval-after-load 'ghostel diff --git a/tests/test-term-tmux-history.el b/tests/test-term-tmux-history.el index 89b4875eb..0ea7cf37d 100644 --- a/tests/test-term-tmux-history.el +++ b/tests/test-term-tmux-history.el @@ -355,47 +355,11 @@ Emacs region gets stuck in the ghostel buffer and tmux copy-mode's begin-selection never starts." (should (eq (keymap-lookup ghostel-mode-map "C-SPC") #'cj/term-send-C-SPC))) -;; --------------------------- directional copy mode --------------------------- - -(ert-deftest test-term-copy-mode-step-tmux-sends-arrow () - "Normal: in tmux, each direction writes its arrow escape sequence to the pty." - (dolist (case '((up . "\e[A") (down . "\e[B") (left . "\e[D") (right . "\e[C"))) - (let ((sent nil)) - (cl-letf (((symbol-function 'ghostel-send-string) - (lambda (s) (push s sent)))) - (cj/--term-copy-mode-move-step (car case) t) - (should (equal sent (list (cdr case)))))))) - -(ert-deftest test-term-copy-mode-step-nontmux-moves-point () - "Normal: without tmux, up/down move by line and left/right move by char." - (with-temp-buffer - (insert "abc\ndef\nghi\n") - ;; up/down: land on line 2, step around. - (goto-char (point-min)) - (forward-line 1) - (should (= (line-number-at-pos) 2)) - (cj/--term-copy-mode-move-step 'up nil) - (should (= (line-number-at-pos) 1)) - (cj/--term-copy-mode-move-step 'down nil) - (should (= (line-number-at-pos) 2)) - ;; left/right: move by a single character. - (goto-char 2) - (cj/--term-copy-mode-move-step 'right nil) - (should (= (point) 3)) - (cj/--term-copy-mode-move-step 'left nil) - (should (= (point) 2)))) - -(ert-deftest test-term-copy-mode-step-unknown-direction-no-op () - "Boundary: an unknown direction sends nothing in the tmux branch." - (let ((sent nil)) - (cl-letf (((symbol-function 'ghostel-send-string) - (lambda (s) (push s sent)))) - (cj/--term-copy-mode-move-step 'sideways t) - (should-not sent)))) - -(ert-deftest test-term-copy-mode-up-command-enters-then-steps () - "Normal: the bound command enters tmux copy-mode, then sends the arrow, -so a single modified arrow both enters copy-mode and carries its direction." +;; ----------------------------- copy-mode scroll ------------------------------ + +(ert-deftest test-term-copy-mode-up-tmux-enters-then-scrolls-up () + "Normal: from a live (non-copy) tmux pane, C-<up> enters copy-mode then sends +the up-arrow, so one stroke both enters copy-mode and scrolls up." (let ((agent (cj/test--make-fake-ghostel-buffer "agent [emacs.d]")) (sent nil)) (unwind-protect @@ -408,33 +372,83 @@ so a single modified arrow both enters copy-mode and carries its direction." (lambda (s) (push s sent)))) (test-term-tmux-history--with-tmux-mock '((("list-clients" "-F" "#{client_tty}\t#{pane_id}") 0 - "/dev/pts/8\t%8\n")) + "/dev/pts/8\t%8\n") + (("display-message" "-p" "-t" "%8" "#{pane_in_mode}") 0 "0\n")) (cj/term-copy-mode-up) (should (equal (reverse sent) '("\C-b[\C-a" "\e[A")))))) (when (buffer-live-p agent) (kill-buffer agent))))) -(ert-deftest test-term-copy-mode-arrows-bound-in-ghostel-map () - "Normal: C-<arrow> and M-<arrow> both reach the directional copy-mode commands." - (dolist (case '(("C-<up>" . cj/term-copy-mode-up) - ("M-<up>" . cj/term-copy-mode-up) - ("C-<down>" . cj/term-copy-mode-down) - ("M-<down>" . cj/term-copy-mode-down) - ("C-<left>" . cj/term-copy-mode-left) - ("M-<left>" . cj/term-copy-mode-left) - ("C-<right>" . cj/term-copy-mode-right) - ("M-<right>" . cj/term-copy-mode-right))) - (should (eq (keymap-lookup ghostel-mode-map (car case)) (cdr case))))) - -(ert-deftest test-term-copy-mode-arrows-in-keymap-exceptions () - "Regression: C-/M-<arrow> are in `ghostel-keymap-exceptions' and the rebuilt -semi-char map no longer forwards them to the pty, so they reach Emacs and -trigger copy-mode entry from inside a ghostel buffer." - (dolist (key '("C-<up>" "C-<down>" "C-<left>" "C-<right>" +(ert-deftest test-term-copy-mode-up-tmux-already-in-mode-just-scrolls () + "Normal: when the tmux pane is already in copy-mode, C-<up> only sends the +up-arrow -- it does not re-enter (which would reset the cursor)." + (let ((agent (cj/test--make-fake-ghostel-buffer "agent [emacs.d]")) + (sent 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 &rest _) "/dev/pts/8")) + ((symbol-function 'ghostel-send-string) + (lambda (s) (push s sent)))) + (test-term-tmux-history--with-tmux-mock + '((("list-clients" "-F" "#{client_tty}\t#{pane_id}") 0 + "/dev/pts/8\t%8\n") + (("display-message" "-p" "-t" "%8" "#{pane_in_mode}") 0 "1\n")) + (cj/term-copy-mode-up) + (should (equal (reverse sent) '("\e[A")))))) + (when (buffer-live-p agent) + (kill-buffer agent))))) + +(ert-deftest test-term-copy-mode-up-nontmux-enters-then-moves-up () + "Boundary: without tmux and not yet in copy-mode, C-<up> enters +ghostel-copy-mode then moves point up a line, sending nothing to the pty." + (with-temp-buffer + (insert "abc\ndef\nghi\n") + (goto-char (point-min)) + (forward-line 2) ; land on line 3 + (let ((sent nil) (entered nil)) + (cl-letf (((symbol-function 'ghostel-send-string) (lambda (s) (push s sent))) + ((symbol-function 'ghostel-copy-mode) (lambda () (setq entered t)))) + (cj/term-copy-mode-up) + (should entered) + (should-not sent) + (should (= (line-number-at-pos) 2)))))) + +(ert-deftest test-term-copy-mode-up-nontmux-already-in-copy-just-moves () + "Normal: when ghostel is already in copy-mode, C-<up> just moves point up -- +it does not call `ghostel-copy-mode' again (which would toggle copy-mode off)." + (with-temp-buffer + (insert "abc\ndef\nghi\n") + (goto-char (point-min)) + (forward-line 2) ; land on line 3 + (setq-local ghostel--input-mode 'copy) + (let ((sent nil) (entered nil)) + (cl-letf (((symbol-function 'ghostel-send-string) (lambda (s) (push s sent))) + ((symbol-function 'ghostel-copy-mode) (lambda () (setq entered t)))) + (cj/term-copy-mode-up) + (should-not entered) + (should-not sent) + (should (= (line-number-at-pos) 2)))))) + +(ert-deftest test-term-copy-mode-only-c-up-bound () + "Normal/Regression: only C-<up> enters copy-mode in ghostel-mode-map; the +other arrows are not bound to it, so they pass through to the terminal." + (should (eq (keymap-lookup ghostel-mode-map "C-<up>") #'cj/term-copy-mode-up)) + (dolist (key '("C-<down>" "C-<left>" "C-<right>" "M-<up>" "M-<down>" "M-<left>" "M-<right>")) - (should (member key ghostel-keymap-exceptions))) - (should-not (eq (keymap-lookup ghostel-semi-char-mode-map "C-<up>") - 'ghostel--send-event))) + (should-not (eq (keymap-lookup ghostel-mode-map key) #'cj/term-copy-mode-up)))) + +(ert-deftest test-term-copy-mode-only-c-up-in-keymap-exceptions () + "Regression (C-arrow copy-mode bug): only C-<up> is in +`ghostel-keymap-exceptions'. C-<left>/<right>/<down> are readline word-motion +at the shell prompt and the M-arrows have no copy-mode role, so none are +exceptions -- they reach the terminal program instead of Emacs." + (should (member "C-<up>" ghostel-keymap-exceptions)) + (dolist (key '("C-<down>" "C-<left>" "C-<right>" + "M-<up>" "M-<down>" "M-<left>" "M-<right>")) + (should-not (member key ghostel-keymap-exceptions)))) (provide 'test-term-tmux-history) ;;; test-term-tmux-history.el ends here |
