diff --git a/pi-coding-agent-ui.el b/pi-coding-agent-ui.el index 1f9e71d..2b84120 100644 --- a/pi-coding-agent-ui.el +++ b/pi-coding-agent-ui.el @@ -588,6 +588,45 @@ TYPE is :chat or :input. Returns the buffer." (:input (pi-coding-agent-input-mode)))) buf)))) +;;;; Project Buffer Discovery + +(defun pi-coding-agent-project-buffers () + "Return all pi chat buffers for the current project directory. +Matches buffer names by prefix against the abbreviated project dir. +Returns a list ordered by `buffer-list' recency (most recent first)." + (let ((prefix (format "*pi-coding-agent-chat:%s" + (abbreviate-file-name + (pi-coding-agent--session-directory))))) + (cl-remove-if-not + (lambda (buf) + (string-prefix-p prefix (buffer-name buf))) + (buffer-list)))) + +;;;; Window Hiding + +(defun pi-coding-agent--hide-buffers () + "Hide all pi windows (chat and input) for the current session. +Uses `delete-window' when the frame has other windows, or +`bury-buffer' for sole-window frames. A second pass buries any +pi buffer that `switch-to-prev-buffer' may have landed on." + (let* ((chat-buf (pi-coding-agent--get-chat-buffer)) + (input-buf (pi-coding-agent--get-input-buffer)) + (pi-bufs (list chat-buf input-buf))) + ;; First pass: delete or bury each pi window + (dolist (buf pi-bufs) + (when (buffer-live-p buf) + (dolist (win (get-buffer-window-list buf nil t)) + (if (> (length (window-list (window-frame win))) 1) + (delete-window win) + (with-selected-window win + (bury-buffer)))))) + ;; Second pass: if bury-buffer landed on the paired pi buffer, bury again + (dolist (buf pi-bufs) + (when (buffer-live-p buf) + (dolist (win (get-buffer-window-list buf nil t)) + (with-selected-window win + (bury-buffer))))))) + ;;;; Buffer-Local Session Variables (defvar-local pi-coding-agent--process nil diff --git a/pi-coding-agent.el b/pi-coding-agent.el index 725dfa9..86eb918 100644 --- a/pi-coding-agent.el +++ b/pi-coding-agent.el @@ -41,7 +41,7 @@ ;; Install from: https://github.com/misohena/phscroll ;; ;; Usage: -;; M-x pi Start a session in current project +;; M-x pi DWIM: toggle, reuse, or create session ;; C-u M-x pi Start a named session ;; ;; Key Bindings: @@ -129,24 +129,54 @@ Returns the chat buffer." ;;;###autoload (defun pi-coding-agent (&optional session) "Start or switch to pi coding agent session in current project. -With prefix arg, prompt for SESSION name to allow multiple sessions. -If already in a pi buffer and no SESSION specified, redisplays current session." +Without prefix arg, DWIM behavior: + - From a pi buffer: toggle visibility (hide windows). + - Existing session for project: switch to it. + - No session: create a new one. +With prefix arg, prompt for SESSION name to create a new session. +When called from Lisp with a string SESSION, create or switch to +that named session (backward-compatible)." (interactive (list (when current-prefix-arg (read-string "Session name: ")))) (pi-coding-agent--check-dependencies) - (let (chat-buf input-buf) - (if (and (derived-mode-p 'pi-coding-agent-chat-mode 'pi-coding-agent-input-mode) - (not session)) - ;; Already in pi buffer with no new session requested - use current session - (setq chat-buf (pi-coding-agent--get-chat-buffer) - input-buf (pi-coding-agent--get-input-buffer)) - ;; Find or create session for current directory - (let ((dir (pi-coding-agent--session-directory))) - (setq chat-buf (pi-coding-agent--setup-session dir session)) - (setq input-buf (buffer-local-value 'pi-coding-agent--input-buffer chat-buf)))) - ;; Display and focus - (pi-coding-agent--display-buffers chat-buf input-buf))) + (cond + ;; In a pi buffer with no session arg: toggle visibility + ((and (derived-mode-p 'pi-coding-agent-chat-mode 'pi-coding-agent-input-mode) + (not session)) + (pi-coding-agent--hide-buffers)) + ;; Existing session for this project (no session arg): reuse it + ((and (not session) + (car (pi-coding-agent-project-buffers))) + (let* ((chat-buf (car (pi-coding-agent-project-buffers))) + (input-buf (buffer-local-value 'pi-coding-agent--input-buffer chat-buf))) + (pi-coding-agent--display-buffers chat-buf input-buf))) + ;; Otherwise: create or find named session + (t + (let* ((dir (pi-coding-agent--session-directory)) + (chat-buf (pi-coding-agent--setup-session dir session)) + (input-buf (buffer-local-value 'pi-coding-agent--input-buffer chat-buf))) + (pi-coding-agent--display-buffers chat-buf input-buf))))) + +;;;###autoload +(defun pi-coding-agent-toggle () + "Toggle pi coding agent window visibility for the current project. +If pi windows are visible, hide them. If hidden but a session +exists, show them. If no session exists, signal an error." + (interactive) + (let ((chat-buf (car (pi-coding-agent-project-buffers)))) + (cond + ;; No session at all + ((null chat-buf) + (user-error "No pi session for this project")) + ;; Session visible: hide it + ((get-buffer-window-list chat-buf nil t) + (with-current-buffer chat-buf + (pi-coding-agent--hide-buffers))) + ;; Session hidden: show it + (t + (let ((input-buf (buffer-local-value 'pi-coding-agent--input-buffer chat-buf))) + (pi-coding-agent--display-buffers chat-buf input-buf)))))) ;;;; Performance Optimizations diff --git a/test/pi-coding-agent-test-common.el b/test/pi-coding-agent-test-common.el index cb2160a..e8bf6a1 100644 --- a/test/pi-coding-agent-test-common.el +++ b/test/pi-coding-agent-test-common.el @@ -119,8 +119,8 @@ Automatically cleans up chat and input buffers." ((symbol-function 'pi-coding-agent--display-buffers) #'ignore)) (unwind-protect (progn (pi-coding-agent) ,@body) - (ignore-errors (kill-buffer (pi-coding-agent--chat-buffer-name ,dir nil))) - (ignore-errors (kill-buffer (pi-coding-agent--input-buffer-name ,dir nil))))))) + (ignore-errors (kill-buffer (pi-coding-agent--buffer-name :chat ,dir nil))) + (ignore-errors (kill-buffer (pi-coding-agent--buffer-name :input ,dir nil))))))) ;;;; Two-Session Fixture diff --git a/test/pi-coding-agent-test.el b/test/pi-coding-agent-test.el index ff33c70..b1da447 100644 --- a/test/pi-coding-agent-test.el +++ b/test/pi-coding-agent-test.el @@ -30,5 +30,62 @@ (with-current-buffer "*pi-coding-agent-input:/tmp/pi-coding-agent-test-modes/*" (should (derived-mode-p 'pi-coding-agent-input-mode))))) +;;; DWIM & Toggle + +(ert-deftest pi-coding-agent-test-dwim-reuses-existing-session () + "Calling `pi-coding-agent' from a non-pi buffer reuses the existing session." + (pi-coding-agent-test-with-mock-session "/tmp/pi-coding-agent-test-dwim/" + ;; Session exists; now call from a non-pi buffer in the same project + (with-temp-buffer + (setq default-directory "/tmp/pi-coding-agent-test-dwim/") + (cl-letf (((symbol-function 'project-current) (lambda (&rest _) nil)) + ((symbol-function 'pi-coding-agent--display-buffers) #'ignore)) + (pi-coding-agent)) + ;; Should not have created a second chat buffer + (should (= 1 (length (cl-remove-if-not + (lambda (b) + (string-prefix-p "*pi-coding-agent-chat:/tmp/pi-coding-agent-test-dwim/" + (buffer-name b))) + (buffer-list)))))))) + +(ert-deftest pi-coding-agent-test-new-session-with-prefix-arg () + "\\[universal-argument] \\[pi-coding-agent] creates a named session." + (pi-coding-agent-test-with-mock-session "/tmp/pi-coding-agent-test-named/" + (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) + ((symbol-function 'read-string) (lambda (&rest _) "my-session"))) + (let ((default-directory "/tmp/pi-coding-agent-test-named/") + (current-prefix-arg '(4))) + (call-interactively #'pi-coding-agent))) + (unwind-protect + (should (get-buffer "*pi-coding-agent-chat:/tmp/pi-coding-agent-test-named/*")) + (ignore-errors + (kill-buffer "*pi-coding-agent-chat:/tmp/pi-coding-agent-test-named/*")) + (ignore-errors + (kill-buffer "*pi-coding-agent-input:/tmp/pi-coding-agent-test-named/*"))))) + +(ert-deftest pi-coding-agent-test-project-buffers-finds-session () + "`pi-coding-agent-project-buffers' returns chat buffer for the current project." + (pi-coding-agent-test-with-mock-session "/tmp/pi-coding-agent-test-projbuf/" + (let ((default-directory "/tmp/pi-coding-agent-test-projbuf/")) + (cl-letf (((symbol-function 'project-current) (lambda (&rest _) nil))) + (should (= 1 (length (pi-coding-agent-project-buffers)))) + (should (string-prefix-p "*pi-coding-agent-chat:" + (buffer-name (car (pi-coding-agent-project-buffers))))))))) + +(ert-deftest pi-coding-agent-test-project-buffers-excludes-other-projects () + "`pi-coding-agent-project-buffers' returns nil for a different project." + (pi-coding-agent-test-with-mock-session "/tmp/pi-coding-agent-test-projbuf-a/" + (let ((default-directory "/tmp/pi-coding-agent-test-projbuf-b/")) + (cl-letf (((symbol-function 'project-current) (lambda (&rest _) nil))) + (should (null (pi-coding-agent-project-buffers))))))) + +(ert-deftest pi-coding-agent-test-toggle-no-session-errors () + "`pi-coding-agent-toggle' signals `user-error' when no session exists." + (let ((default-directory "/tmp/pi-coding-agent-test-no-session/")) + (cl-letf (((symbol-function 'project-current) (lambda (&rest _) nil))) + (should-error (pi-coding-agent-toggle) :type 'user-error)))) + (provide 'pi-coding-agent-test) ;;; pi-coding-agent-test.el ends here