diff --git a/pi-coding-agent-input.el b/pi-coding-agent-input.el index ff11586..4c6e751 100644 --- a/pi-coding-agent-input.el +++ b/pi-coding-agent-input.el @@ -37,6 +37,8 @@ ;; `pi-coding-agent-quit' Close session ;; `pi-coding-agent-previous-input' History backward (M-p) ;; `pi-coding-agent-next-input' History forward (M-n) +;; `pi-coding-agent-input-previous-message' Navigate previous chat message +;; `pi-coding-agent-input-next-message' Navigate next chat message ;; `pi-coding-agent-history-isearch-backward' History search (C-r) ;; `pi-coding-agent-queue-steering' Steering message (C-c C-s) @@ -252,6 +254,31 @@ markdown highlighting while preserving mode identity and keybindings." (add-hook 'isearch-mode-hook #'pi-coding-agent--history-isearch-setup nil t) (add-hook 'kill-buffer-hook #'pi-coding-agent--cleanup-input-on-kill nil t)) +;;;; Input-Buffer Chat Navigation + +(defun pi-coding-agent--call-in-visible-chat-window (fn) + "Call FN in the visible linked chat window, preserving input focus." + (let* ((chat-buf (pi-coding-agent--get-chat-buffer)) + (win (and (buffer-live-p chat-buf) + (get-buffer-window chat-buf)))) + (if (window-live-p win) + (save-selected-window + (select-window win) + (funcall fn)) + (user-error "No chat window visible")))) + +(defun pi-coding-agent-input-next-message () + "Move chat to the next user message, keeping focus in input." + (interactive) + (pi-coding-agent--call-in-visible-chat-window + #'pi-coding-agent-next-message)) + +(defun pi-coding-agent-input-previous-message () + "Move chat to the previous user message, keeping focus in input." + (interactive) + (pi-coding-agent--call-in-visible-chat-window + #'pi-coding-agent-previous-message)) + ;;;; Sending Prompts (defun pi-coding-agent--accept-input-text (text) diff --git a/pi-coding-agent-ui.el b/pi-coding-agent-ui.el index afe7254..359e221 100644 --- a/pi-coding-agent-ui.el +++ b/pi-coding-agent-ui.el @@ -423,7 +423,6 @@ Returns the position of the heading line start, or nil if not found." (when (get-buffer-window) (recenter 0))) (message "No previous message")))) - ;;;; Copy Visible Text (defun pi-coding-agent--visible-text (beg end) @@ -623,8 +622,12 @@ new live processes in interactive sessions." "Reference to the chat buffer for this session.") (defun pi-coding-agent--set-chat-buffer (buffer) - "Set the chat BUFFER reference for this session." - (setq pi-coding-agent--chat-buffer buffer)) + "Set the chat BUFFER reference for this session. +In input buffers, also store BUFFER in `other-window-scroll-buffer' +so built-in other-window scrolling commands target the linked chat." + (setq pi-coding-agent--chat-buffer buffer) + (when (derived-mode-p 'pi-coding-agent-input-mode) + (setq-local other-window-scroll-buffer buffer))) (defvar-local pi-coding-agent--input-buffer nil "Reference to the input buffer for this session.") diff --git a/test/pi-coding-agent-input-test.el b/test/pi-coding-agent-input-test.el index 208b4c7..6cbf539 100644 --- a/test/pi-coding-agent-input-test.el +++ b/test/pi-coding-agent-input-test.el @@ -2915,5 +2915,88 @@ no spurious faces are applied to plain colon-ending lines." (should (eq (key-binding (kbd "TAB")) 'pi-coding-agent-complete)) (should (eq (key-binding (kbd "C-c C-s")) 'pi-coding-agent-queue-steering)))) +;;; Input-Buffer Chat Navigation + +(ert-deftest pi-coding-agent-test-input-next-message-moves-chat () + "Input-side next-message moves the linked chat and keeps focus." + (let ((chat-buf (generate-new-buffer "*test-chat*")) + (input-buf (generate-new-buffer "*test-input*"))) + (unwind-protect + (progn + (delete-other-windows) + (switch-to-buffer chat-buf) + (with-current-buffer chat-buf + (pi-coding-agent-chat-mode) + (let ((inhibit-read-only t)) + (pi-coding-agent-test--insert-chat-turns)) + (pi-coding-agent--set-input-buffer input-buf) + (goto-char (point-min))) + (let ((input-win (split-window nil -10 'below))) + (set-window-buffer input-win input-buf) + (with-current-buffer input-buf + (pi-coding-agent-input-mode) + (pi-coding-agent--set-chat-buffer chat-buf)) + (select-window input-win) + (pi-coding-agent-input-next-message) + (with-current-buffer chat-buf + (should (looking-at "You · 10:00"))) + (should (eq (window-buffer (selected-window)) input-buf)))) + (kill-buffer chat-buf) + (kill-buffer input-buf) + (delete-other-windows)))) + +(ert-deftest pi-coding-agent-test-input-previous-message-moves-linked-chat () + "Input-side previous-message uses the linked chat, not scroll state." + (let ((chat-a (generate-new-buffer "*test-chat-a*")) + (chat-b (generate-new-buffer "*test-chat-b*")) + (input-buf (generate-new-buffer "*test-input*"))) + (unwind-protect + (progn + (delete-other-windows) + (switch-to-buffer chat-a) + (let* ((chat-win-a (selected-window)) + (input-win (split-window chat-win-a -10 'below)) + (chat-win-b (split-window chat-win-a nil 'right))) + (set-window-buffer input-win input-buf) + (set-window-buffer chat-win-b chat-b) + (with-current-buffer chat-a + (pi-coding-agent-chat-mode) + (let ((inhibit-read-only t)) + (pi-coding-agent-test--insert-chat-turns)) + (goto-char (point-max)) + (re-search-backward "^You · 10:10$" nil t)) + (with-current-buffer chat-b + (pi-coding-agent-chat-mode) + (let ((inhibit-read-only t)) + (pi-coding-agent-test--insert-chat-turns)) + (goto-char (point-max)) + (re-search-backward "^You · 10:10$" nil t)) + (with-current-buffer input-buf + (pi-coding-agent-input-mode) + (pi-coding-agent--set-chat-buffer chat-a) + (setq-local other-window-scroll-buffer chat-b)) + (select-window input-win) + (pi-coding-agent-input-previous-message) + (with-current-buffer chat-a + (should (looking-at "You · 10:05"))) + (with-current-buffer chat-b + (should (looking-at "You · 10:10"))) + (should (eq (window-buffer (selected-window)) input-buf)))) + (kill-buffer chat-a) + (kill-buffer chat-b) + (kill-buffer input-buf) + (delete-other-windows)))) + +(ert-deftest pi-coding-agent-test-input-previous-message-no-chat-window-errors () + "Navigating from input without a visible linked chat signals error." + (let ((chat-buf (generate-new-buffer "*test-chat-hidden*"))) + (unwind-protect + (with-temp-buffer + (pi-coding-agent-input-mode) + (pi-coding-agent--set-chat-buffer chat-buf) + (should-error (pi-coding-agent-input-previous-message) + :type 'user-error)) + (kill-buffer chat-buf)))) + (provide 'pi-coding-agent-input-test) ;;; pi-coding-agent-input-test.el ends here diff --git a/test/pi-coding-agent-ui-test.el b/test/pi-coding-agent-ui-test.el index a23c9fb..06e0827 100644 --- a/test/pi-coding-agent-ui-test.el +++ b/test/pi-coding-agent-ui-test.el @@ -446,6 +446,66 @@ Buffer is read-only with `inhibit-read-only' used for insertion. (should (= (point) pos)) (should (equal shown-message "No previous message"))))) +(ert-deftest pi-coding-agent-test-other-window-scroll-buffer-set-locally () + "Session setup stores `other-window-scroll-buffer' as input-local state." + (let ((root "/tmp/pi-coding-agent-test-scroll-other/") + (original-default (default-value 'other-window-scroll-buffer))) + (make-directory root t) + (cl-letf (((symbol-function 'project-current) (lambda (&rest _) nil)) + ((symbol-function 'pi-coding-agent--start-process) (lambda (_) nil)) + ((symbol-function 'pi-coding-agent--display-buffers) #'ignore)) + (unwind-protect + (progn + (let ((default-directory root)) + (pi-coding-agent)) + (let ((chat (get-buffer (pi-coding-agent-test--chat-buffer-name root))) + (input (get-buffer (pi-coding-agent-test--input-buffer-name root)))) + (with-current-buffer input + (should (local-variable-p 'other-window-scroll-buffer)) + (should (eq other-window-scroll-buffer chat))) + (should (eq (default-value 'other-window-scroll-buffer) + original-default)))) + (set-default 'other-window-scroll-buffer original-default) + (pi-coding-agent-test--kill-session-buffers root))))) + +(ert-deftest pi-coding-agent-test-other-window-for-scrolling-tracks-each-input-session () + "Each input buffer scrolls its own chat buffer." + (let ((root-a "/tmp/pi-coding-agent-test-scroll-a/") + (root-b "/tmp/pi-coding-agent-test-scroll-b/") + (original-default (default-value 'other-window-scroll-buffer))) + (make-directory root-a t) + (make-directory root-b t) + (cl-letf (((symbol-function 'project-current) (lambda (&rest _) nil)) + ((symbol-function 'pi-coding-agent--start-process) (lambda (_) nil)) + ((symbol-function 'pi-coding-agent--display-buffers) #'ignore)) + (unwind-protect + (progn + (let ((default-directory root-a)) + (pi-coding-agent)) + (let ((default-directory root-b)) + (pi-coding-agent)) + (let* ((chat-a (get-buffer (pi-coding-agent-test--chat-buffer-name root-a))) + (input-a (get-buffer (pi-coding-agent-test--input-buffer-name root-a))) + (chat-b (get-buffer (pi-coding-agent-test--chat-buffer-name root-b))) + (input-b (get-buffer (pi-coding-agent-test--input-buffer-name root-b)))) + (delete-other-windows) + (switch-to-buffer chat-a) + (let* ((chat-win-a (selected-window)) + (input-win-a (split-window chat-win-a -10 'below)) + (chat-win-b (split-window chat-win-a nil 'right)) + input-win-b) + (set-window-buffer input-win-a input-a) + (set-window-buffer chat-win-b chat-b) + (setq input-win-b (split-window chat-win-b -10 'below)) + (set-window-buffer input-win-b input-b) + (select-window input-win-a) + (should (eq (window-buffer (other-window-for-scrolling)) chat-a)) + (select-window input-win-b) + (should (eq (window-buffer (other-window-for-scrolling)) chat-b))))) + (set-default 'other-window-scroll-buffer original-default) + (pi-coding-agent-test--kill-session-buffers root-a) + (pi-coding-agent-test--kill-session-buffers root-b))))) + ;;; Turn Detection (ert-deftest pi-coding-agent-test-turn-index-on-first-heading ()