aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--modules/term-config.el117
-rw-r--r--tests/test-term-tmux-history.el138
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