Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 89 additions & 4 deletions ai-code-backends-infra-ghostel.el
Original file line number Diff line number Diff line change
Expand Up @@ -25,20 +25,26 @@
"ai-code-backends-infra" (buffer directory))
(declare-function ai-code-backends-infra--sync-terminal-cursor
"ai-code-backends-infra" ())
(declare-function ai-code-session-update-metadata
"ai-code-session" (id-or-buffer metadata))
(declare-function ghostel-exec "ghostel" (buffer program &optional args))
(declare-function ghostel-send-key "ghostel" (key-name &optional mods))
(declare-function ghostel-send-string "ghostel" (string))
(declare-function ghostel-paste-string "ghostel" (string))
(declare-function ghostel--window-adjust-process-window-size
"ghostel" (process windows))

(defvar ai-code-backends-infra--session-terminal-backend)
(eval-when-compile
(defvar ghostel-command-finish-functions)
(defvar ghostel-command-start-functions)
(defvar ghostel-kill-buffer-on-exit)
(defvar ghostel-progress-function)
(defvar ghostel-set-title-function)
(defvar ghostel--copy-mode-active)
(defvar ghostel--input-mode))

(defvar-local ai-code-backends-infra-ghostel--progress-function nil
"Original Ghostel progress function captured before AI Code wrapping.")

