diff --git a/ai-code-prompt-mode.el b/ai-code-prompt-mode.el index d143be8e..9b3bee82 100644 --- a/ai-code-prompt-mode.el +++ b/ai-code-prompt-mode.el @@ -22,6 +22,11 @@ (defvar ai-code-discussion-auto-follow-up-enabled) (defvar ai-code-discussion-auto-follow-up-suffix) (defvar ai-code-use-prompt-suffix) +(defvar ai-code-backends-infra--session-terminal-backend nil + "Buffer-local terminal backend symbol for an AI session buffer, or nil. +This is set by `ai-code-backends-infra.el' for terminal-managed sessions +such as `vterm' and `eat'. A nil value means the buffer is not managed by +the terminal backend infrastructure.") (declare-function yas-load-directory "yasnippet" (dir)) (declare-function yas-minor-mode "yasnippet") @@ -35,6 +40,11 @@ (declare-function ai-code--choose-symbol-from-file "ai-code-input" (file)) (declare-function ai-code-read-string "ai-code-input" (prompt &optional initial-input candidate-list)) (declare-function ai-code-current-backend-label "ai-code-backends" ()) +(declare-function ai-code-backends-infra--session-buffer-p "ai-code-backends-infra" (buffer)) +(declare-function ai-code-backends-infra--session-buffer-matches-directory-p "ai-code-backends-infra" (buffer directory)) +(declare-function ai-code-backends-infra--terminal-send-string "ai-code-backends-infra" (string)) +(declare-function ai-code-backends-infra--terminal-send-return "ai-code-backends-infra" ()) +(declare-function ai-code-backends-infra--display-buffer-in-side-window "ai-code-backends-infra" (buffer)) (defcustom ai-code-prompt-preprocess-filepaths t "When non-nil, preprocess the prompt to replace file paths. @@ -176,10 +186,70 @@ that should be recorded in the prompt history file." (ai-code--insert-backend-label-drawer) (ai-code--format-and-insert-prompt stored-prompt-text)) +(defun ai-code--find-visible-session-buffer () + "Return a visible terminal-managed AI session buffer in the current frame." + (cl-some + (lambda (win) + (let ((buf (window-buffer win))) + (when (and (buffer-live-p buf) + (ai-code-backends-infra--session-buffer-p buf) + (buffer-local-value + 'ai-code-backends-infra--session-terminal-backend buf)) + buf))) + (window-list nil 'no-minibuffer))) + +(defun ai-code--find-project-session-buffers () + "Return terminal-managed AI session buffers associated with the current project." + (when-let ((git-root (ai-code--git-root))) + (cl-remove-if-not + (lambda (buf) + (and (ai-code-backends-infra--session-buffer-p buf) + (buffer-local-value + 'ai-code-backends-infra--session-terminal-backend buf) + (ai-code-backends-infra--session-buffer-matches-directory-p buf git-root))) + (buffer-list)))) + +(defun ai-code--prompt-choose-target-session () + "Choose AI session buffer to send prompt to. +Return session buffer for direct routing, or nil to use default backend dispatch." + (when-let ((visible-session (ai-code--find-visible-session-buffer))) + (let* ((project-sessions (ai-code--find-project-session-buffers)) + (visible-is-project-session (memq visible-session project-sessions)) + (competing-sessions + (cl-remove visible-session project-sessions))) + (cond + (visible-is-project-session nil) + ((null competing-sessions) + visible-session) + (t + (let* ((choice-alist (mapcar (lambda (buf) + (cons (buffer-name buf) buf)) + (cons visible-session competing-sessions))) + (selection (completing-read + "Multiple AI sessions available. Send to: " + (mapcar #'car choice-alist) + nil t nil nil (buffer-name visible-session)))) + (cdr (assoc selection choice-alist)))))))) + +(defun ai-code--send-prompt-to-session-buffer (prompt buffer) + "Send PROMPT directly to session BUFFER and display it." + (with-current-buffer buffer + (ai-code-backends-infra--terminal-send-string prompt) + (sit-for 0.5) + (ai-code-backends-infra--terminal-send-return)) + (if-let ((window (get-buffer-window buffer))) + (select-window window) + (ai-code-backends-infra--display-buffer-in-side-window buffer))) + (defun ai-code--send-prompt (full-prompt) - "Send FULL-PROMPT to AI." - (ai-code-cli-send-command full-prompt) - (ai-code-cli-switch-to-buffer)) + "Send FULL-PROMPT to AI. +When a visible AI session buffer is detected in the current frame, +send the prompt directly to it instead of going through the default +backend dispatch." + (if-let ((target (ai-code--prompt-choose-target-session))) + (ai-code--send-prompt-to-session-buffer full-prompt target) + (ai-code-cli-send-command full-prompt) + (ai-code-cli-switch-to-buffer))) (defun ai-code--write-prompt-to-file-and-send (prompt-text) "Write PROMPT-TEXT to the AI prompt file." diff --git a/test/test_ai-code-prompt-mode.el b/test/test_ai-code-prompt-mode.el index d320d79e..dd53f170 100644 --- a/test/test_ai-code-prompt-mode.el +++ b/test/test_ai-code-prompt-mode.el @@ -1474,5 +1474,169 @@ and ensures everything is cleaned up afterward." (should (string-match-p (regexp-quote ":AGENT: gemini") content)) (should (string-match-p (regexp-quote ":END:") content)))))) +;;; Tests for visible session routing in ai-code--send-prompt + +(ert-deftest ai-code-test-find-visible-session-buffer-returns-session () + "Find a session buffer in visible windows." + (let ((session-buf (get-buffer-create "*claude[test-project]*"))) + (unwind-protect + (with-current-buffer session-buf + (setq-local ai-code-backends-infra--session-terminal-backend 'vterm) + (cl-letf (((symbol-function 'window-list) + (lambda (&optional _frame _no-minibuf) + '(win1 win2))) + ((symbol-function 'window-buffer) + (lambda (win) + (if (eq win 'win1) (get-buffer "*scratch*") session-buf)))) + (should (eq (ai-code--find-visible-session-buffer) session-buf)))) + (kill-buffer session-buf)))) + +(ert-deftest ai-code-test-find-visible-session-buffer-nil-when-no-sessions () + "Return nil when no session buffers are visible." + (cl-letf (((symbol-function 'window-list) + (lambda (&optional _frame _no-minibuf) '(win1))) + ((symbol-function 'window-buffer) + (lambda (_win) (get-buffer "*scratch*")))) + (should-not (ai-code--find-visible-session-buffer)))) + +(ert-deftest ai-code-test-find-visible-session-buffer-ignores-non-terminal-sessions () + "Ignore visible session-like buffers that are not terminal managed." + (let ((session-buf (get-buffer-create "*claude[test-project]*"))) + (unwind-protect + (cl-letf (((symbol-function 'window-list) + (lambda (&optional _frame _no-minibuf) '(win1))) + ((symbol-function 'window-buffer) + (lambda (_win) session-buf))) + (should-not (ai-code--find-visible-session-buffer))) + (kill-buffer session-buf)))) + +(ert-deftest ai-code-test-find-project-session-buffers-finds-matching () + "Find session buffers matching the current project directory." + (ai-code-with-test-repo + (let ((session-buf (get-buffer-create "*claude[test-repo]*"))) + (unwind-protect + (with-current-buffer session-buf + (setq-local ai-code-backends-infra--session-terminal-backend 'vterm) + (cl-letf (((symbol-function 'ai-code-backends-infra--session-buffer-matches-directory-p) + (lambda (_buf _dir) (eq _buf session-buf)))) + (should (memq session-buf (ai-code--find-project-session-buffers))))) + (kill-buffer session-buf))))) + +(ert-deftest ai-code-test-find-project-session-buffers-excludes-other-projects () + "Exclude session buffers for other projects." + (ai-code-with-test-repo + (let ((other-buf (get-buffer-create "*claude[other-project]*"))) + (unwind-protect + (with-current-buffer other-buf + (setq-local ai-code-backends-infra--session-terminal-backend 'vterm) + (cl-letf (((symbol-function 'ai-code-backends-infra--session-buffer-matches-directory-p) + (lambda (_buf _dir) nil))) + (should-not (memq other-buf (ai-code--find-project-session-buffers))))) + (kill-buffer other-buf))))) + +(ert-deftest ai-code-test-find-project-session-buffers-excludes-non-terminal-sessions () + "Exclude project sessions that are not terminal managed." + (ai-code-with-test-repo + (let ((session-buf (get-buffer-create "*claude[test-repo]*"))) + (unwind-protect + (cl-letf (((symbol-function 'ai-code-backends-infra--session-buffer-matches-directory-p) + (lambda (_buf _dir) t))) + (should-not (memq session-buf (ai-code--find-project-session-buffers)))) + (kill-buffer session-buf))))) + +(ert-deftest ai-code-test-prompt-choose-target-session-nil-when-no-visible () + "Return nil when no visible session buffers." + (cl-letf (((symbol-function 'ai-code--find-visible-session-buffer) + (lambda () nil))) + (should-not (ai-code--prompt-choose-target-session)))) + +(ert-deftest ai-code-test-prompt-choose-target-session-returns-visible-when-only-option () + "Return nil when visible session belongs to the same project (default dispatch)." + (let ((session-buf (get-buffer-create "*claude[test]*"))) + (unwind-protect + (cl-letf (((symbol-function 'ai-code--find-visible-session-buffer) + (lambda () session-buf)) + ((symbol-function 'ai-code--find-project-session-buffers) + (lambda () (list session-buf)))) + (should-not (ai-code--prompt-choose-target-session))) + (kill-buffer session-buf)))) + +(ert-deftest ai-code-test-prompt-choose-target-session-returns-visible-when-no-project-session () + "Return visible session buffer when current project has no sessions." + (let ((visible-buf (get-buffer-create "*gemini[other-project]*"))) + (unwind-protect + (cl-letf (((symbol-function 'ai-code--find-visible-session-buffer) + (lambda () visible-buf)) + ((symbol-function 'ai-code--find-project-session-buffers) + (lambda () nil))) + (should (eq (ai-code--prompt-choose-target-session) visible-buf))) + (kill-buffer visible-buf)))) + +(ert-deftest ai-code-test-prompt-choose-target-session-asks-when-sessions-differ () + "Ask user to choose when visible and project sessions differ." + (let ((visible-buf (get-buffer-create "*gemini[other-project]*")) + (project-buf (get-buffer-create "*claude[my-project]*")) + (offered-choices nil)) + (unwind-protect + (cl-letf (((symbol-function 'ai-code--find-visible-session-buffer) + (lambda () visible-buf)) + ((symbol-function 'ai-code--find-project-session-buffers) + (lambda () (list project-buf))) + ((symbol-function 'completing-read) + (lambda (_prompt collection &rest _args) + (setq offered-choices collection) + (buffer-name project-buf)))) + (let ((result (ai-code--prompt-choose-target-session))) + (should (eq result project-buf)) + (should (member (buffer-name visible-buf) offered-choices)) + (should (member (buffer-name project-buf) offered-choices)))) + (kill-buffer visible-buf) + (kill-buffer project-buf)))) + +(ert-deftest ai-code-test-send-prompt-uses-visible-session-directly () + "Send prompt directly to visible session when target is resolved." + (let ((session-buf (get-buffer-create "*claude[test]*")) + (sent-string nil) + (return-sent nil) + (displayed-buffer nil) + (cli-send-called nil)) + (unwind-protect + (cl-letf (((symbol-function 'ai-code--prompt-choose-target-session) + (lambda () session-buf)) + ((symbol-function 'ai-code-backends-infra--terminal-send-string) + (lambda (str) (setq sent-string str))) + ((symbol-function 'ai-code-backends-infra--terminal-send-return) + (lambda () (setq return-sent t))) + ((symbol-function 'get-buffer-window) + (lambda (_buf &rest _) nil)) + ((symbol-function 'ai-code-backends-infra--display-buffer-in-side-window) + (lambda (buf) (setq displayed-buffer buf))) + ((symbol-function 'ai-code-cli-send-command) + (lambda (_cmd) (setq cli-send-called t))) + ((symbol-function 'ai-code-cli-switch-to-buffer) + (lambda () nil)) + ((symbol-function 'sit-for) + (lambda (_secs) nil))) + (ai-code--send-prompt "test prompt") + (should (string= sent-string "test prompt")) + (should return-sent) + (should (eq displayed-buffer session-buf)) + (should-not cli-send-called)) + (kill-buffer session-buf)))) + +(ert-deftest ai-code-test-send-prompt-falls-through-when-no-visible-session () + "Use default path when no visible session is chosen." + (let ((cli-send-called nil) + (switch-called nil)) + (cl-letf (((symbol-function 'ai-code--prompt-choose-target-session) + (lambda () nil)) + ((symbol-function 'ai-code-cli-send-command) + (lambda (cmd) (setq cli-send-called cmd))) + ((symbol-function 'ai-code-cli-switch-to-buffer) + (lambda () (setq switch-called t)))) + (ai-code--send-prompt "test prompt") + (should (string= cli-send-called "test prompt")) + (should switch-called)))) + (provide 'test-ai-code-prompt-mode) ;;; test_ai-code-prompt-mode.el ends here