diff --git a/ai-code-backends-infra-ghostel.el b/ai-code-backends-infra-ghostel.el index 79f16cc..76413c2 100644 --- a/ai-code-backends-infra-ghostel.el +++ b/ai-code-backends-infra-ghostel.el @@ -33,7 +33,9 @@ (defvar ai-code-backends-infra--session-terminal-backend) (defvar ghostel--copy-mode-active nil) -(defvar ghostel-set-title-function nil) +(defvar ghostel--input-mode) +(defvar ghostel-kill-buffer-on-exit) +(defvar ghostel-set-title-function) (defun ai-code-backends-infra-ghostel-ensure-backend () "Ensure the Ghostel backend is available." @@ -43,7 +45,8 @@ (defun ai-code-backends-infra-ghostel-navigation-mode-p () "Return non-nil when the current Ghostel buffer is in copy mode." - (bound-and-true-p ghostel--copy-mode-active)) + (or (bound-and-true-p ghostel--copy-mode-active) + (eq ghostel--input-mode 'copy))) (defun ai-code-backends-infra-ghostel-install-navigation-cursor-sync () "Install cursor synchronization for Ghostel navigation mode." @@ -73,6 +76,7 @@ (defun ai-code-backends-infra--configure-ghostel-buffer () "Configure the current Ghostel buffer for AI Code sessions." (setq-local ghostel-set-title-function nil) + (setq-local ghostel-kill-buffer-on-exit nil) (ai-code-backends-infra--configure-session-input-shortcuts) (ai-code-backends-infra--install-navigation-cursor-sync)) @@ -86,7 +90,10 @@ (cond ((not program) nil) ((fboundp 'ghostel-exec) - (ghostel-exec buffer program args)) + (let ((proc (ghostel-exec buffer program args))) + ;; `ghostel-exec' enters `ghostel-mode', which resets local state. + (ai-code-backends-infra--configure-ghostel-buffer) + proc)) (t (user-error "Ghostel backend requires a Ghostel version that provides `ghostel-exec`")))))) @@ -107,6 +114,11 @@ variables for the terminal process." (when (processp proc) (ignore-errors (set-process-query-on-exit-flag proc nil)) + (when-let ((sentinel (ignore-errors (process-sentinel proc)))) + (ignore-errors + (process-put proc + 'ai-code-backends-infra--ghostel-sentinel + sentinel))) (let ((orig-filter (process-filter proc))) (set-process-filter proc diff --git a/ai-code-backends-infra.el b/ai-code-backends-infra.el index a51df80..7bc0d73 100644 --- a/ai-code-backends-infra.el +++ b/ai-code-backends-infra.el @@ -152,7 +152,7 @@ being sent for the response completion.") This validates the textual shape, but not UUID version or variant bits.") (defun ai-code-backends-infra--selected-session-id () - "Return the active region text when it contains a UUID session id." + "Return active region text if it has a UUID session id." (when (use-region-p) (let ((candidate (string-trim @@ -197,11 +197,12 @@ Set to t to debug scrollback-preservation transformations.") (defvar-local ai-code-backends-infra--last-scrollback-inject-time 0 "Timestamp of the last scrollback-preservation injection. -Used to throttle injections per `ai-code-backends-infra-scrollback-inject-interval'.") +Used to throttle injections per +`ai-code-backends-infra-scrollback-inject-interval'.") (defvar-local ai-code-backends-infra--sync-redraw-scrollback nil - "When non-nil, inject scrollback-preserving newlines before -synchronized-update frame redraws (\\e[?2026h\\e[1;1H). + "Non-nil means inject scrollback-preserving newlines before redraws. +This applies to synchronized-update frame redraws (\\e[?2026h\\e[1;1H). Backends that hardcode alternate screen buffer should set this to t via their post-start-fn.") @@ -304,7 +305,7 @@ the current buffer is an AI session buffer, apply these transformations: (declare-function ai-code-notifications-response-ready "ai-code-notifications" (&optional backend-name)) (defun ai-code-backends-infra--output-meaningful-p (output) - "Return non-nil when OUTPUT contains meaningful printable content." + "Return non-nil if OUTPUT has meaningful printable content." (let* ((str (or output "")) ;; Strip OSC sequences (ESC ] ... BEL or ESC ] ... ESC \). (str (replace-regexp-in-string "\x1b\\][^\x07\x1b]*\\(?:\x07\\|\x1b\\\\\\)" "" str)) @@ -502,9 +503,9 @@ When BACKEND is nil, use `ai-code-backends-infra-terminal-backend'." (defun ai-code-backends-infra--terminal-reflow-filter (original-fn &rest args) "Filter terminal reflows to prevent height-only resize triggers. +ORIGINAL-FN is the terminal resize function and ARGS are its arguments. Suppress reflow when terminal width is unchanged or when the session -buffer is in scroll/copy mode, working around bug #1422. -ORIGINAL-FN and ARGS are the resize handler and arguments." +buffer is in scroll/copy mode, working around bug #1422." (let* ((base-result (apply original-fn args)) (dimensions-stable t)) (dolist (win (window-list)) @@ -606,19 +607,24 @@ from the window where it was initially created." (let ((backend (ai-code-backends-infra--current-terminal-backend)) (windows (or (get-buffer-window-list buffer nil t) (list window)))) - (pcase backend - ('vterm - (let ((result - (funcall (ai-code-backends-infra--terminal-resize-handler - 'vterm) - proc windows))) - (when result - (ai-code-backends-infra-vterm-flush-render-queue buffer)) - result)) - (_ - (set-process-window-size proc - (window-body-height window) - (window-body-width window))))))))) + (if (eq backend 'ghostel) + (when-let ((size (funcall (ai-code-backends-infra-ghostel-resize-handler) + proc + windows))) + (set-process-window-size proc (cdr size) (car size))) + (pcase backend + ('vterm + (let ((result + (funcall (ai-code-backends-infra--terminal-resize-handler + 'vterm) + proc windows))) + (when result + (ai-code-backends-infra-vterm-flush-render-queue buffer)) + result)) + (_ + (set-process-window-size proc + (window-body-height window) + (window-body-width window)))))))))) ;;; Session Helpers @@ -661,6 +667,7 @@ from the window where it was initially created." (defun ai-code-backends-infra--attached-file-session (prefix source-buffer working-dir) "Return attached session state for PREFIX and SOURCE-BUFFER. +WORKING-DIR is the directory used to validate compatible sessions. Return a cons of (BUFFER . MISSING-P)." (let ((key (ai-code-backends-infra--file-session-map-key prefix source-buffer))) (if (null key) @@ -831,6 +838,7 @@ Return a cons of (base-name . instance-name) or nil." (defun ai-code-backends-infra--select-session-buffer (prefix directory &optional force-prompt) "Select a session buffer for PREFIX in DIRECTORY. +FORCE-PROMPT means always prompt even if a session was remembered. Returns the selected buffer or nil if none exist." (let* ((remembered (gethash (ai-code-backends-infra--session-map-key prefix directory) ai-code-backends-infra--directory-buffer-map)) @@ -902,8 +910,9 @@ DEFAULT-INSTANCE-NAME seeds the minibuffer when prompting." "default")) (defun ai-code-backends-infra--resolve-start-command (program switches arg &optional prompt-label) - "Build command string for PROGRAM and SWITCHES. -When ARG is non-nil, prompt for CLI args using SWITCHES as default input. + "Build command string for PROGRAM. +SWITCHES is the default command-line argument list. +Use it as default input when ARG is non-nil and CLI args are prompted. PROMPT-LABEL is used in the minibuffer prompt. When resuming and the active region contains a UUID, prompt as though ARG were non-nil and append that UUID to the default CLI args." @@ -935,6 +944,7 @@ were non-nil and append that UUID to the default CLI args." (defun ai-code-backends-infra--cleanup-session (directory buffer-name process-table &optional instance-name prefix event) "Clean up a session for DIRECTORY using BUFFER-NAME and PROCESS-TABLE. +INSTANCE-NAME and PREFIX identify the session map entry to remove. EVENT is the process sentinel event string. When EVENT is non-nil and does not start with \"finished\", the buffer is preserved so the user can inspect any error output left behind by the CLI." @@ -1020,6 +1030,7 @@ Return a plist with target information plus the current buffer and process." (defun ai-code-backends-infra--reuse-session-window (buffer working-dir prefix multiline-input-sequence) "Toggle visibility for an existing session BUFFER. +WORKING-DIR, PREFIX, and MULTILINE-INPUT-SEQUENCE refresh session state. When BUFFER is already visible, close its window. Otherwise refresh session-local state and display it." (if (get-buffer-window buffer) @@ -1036,19 +1047,28 @@ Otherwise refresh session-local state and display it." prefix escape-fn cleanup-fn multiline-input-sequence post-start-fn) - "Finalize a successfully started session BUFFER and PROCESS." - (set-process-sentinel - process - (lambda (_proc event) - (ai-code-backends-infra--cleanup-session - working-dir - buffer-name - process-table - resolved-instance - prefix - event) - (when cleanup-fn - (funcall cleanup-fn)))) + "Finalize a successfully started session BUFFER and PROCESS. +WORKING-DIR, BUFFER-NAME, PROCESS-TABLE, RESOLVED-INSTANCE, and PREFIX +identify the session for cleanup and reuse. ESCAPE-FN, CLEANUP-FN, +MULTILINE-INPUT-SEQUENCE, and POST-START-FN install session behavior." + (let ((previous-sentinel + (ignore-errors + (process-get process 'ai-code-backends-infra--ghostel-sentinel)))) + (set-process-sentinel + process + (lambda (proc event) + (when previous-sentinel + (ignore-errors + (funcall previous-sentinel proc event))) + (ai-code-backends-infra--cleanup-session + working-dir + buffer-name + process-table + resolved-instance + prefix + event) + (when cleanup-fn + (funcall cleanup-fn))))) (ai-code-backends-infra--configure-session-buffer buffer escape-fn multiline-input-sequence) (when post-start-fn @@ -1065,7 +1085,7 @@ Otherwise refresh session-local state and display it." (ai-code-backends-infra--display-buffer-in-side-window buffer)) (defun ai-code-backends-infra--handle-session-start-failure (buffer session-key process-table) - "Handle startup failure for BUFFER and SESSION-KEY." + "Handle startup failure for BUFFER and SESSION-KEY in PROCESS-TABLE." (remhash session-key process-table) (if (buffer-live-p buffer) (progn @@ -1151,6 +1171,7 @@ session starts successfully." (defun ai-code-backends-infra--switch-to-session-buffer (buffer-name missing-message &optional prefix working-dir force-prompt) "Switch to BUFFER-NAME or signal MISSING-MESSAGE. +FORCE-PROMPT means always prompt when PREFIX and WORKING-DIR are provided. When PREFIX and WORKING-DIR are provided, select from multiple sessions." (let* ((source-buffer (current-buffer)) (buffer (ai-code-backends-infra--resolve-session-buffer diff --git a/test/test_ai-code-backends-infra.el b/test/test_ai-code-backends-infra.el index f2d14e7..b6ebc30 100644 --- a/test/test_ai-code-backends-infra.el +++ b/test/test_ai-code-backends-infra.el @@ -18,13 +18,28 @@ (defvar vterm-copy-mode-hook) (defvar eat-term-name) (defvar ghostel-set-title-function) +(defvar ghostel-kill-buffer-on-exit) (defvar ghostel--copy-mode-active) +(defvar ghostel--input-mode) (defvar ghostel--process) (defconst test-ai-code-backends-infra-valid-uuid "123e4567-e89b-12d3-a456-426614174000" "UUID fixture used by resume command resolution tests.") +(defun test-ai-code-backends-infra--capture-default-binding (symbol) + "Return SYMBOL's default binding state. +The result is a cons of whether SYMBOL is bound and its default value." + (if (boundp symbol) + (cons t (default-value symbol)) + (cons nil nil))) + +(defun test-ai-code-backends-infra--restore-default-binding (symbol state) + "Restore SYMBOL's default binding STATE from `...--capture-default-binding'." + (if (car state) + (set-default symbol (cdr state)) + (makunbound symbol))) + (ert-deftest test-ai-code-backends-infra-output-meaningful-p-noise () "Ensure terminal noise is not considered meaningful output." (should-not (ai-code-backends-infra--output-meaningful-p nil)) @@ -67,6 +82,27 @@ (should (re-search-forward "^(defcustom ai-code-backends-infra-eat-preserve-position\\_>" nil t)))) +(ert-deftest test-ai-code-backends-infra-ghostel-forward-declarations-do-not-set-defaults () + "Ghostel forward declarations should not override Ghostel defaults." + (with-temp-buffer + (insert-file-contents "ai-code-backends-infra-ghostel.el") + (let (forms) + (condition-case nil + (while t + (push (read (current-buffer)) forms)) + (end-of-file nil)) + (setq forms (nreverse forms)) + (dolist (symbol '(ghostel-kill-buffer-on-exit + ghostel-set-title-function)) + (let ((form (seq-find + (lambda (sexp) + (and (listp sexp) + (eq (car sexp) 'defvar) + (eq (cadr sexp) symbol))) + forms))) + (should form) + (should (= (length form) 2))))))) + (ert-deftest test-ai-code-backends-infra--resume-double-dash-prefills-uuid () "A selected UUID should make `--resume' prompt with that id appended." (let ((uuid test-ai-code-backends-infra-valid-uuid) @@ -335,6 +371,29 @@ (advice-remove handler #'ai-code-backends-infra--terminal-reflow-filter)) (fmakunbound handler))))) +(ert-deftest test-ai-code-backends-infra-sync-terminal-dimensions-uses-ghostel-handler () + "Ghostel dimension sync should update Ghostel's terminal model before PTY size." + (let ((adjust-calls nil) + (set-size-calls nil)) + (cl-letf (((symbol-function 'get-buffer-process) + (lambda (_buffer) 'ghostel-proc)) + ((symbol-function 'window-live-p) + (lambda (_window) t)) + ((symbol-function 'ghostel--window-adjust-process-window-size) + (lambda (process windows) + (push (list process windows) adjust-calls) + '(90 . 24))) + ((symbol-function 'set-process-window-size) + (lambda (process height width) + (push (list process height width) set-size-calls)))) + (with-temp-buffer + (setq-local ai-code-backends-infra--session-terminal-backend 'ghostel) + (ai-code-backends-infra--sync-terminal-dimensions + (current-buffer) + 'mock-window))) + (should (equal adjust-calls '((ghostel-proc (mock-window))))) + (should (equal set-size-calls '((ghostel-proc 24 90)))))) + (ert-deftest test-ai-code-backends-infra-display-buffer-in-side-window-uses-body-width () "Horizontal side windows should size to the configured body width." (with-temp-buffer @@ -521,6 +580,9 @@ (setq-local ghostel--copy-mode-active t) (should (ai-code-backends-infra--terminal-navigation-mode-p)) (setq-local ghostel--copy-mode-active nil) + (setq-local ghostel--input-mode 'copy) + (should (ai-code-backends-infra--terminal-navigation-mode-p)) + (setq-local ghostel--input-mode 'semi-char) (should-not (ai-code-backends-infra--terminal-navigation-mode-p)))) (ert-deftest test-ai-code-backends-infra-configure-vterm-buffer-installs-cursor-sync-hook () @@ -670,6 +732,8 @@ (lambda (_process) (lambda (_process _output) (setq orig-filter-called t)))) + ((symbol-function 'process-sentinel) + (lambda (_process) nil)) ((symbol-function 'set-process-filter) (lambda (_process filter) (setq wrapped-filter filter))) @@ -852,7 +916,9 @@ (buffer (get-buffer-create buffer-name)) (process 'ghostel-proc) (title-tracking-before-start :unset) - (saved-default (default-value 'ghostel-set-title-function)) + (saved-default + (test-ai-code-backends-infra--capture-default-binding + 'ghostel-set-title-function)) (ai-code-backends-infra-terminal-backend 'ghostel)) (unwind-protect (progn @@ -875,7 +941,48 @@ "echo hi" nil) (should (eq title-tracking-before-start nil)))) - (setq-default ghostel-set-title-function saved-default) + (test-ai-code-backends-infra--restore-default-binding + 'ghostel-set-title-function + saved-default) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + +(ert-deftest test-ai-code-backends-infra-create-terminal-session-ghostel-disables-title-tracking-after-exec () + "Ghostel startup should keep title tracking disabled after `ghostel-exec'." + (let* ((buffer-name "*test-ai-code-ghostel-title-tracking-after-exec*") + (buffer (get-buffer-create buffer-name)) + (process 'ghostel-proc) + (saved-default + (test-ai-code-backends-infra--capture-default-binding + 'ghostel-set-title-function)) + (ai-code-backends-infra-terminal-backend 'ghostel)) + (unwind-protect + (progn + (setq-default ghostel-set-title-function #'ignore) + (cl-letf (((symbol-function 'ai-code-backends-infra--terminal-ensure-backend) + (lambda () nil)) + ((symbol-function 'ghostel-exec) + (lambda (target-buffer _program &optional _args) + (with-current-buffer target-buffer + ;; `ghostel-exec' enters `ghostel-mode', which resets + ;; buffer-local title-tracking state. + (kill-local-variable 'ghostel-set-title-function) + (setq-local ghostel--process process)) + process)) + ((symbol-function 'get-buffer-process) + (lambda (target-buffer) + (with-current-buffer target-buffer + ghostel--process)))) + (ai-code-backends-infra--create-terminal-session + buffer-name + default-directory + "echo hi" + nil) + (with-current-buffer buffer + (should-not ghostel-set-title-function)))) + (test-ai-code-backends-infra--restore-default-binding + 'ghostel-set-title-function + saved-default) (when (buffer-live-p buffer) (kill-buffer buffer))))) @@ -914,6 +1021,47 @@ (when (file-directory-p working-dir) (delete-directory working-dir t))))) +(ert-deftest test-ai-code-backends-infra-create-terminal-session-ghostel-preserves-native-sentinel () + "Ghostel startup should preserve Ghostel's process sentinel for chaining." + (let* ((buffer-name "*test-ai-code-ghostel-native-sentinel*") + (buffer (get-buffer-create buffer-name)) + (proc (make-process :name "ai-code-ghostel-native-sentinel" + :buffer buffer + :command '("sleep" "10") + :noquery t)) + (native-sentinel (lambda (&rest _args) nil)) + (saved-kill-default + (test-ai-code-backends-infra--capture-default-binding + 'ghostel-kill-buffer-on-exit)) + (ai-code-backends-infra-terminal-backend 'ghostel)) + (unwind-protect + (progn + (setq-default ghostel-kill-buffer-on-exit t) + (set-process-sentinel proc native-sentinel) + (cl-letf (((symbol-function 'ai-code-backends-infra--terminal-ensure-backend) + (lambda () nil)) + ((symbol-function 'ghostel-exec) + (lambda (target-buffer _program &optional _args) + (with-current-buffer target-buffer + (setq-local ghostel--process proc)) + proc))) + (ai-code-backends-infra--create-terminal-session + buffer-name + default-directory + "echo hi" + nil) + (should (eq (process-get proc 'ai-code-backends-infra--ghostel-sentinel) + native-sentinel)) + (with-current-buffer buffer + (should-not ghostel-kill-buffer-on-exit)))) + (test-ai-code-backends-infra--restore-default-binding + 'ghostel-kill-buffer-on-exit + saved-kill-default) + (when (process-live-p proc) + (delete-process proc)) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + (ert-deftest test-ai-code-backends-infra-configure-ghostel-buffer-installs-cursor-sync-hook () "Ghostel session configuration should only add AI Code local behavior." (let ((hook-calls nil)) @@ -934,7 +1082,9 @@ (ert-deftest test-ai-code-backends-infra-configure-ghostel-buffer-disables-title-tracking () "Ghostel AI session buffers should keep their original buffer names." - (let ((saved-default (default-value 'ghostel-set-title-function))) + (let ((saved-default + (test-ai-code-backends-infra--capture-default-binding + 'ghostel-set-title-function))) (unwind-protect (progn (setq-default ghostel-set-title-function #'ignore) @@ -948,7 +1098,9 @@ (ai-code-backends-infra--configure-ghostel-buffer) (should-not ghostel-set-title-function) (should (eq (default-value 'ghostel-set-title-function) #'ignore))))) - (setq-default ghostel-set-title-function saved-default)))) + (test-ai-code-backends-infra--restore-default-binding + 'ghostel-set-title-function + saved-default)))) (ert-deftest test-ai-code-backends-infra-create-terminal-session-ghostel-wraps-output-filter () "Ghostel session creation should track meaningful output and linkify output." @@ -2301,6 +2453,54 @@ (when (buffer-live-p buffer) (kill-buffer buffer))))) +(ert-deftest test-ai-code-backends-infra-finalize-started-session-chains-ghostel-sentinel () + "Successful startup finalization should run Ghostel's native sentinel first." + (let* ((working-dir "/tmp/ai-code-finalize-ghostel-sentinel/") + (prefix "claude") + (buffer-name "*claude[finalize-ghostel-sentinel]*") + (buffer (get-buffer-create buffer-name)) + (process 'mock-process) + (process-table (make-hash-table :test 'equal)) + (installed-sentinel nil) + (calls nil)) + (unwind-protect + (cl-letf (((symbol-function 'process-get) + (lambda (_process prop) + (and (eq prop 'ai-code-backends-infra--ghostel-sentinel) + (lambda (proc event) + (push (list :ghostel proc event) calls))))) + ((symbol-function 'set-process-sentinel) + (lambda (_process fn) + (setq installed-sentinel fn))) + ((symbol-function 'ai-code-backends-infra--cleanup-session) + (lambda (&rest _args) + (push :cleanup calls))) + ((symbol-function 'ai-code-backends-infra--configure-session-buffer) + (lambda (&rest _args) nil)) + ((symbol-function 'ai-code-backends-infra--remember-session-buffer) + (lambda (&rest _args) nil)) + ((symbol-function 'ai-code-backends-infra--display-buffer-in-side-window) + (lambda (&rest _args) nil))) + (ai-code-backends-infra--finalize-started-session + buffer + process + working-dir + buffer-name + process-table + "default" + prefix + nil + nil + nil + nil) + (should installed-sentinel) + (funcall installed-sentinel process "finished\n") + (should (equal (nreverse calls) + (list (list :ghostel process "finished\n") + :cleanup)))) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + (ert-deftest test-ai-code-backends-infra-handle-session-start-failure-shows-live-buffer () "Startup failure should preserve and show a live buffer with an error message." (let* ((session-key '("/tmp/ai-code-start-failure/" . "default"))