(defun ai-code-backends-infra-ghostel-ensure-backend ()
"Ensure the Ghostel backend is available."
(unless (featurep 'ghostel) (require 'ghostel nil t))
Expand All @@ -55,6 +61,83 @@
(add-hook 'post-command-hook
#'ai-code-backends-infra--sync-terminal-cursor nil t))

(defun ai-code-backends-infra-ghostel--ai-session-buffer-p (&optional buffer)
"Return non-nil when BUFFER is an AI Code Ghostel session buffer."
(when (buffer-live-p (or buffer (current-buffer)))
(with-current-buffer (or buffer (current-buffer))
(eq ai-code-backends-infra--session-terminal-backend 'ghostel))))

(defun ai-code-backends-infra-ghostel--update-session-metadata (buffer metadata)
"Merge METADATA into the AI Code session associated with BUFFER."
(when (and (buffer-live-p buffer)
(fboundp 'ai-code-session-update-metadata)
(ai-code-backends-infra-ghostel--ai-session-buffer-p buffer))
(with-current-buffer buffer
(ai-code-session-update-metadata buffer metadata))))

(defun ai-code-backends-infra-ghostel--command-start (buffer)
"Record a Ghostel OSC 133 command-start event for BUFFER."
(ai-code-backends-infra-ghostel--update-session-metadata
buffer
(list :ghostel-command-state 'started
:ghostel-command-started-at (float-time)
:ghostel-command-finished-at nil
:ghostel-command-exit-status nil
:ghostel-status 'running)))

(defun ai-code-backends-infra-ghostel--command-finish (buffer exit-status)
"Record a Ghostel OSC 133 command-finish event for BUFFER.
EXIT-STATUS is the status reported by Ghostel, or nil when unavailable."
(ai-code-backends-infra-ghostel--update-session-metadata
buffer
(list :ghostel-command-state 'finished
:ghostel-command-finished-at (float-time)
:ghostel-command-exit-status exit-status
:ghostel-status (if (and exit-status (/= exit-status 0))
'error
'idle))))

(defun ai-code-backends-infra-ghostel--progress-status (state)
"Return an AI Code status symbol for Ghostel progress STATE."
(pcase state
('remove 'idle)
('error 'error)
('pause 'paused)
((or 'set 'indeterminate) 'running)
(_ 'running)))

(defun ai-code-backends-infra-ghostel--progress (state progress)
"Record a Ghostel OSC 9;4 progress report and delegate to Ghostel.
STATE and PROGRESS use the signature of `ghostel-progress-function'."
(when (ai-code-backends-infra-ghostel--ai-session-buffer-p)
(ai-code-backends-infra-ghostel--update-session-metadata
(current-buffer)
(list :ghostel-progress-state state
:ghostel-progress-value progress
:ghostel-progress-updated-at (float-time)
:ghostel-status
(ai-code-backends-infra-ghostel--progress-status state))))
(when (and ai-code-backends-infra-ghostel--progress-function
(not (eq ai-code-backends-infra-ghostel--progress-function
#'ai-code-backends-infra-ghostel--progress)))
(funcall ai-code-backends-infra-ghostel--progress-function state progress)))

(defun ai-code-backends-infra-ghostel--install-lifecycle-hooks ()
"Install Ghostel lifecycle hooks for the current AI Code session."
(when (boundp 'ghostel-command-start-functions)
(add-hook 'ghostel-command-start-functions
#'ai-code-backends-infra-ghostel--command-start nil t))
(when (boundp 'ghostel-command-finish-functions)
(add-hook 'ghostel-command-finish-functions
#'ai-code-backends-infra-ghostel--command-finish nil t))
(when (boundp 'ghostel-progress-function)
(unless (eq ghostel-progress-function
#'ai-code-backends-infra-ghostel--progress)
(setq-local ai-code-backends-infra-ghostel--progress-function
ghostel-progress-function))
(setq-local ghostel-progress-function
#'ai-code-backends-infra-ghostel--progress)))

(defun ai-code-backends-infra-ghostel-send-string (string &optional paste)
"Send STRING to the current Ghostel process.
If PASTE is non-nil, send it as a pasted string."
Expand All @@ -75,13 +158,15 @@ If PASTE is non-nil, send it as a pasted string."
(ghostel-send-key "backspace"))

(defun ai-code-backends-infra-ghostel-resize-handler ()
"Return the Ghostel resize handler."
#'ghostel--window-adjust-process-window-size)
"Return the Ghostel resize handler.
Ghostel owns terminal-model resizing through its mode-local window hooks."
nil)

(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-ghostel--install-lifecycle-hooks)
(ai-code-backends-infra--configure-session-input-shortcuts)
(ai-code-backends-infra--install-navigation-cursor-sync))

Expand Down
7 changes: 3 additions & 4 deletions ai-code-backends-infra.el
Original file line number Diff line number Diff line change
Expand Up @@ -663,10 +663,9 @@ from the window where it was initially created."
(windows (or (get-buffer-window-list buffer nil t)
(list 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)))
(set-process-window-size proc
(window-body-height window)
(window-body-width window))
(pcase backend
('vterm
(let ((result
Expand Down
111 changes: 111 additions & 0 deletions test/test_ai-code-backends-infra-ghostel.el
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
;;; test_ai-code-backends-infra-ghostel.el --- Ghostel lifecycle tests -*- lexical-binding: t; -*-

;; Author: Kang Tu <tninja@gmail.com>
;; SPDX-License-Identifier: Apache-2.0

;;; Commentary:
;; Tests for Ghostel-specific lifecycle integration.

;;; Code:

(require 'ert)
(require 'cl-lib)
(require 'ai-code-backends-infra-ghostel)

(defvar ghostel-command-finish-functions)
(defvar ghostel-command-start-functions)
(defvar ghostel-progress-function)
(defvar ghostel-set-title-function)
(defvar ghostel-kill-buffer-on-exit)

(ert-deftest test-ai-code-backends-infra-ghostel-configures-lifecycle-hooks ()
"Ghostel AI session configuration should install lifecycle hooks locally."
(with-temp-buffer
(setq-local ai-code-backends-infra--session-terminal-backend 'ghostel)
(setq-local ghostel-command-start-functions nil)
(setq-local ghostel-command-finish-functions nil)
(setq-local ghostel-progress-function #'ignore)
(cl-letf (((symbol-function 'ai-code-backends-infra--configure-session-input-shortcuts)
(lambda () nil))
((symbol-function 'ai-code-backends-infra--install-navigation-cursor-sync)
(lambda () nil)))
(ai-code-backends-infra--configure-ghostel-buffer))
(should (memq #'ai-code-backends-infra-ghostel--command-start
ghostel-command-start-functions))
(should (memq #'ai-code-backends-infra-ghostel--command-finish
ghostel-command-finish-functions))
(should (eq ghostel-progress-function
#'ai-code-backends-infra-ghostel--progress))
(should (eq ai-code-backends-infra-ghostel--progress-function
#'ignore))))

(ert-deftest test-ai-code-backends-infra-ghostel-command-lifecycle-updates-session-metadata ()
"OSC 133 command hooks should write structured session metadata."
(let ((buffer (generate-new-buffer "*ai-code-ghostel-lifecycle*"))
(updates nil))
(unwind-protect
(cl-letf (((symbol-function 'ai-code-session-update-metadata)
(lambda (target metadata)
(push (list target metadata) updates))))
(with-current-buffer buffer
(setq-local ai-code-backends-infra--session-terminal-backend 'ghostel))
(ai-code-backends-infra-ghostel--command-start buffer)
(ai-code-backends-infra-ghostel--command-finish buffer 2)
(let ((start (cadr (nth 1 updates)))
(finish (cadr (nth 0 updates))))
(should (eq (plist-get start :ghostel-command-state) 'started))
(should (eq (plist-get start :ghostel-status) 'running))
(should (null (plist-get start :ghostel-command-exit-status)))
(should (eq (plist-get finish :ghostel-command-state) 'finished))
(should (= (plist-get finish :ghostel-command-exit-status) 2))
(should (eq (plist-get finish :ghostel-status) 'error))))
(when (buffer-live-p buffer)
(kill-buffer buffer)))))

(ert-deftest test-ai-code-backends-infra-ghostel-command-hooks-ignore-non-ai-buffers ()
"Global Ghostel command hooks should not touch unrelated Ghostel buffers."
(let ((buffer (generate-new-buffer "*plain-ghostel*"))
(updated nil))
(unwind-protect
(cl-letf (((symbol-function 'ai-code-session-update-metadata)
(lambda (&rest _args)
(setq updated t))))
(ai-code-backends-infra-ghostel--command-start buffer)
(ai-code-backends-infra-ghostel--command-finish buffer 0)
(should-not updated))
(when (buffer-live-p buffer)
(kill-buffer buffer)))))

(ert-deftest test-ai-code-backends-infra-ghostel-progress-updates-session-and-delegates ()
"OSC 9;4 progress should update metadata and preserve Ghostel's handler."
(let ((updates nil)
(delegated nil))
(cl-letf (((symbol-function 'ai-code-session-update-metadata)
(lambda (target metadata)
(push (list target metadata) updates))))
(with-temp-buffer
(setq-local ai-code-backends-infra--session-terminal-backend 'ghostel)
(setq-local ai-code-backends-infra-ghostel--progress-function
(lambda (state progress)
(setq delegated (list state progress))))
(ai-code-backends-infra-ghostel--progress 'set 42)
(let ((metadata (cadar updates)))
(should (eq (plist-get metadata :ghostel-progress-state) 'set))
(should (= (plist-get metadata :ghostel-progress-value) 42))
(should (eq (plist-get metadata :ghostel-status) 'running)))
(should (equal delegated '(set 42)))))))

(ert-deftest test-ai-code-backends-infra-ghostel-progress-remove-marks-idle ()
"OSC 9;4 remove progress should mark the Ghostel status as idle."
(let ((updates nil))
(cl-letf (((symbol-function 'ai-code-session-update-metadata)
(lambda (_target metadata)
(push metadata updates))))
(with-temp-buffer
(setq-local ai-code-backends-infra--session-terminal-backend 'ghostel)
(ai-code-backends-infra-ghostel--progress 'remove nil)
(should (eq (plist-get (car updates) :ghostel-progress-state) 'remove))
(should (eq (plist-get (car updates) :ghostel-status) 'idle))))))

(provide 'test_ai-code-backends-infra-ghostel)
;;; test_ai-code-backends-infra-ghostel.el ends here
25 changes: 10 additions & 15 deletions test/test_ai-code-backends-infra.el
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,6 @@
(require 'ai-code-backends-infra)
(require 'ai-code-notifications)

(declare-function ghostel--window-adjust-process-window-size "ghostel" (process windows))

(defvar vterm-copy-mode-hook)
(defvar eat-term-name)
(defvar ghostel-set-title-function)
Expand Down Expand Up @@ -478,18 +476,17 @@ The result is a cons of whether SYMBOL is bound and its default value."
(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))
(ert-deftest test-ai-code-backends-infra-sync-terminal-dimensions-ghostel-uses-generic-pty-resize ()
"Ghostel dimension sync should not call removed Ghostel private handlers."
(let ((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 'window-body-height)
(lambda (_window) 24))
((symbol-function 'window-body-width)
(lambda (_window) 90))
((symbol-function 'set-process-window-size)
(lambda (process height width)
(push (list process height width) set-size-calls))))
Expand All @@ -498,7 +495,6 @@ The result is a cons of whether SYMBOL is bound and its default value."
(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 ()
Expand Down Expand Up @@ -991,11 +987,10 @@ The result is a cons of whether SYMBOL is bound and its default value."
(lambda () t)))
(should (ai-code-backends-infra--terminal-navigation-mode-p)))))

(ert-deftest test-ai-code-backends-infra-terminal-resize-handler-supports-ghostel ()
"Ghostel backend should expose its resize handler."
(ert-deftest test-ai-code-backends-infra-terminal-resize-handler-skips-ghostel ()
"Ghostel backend should not expose removed private resize handlers."
(let ((ai-code-backends-infra-terminal-backend 'ghostel))
(should (eq (ai-code-backends-infra--terminal-resize-handler)
#'ghostel--window-adjust-process-window-size))))
(should-not (ai-code-backends-infra--terminal-resize-handler))))

(ert-deftest test-ai-code-backends-infra-terminal-resize-handler-delegates-to-eat-module ()
"Resize handler lookup should delegate eat specifics to the eat module."
Expand Down
Loading