From 7984d8b0a4a4bc45aa3de1a910b836fd86a4cfb8 Mon Sep 17 00:00:00 2001 From: Kang Tu Date: Fri, 25 Sep 2026 20:21:17 -0700 Subject: [PATCH 1/3] feat: show Company prompt suggestions in vterm AI sessions --- README.org | 19 ++ ai-code-backends-infra.el | 11 + ai-code-prompt-completion.el | 2 +- ai-code-terminal-completion.el | 253 +++++++++++++++++++++++ test/test_ai-code-terminal-completion.el | 140 +++++++++++++ 5 files changed, 424 insertions(+), 1 deletion(-) create mode 100644 ai-code-terminal-completion.el create mode 100644 test/test_ai-code-terminal-completion.el diff --git a/README.org b/README.org index 49e98e26..f0f494c4 100644 --- a/README.org +++ b/README.org @@ -223,6 +223,25 @@ appends the destination PNG path when invoking it: *** Reusing Prompts You Already Wrote +To show Company suggestions while typing directly into an AI Code =vterm= +session (including Claude Code and Codex), install =company= and set: + +#+begin_src emacs-lisp +(setq ai-code-backends-infra-enable-terminal-completion t) +#+end_src + +This works across native CLI agents using the same terminal infrastructure. +For an existing session, run =M-x ai-code-terminal-completion-mode=. After +three letters, Company offers single-line prompt history and, if Cape is +installed, dictionary words. =TAB= accepts the selected candidate without +submitting it; =C-n= and =C-p= choose a candidate, =ESC= dismisses, and =RET= +still submits to the CLI. The feature is off by default and currently +requires =vterm=. It tracks ordinary typing and backspace at the terminal +cursor. Cursor moves, pasted text, CLI history and native CLI completions +discard the tracked suffix; keep typing to start a new completion. It cannot +reconstruct input edited by the CLI itself. Multi-line history entries are +excluded from terminal suggestions. + Most of what you retype into a prompt is wording, not structure: "explain the current code", the handoff instructions you send at every backend switch. This completes those from the prompt history files AI Code already keeps, across every repository it knows about -- projectile's known projects, `ai-code-prompt-completion-roots`, the current directory and the fallback location, with remote (Tramp) roots skipped so completion never opens a connection. Point `ai-code-prompt-completion-files` (nil by default) at files of your own -- a prompt library you keep by hand, a diary, any Org file whose wording you reuse. Setting `ai-code-prompt-completion-use-org-roam` adds the notes org-roam tracks the same way -- off by default, since a whole zettelkasten contributes note sections rather than prompts. In both, every headline offers two prompts however deep it sits, its own text and the text written under it, and whatever stands before the first headline -- the whole of a file written without any -- is split on blank lines. Datetree headlines, tags, statistics cookies and link wrappers are markup rather than wording and drop out, as does a heading of a word or two, quicker typed than picked from a popup. Neither history filter below applies there: you wrote those files, so they are offered as written. A large diary is worth measuring before you point at it -- 10MB of Org indexes in about a second and contributes on the order of 10^5 candidates. It needs no configuration: opening a prompt file installs the capf, so `completion-at-point` (=M-TAB=) offers your earlier prompts right away, and if =cape= is installed its `cape-dict` words join the same candidate list. Only the popup front-end is left to you: diff --git a/ai-code-backends-infra.el b/ai-code-backends-infra.el index bcb7c9fc..db620eb0 100644 --- a/ai-code-backends-infra.el +++ b/ai-code-backends-infra.el @@ -26,6 +26,7 @@ (require 'ai-code-backends-infra-vterm) (require 'ai-code-backends-infra-eat) (require 'ai-code-backends-infra-ghostel) +(require 'ai-code-terminal-completion) (declare-function ai-code--session-handle-at-input "ai-code-input" ()) (declare-function ai-code--session-project-root "ai-code-utils" ()) @@ -46,6 +47,12 @@ Can be either `vterm', `eat', or `ghostel'." (const :tag "ghostel" ghostel)) :group 'ai-code-backends-infra) +(defcustom ai-code-backends-infra-enable-terminal-completion nil + "Enable automatic Company completion in new vterm AI sessions. +The mode can also be toggled per session with +`ai-code-terminal-completion-mode'." + :type 'boolean :group 'ai-code-backends-infra) + (defcustom ai-code-backends-infra-window-side 'right "Side of the frame where the window should appear." :type '(choice (const :tag "Left" left) @@ -542,6 +549,10 @@ ESCAPE-FN is bound to `C-' when non-nil. MULTILINE-INPUT-SEQUENCE configures `S-' and `C-' when non-nil." (with-current-buffer buffer (ai-code-backends-infra--ensure-buffer-local-keymap) + (when (and ai-code-backends-infra-enable-terminal-completion + (eq ai-code-backends-infra--session-terminal-backend 'vterm) + (not ai-code-terminal-completion-mode)) + (ai-code-terminal-completion-mode 1)) (setq-local ai-code-editor-viewport--submit-function #'ai-code-backends-infra--terminal-send-return) (when escape-fn diff --git a/ai-code-prompt-completion.el b/ai-code-prompt-completion.el index 6ab26fdc..f7fb0a04 100644 --- a/ai-code-prompt-completion.el +++ b/ai-code-prompt-completion.el @@ -504,7 +504,7 @@ ai-code-prompt-completion.el for `cape' and `company' setups." Falls back to `ai-code-prompt-completion-capf' alone when cape is not installed, so the prompt candidates are there either way." (if (fboundp 'cape-wrap-super) - (cape-wrap-super #'ai-code-prompt-completion-capf #'cape-dict) + (funcall (cape-wrap-super #'ai-code-prompt-completion-capf #'cape-dict)) (ai-code-prompt-completion-capf))) (defun ai-code-prompt-completion--enable () diff --git a/ai-code-terminal-completion.el b/ai-code-terminal-completion.el new file mode 100644 index 00000000..e7c5283f --- /dev/null +++ b/ai-code-terminal-completion.el @@ -0,0 +1,253 @@ +;;; ai-code-terminal-completion.el --- Company in AI Code terminals -*- lexical-binding: t; -*- + +;; SPDX-License-Identifier: Apache-2.0 + +;;; Commentary: +;; An opt-in, agent-independent completion interface for vterm sessions. +;; The CLI owns its input; Emacs tracks only a contiguous suffix typed at the +;; terminal cursor. Company displays candidates over the read-only terminal, +;; while accepting a candidate sends keystrokes through the terminal adapter. + +;;; Code: + +(require 'cl-lib) +(require 'subr-x) +(require 'ai-code-prompt-completion) + +(declare-function ai-code-backends-infra--terminal-send-string + "ai-code-backends-infra" (string &optional paste)) +(declare-function ai-code-backends-infra--terminal-send-backspace + "ai-code-backends-infra" ()) +(declare-function ai-code-backends-infra--terminal-send-return + "ai-code-backends-infra" ()) +(declare-function company-mode "company" (&optional arg)) +(declare-function company-auto-begin "company" ()) +(declare-function company-abort "company" ()) +(declare-function company-select-next "company" ()) +(declare-function company-select-previous "company" ()) +(declare-function cape-dict "cape" (&optional interactive)) +(defvar company-active-map) +(defvar company-backends) +(defvar company-backend) +(defvar company-candidates) +(defvar company-idle-delay) +(defvar company-minimum-prefix-length) +(defvar company-frontends) +(defvar company-selection) +(defvar company-mode) +(defvar company-insertion-on-trigger) +(defvar ai-code-backends-infra--session-terminal-backend) +(defvar vterm-copy-mode) +(defvar ai-code-terminal-completion-mode) + +(defgroup ai-code-terminal-completion nil + "Completion inside AI Code terminal sessions." + :group 'ai-code-backends-infra) + +(defcustom ai-code-terminal-completion-delay 0.2 + "Idle seconds before showing Company candidates in a terminal." + :type 'number :group 'ai-code-terminal-completion) + +(defvar-local ai-code-terminal-completion--input "") +(defvar-local ai-code-terminal-completion--timer nil) + +(defun ai-code-terminal-completion--cancel-timer () + "Cancel this session's pending completion timer." + (when (timerp ai-code-terminal-completion--timer) + (cancel-timer ai-code-terminal-completion--timer)) + (setq ai-code-terminal-completion--timer nil)) + +(defun ai-code-terminal-completion--reset () + "Forget input after an operation whose effect is not known." + (ai-code-terminal-completion--cancel-timer) + (setq ai-code-terminal-completion--input "") + (when (and (featurep 'company) company-candidates) + (company-abort))) + +(defun ai-code-terminal-completion--tracked-word () + "Return the last tracked word outside native agent input tokens." + (when (and ai-code-terminal-completion-mode + (not (bound-and-true-p vterm-copy-mode)) + ;; Leave native slash commands and @file menus to the CLI. + (not (string-match-p + "\\(?:\\`\\|[[:space:]]\\)[/@][^[:space:]]*\\'" + ai-code-terminal-completion--input)) + (string-match "\\([[:alpha:]]+\\)\\'" + ai-code-terminal-completion--input)) + (match-string 1 ai-code-terminal-completion--input))) + +(defun ai-code-terminal-completion--word () + "Return the tracked word only if it is visible at the terminal cursor." + (when-let* ((word (ai-code-terminal-completion--tracked-word))) + (when (and (>= (point) (+ (point-min) (length word))) + (string= word (buffer-substring-no-properties + (- (point) (length word)) (point)))) + word))) + +(defun ai-code-terminal-completion--dict (prefix) + "Collect English dictionary candidates matching PREFIX when Cape exists." + (when (fboundp 'cape-dict) + (with-temp-buffer + (insert prefix) + (when-let* ((capf (cape-dict)) + (table (nth 2 capf))) + (all-completions prefix table (plist-get (nthcdr 3 capf) :predicate)))))) + +(defun ai-code-terminal-completion--company (command &optional arg &rest _ignored) + "Company backend for tracked terminal text; COMMAND and ARG are its API." + (pcase command + ('prefix (ai-code-terminal-completion--word)) + ('candidates + (let* ((index (ai-code-prompt-completion--ensure-index)) + (prompts (cl-remove-if-not + (lambda (candidate) + (and (not (string-match-p "\n" candidate)) + (string-prefix-p arg candidate t))) + (car index)))) + (delete-dups + (append prompts (ai-code-terminal-completion--dict arg))))) + ('annotation + (when (gethash arg (nth 1 (ai-code-prompt-completion--ensure-index))) + " prompt")) + ('sorted t) + ('no-cache t))) + +(defun ai-code-terminal-completion--schedule () + "Schedule a popup for the current terminal window." + (ai-code-terminal-completion--cancel-timer) + (when (and (featurep 'company) company-candidates) + (company-abort)) + (when-let* ((word (ai-code-terminal-completion--tracked-word))) + (when (>= (length word) 3) + (let ((buffer (current-buffer)) + (window (selected-window)) + (input ai-code-terminal-completion--input)) + (setq ai-code-terminal-completion--timer + (run-with-timer + ai-code-terminal-completion-delay nil + (lambda () + (when (and (buffer-live-p buffer) (window-live-p window) + (eq (window-buffer window) buffer) + (eq (selected-window) window)) + (with-current-buffer buffer + (when (and ai-code-terminal-completion-mode + (equal ai-code-terminal-completion--input input) + (ai-code-terminal-completion--word)) + ;; Vterm is read-only. Company needs this binding for + ;; its eligibility check, but never owns an insertion. + (let ((buffer-read-only nil)) + (company-auto-begin)))))))))))) + +(defun ai-code-terminal-completion--post-command () + "Track only plain keys that the terminal itself processed." + (when ai-code-terminal-completion-mode + (let* ((keys (this-command-keys-vector)) + (key (and (= (length keys) 1) (aref keys 0)))) + (cond + ((and (memq this-command '(vterm--self-insert vterm-send-space)) + (characterp key) (<= 32 key) (<= key 126)) + (setq ai-code-terminal-completion--input + (concat ai-code-terminal-completion--input (char-to-string key))) + (ai-code-terminal-completion--schedule)) + ((eq this-command 'vterm-send-backspace) + (if (string-empty-p ai-code-terminal-completion--input) + (ai-code-terminal-completion--reset) + (setq ai-code-terminal-completion--input + (substring ai-code-terminal-completion--input 0 -1)) + (ai-code-terminal-completion--schedule))) + ((memq this-command '(company-select-next company-select-previous + company-abort ai-code-terminal-completion-accept)) + nil) + (t (ai-code-terminal-completion--reset)))))) + +(defun ai-code-terminal-completion-accept () + "Insert the selected Company candidate into the CLI without submitting." + (interactive) + (let* ((word (ai-code-terminal-completion--word)) + (candidate (and word company-candidates + (nth (or company-selection 0) company-candidates))) + (full (and candidate + (gethash (substring-no-properties candidate) + (nth 1 (ai-code-prompt-completion--ensure-index))))) + (input ai-code-terminal-completion--input) + (replace-all (and full + (string-prefix-p input full t) + (>= (point) (+ (point-min) (length input))) + (string= input (buffer-substring-no-properties + (- (point) (length input)) (point))))) + (replacement (or (and replace-all full) candidate)) + (count (if replace-all (length input) (length (or word ""))))) + (if (and word candidate + (not (string-match-p "\n" replacement))) + (progn + (company-abort) + (ai-code-terminal-completion--cancel-timer) + (dotimes (_ count) + (ai-code-backends-infra--terminal-send-backspace)) + (ai-code-backends-infra--terminal-send-string replacement t) + ;; The CLI may transform a paste; start fresh on the next key. + (setq ai-code-terminal-completion--input "")) + (ai-code-terminal-completion--reset)))) + +;; Company must leave the selection visible until this command reads it. +(put 'ai-code-terminal-completion-accept 'company-keep t) + +(defun ai-code-terminal-completion--return () + "Dismiss completion and forward RET to the CLI." + (interactive) + (ai-code-terminal-completion--reset) + (ai-code-backends-infra--terminal-send-return)) + +(defun ai-code-terminal-completion--finish (original candidate) + "Protect the read-only terminal from Company insertion. +ORIGINAL is `company-finish'; CANDIDATE is forwarded to the CLI instead." + (if (and ai-code-terminal-completion-mode + (eq company-backend #'ai-code-terminal-completion--company)) + (let ((company-candidates (list candidate)) + (company-selection 0)) + (ai-code-terminal-completion-accept)) + (funcall original candidate))) + +(defvar ai-code-terminal-completion--advice-installed nil) + +;;;###autoload +(define-minor-mode ai-code-terminal-completion-mode + "Automatically show Company completion while typing in a vterm session. +This opt-in mode supports any AI Code agent using vterm. It follows only +plain printable keys and backspace. Cursor movement, CLI history, paste, +and other edits drop the tracked input until typing starts again." + :lighter " TermComp" + (if ai-code-terminal-completion-mode + (progn + (unless (eq ai-code-backends-infra--session-terminal-backend 'vterm) + (setq ai-code-terminal-completion-mode nil) + (user-error "Terminal completion currently requires vterm")) + (unless (require 'company nil t) + (setq ai-code-terminal-completion-mode nil) + (user-error "Install company to enable terminal completion")) + (require 'cape nil t) + (unless ai-code-terminal-completion--advice-installed + (advice-add 'company-finish :around #'ai-code-terminal-completion--finish) + (setq ai-code-terminal-completion--advice-installed t)) + (setq-local company-backends '(ai-code-terminal-completion--company) + company-idle-delay nil + company-minimum-prefix-length 3 + company-insertion-on-trigger nil + company-frontends '(company-pseudo-tooltip-frontend + company-echo-metadata-frontend)) + (let ((map (make-sparse-keymap))) + (define-key map (kbd "TAB") #'ai-code-terminal-completion-accept) + (define-key map (kbd "") #'ai-code-terminal-completion-accept) + (define-key map (kbd "C-n") #'company-select-next) + (define-key map (kbd "C-p") #'company-select-previous) + (define-key map (kbd "") #'company-abort) + (define-key map (kbd "RET") #'ai-code-terminal-completion--return) + (setq-local company-active-map map)) + (add-hook 'post-command-hook #'ai-code-terminal-completion--post-command nil t) + (company-mode 1)) + (remove-hook 'post-command-hook #'ai-code-terminal-completion--post-command t) + (ai-code-terminal-completion--reset) + (when (bound-and-true-p company-mode) (company-mode -1)))) + +(provide 'ai-code-terminal-completion) +;;; ai-code-terminal-completion.el ends here diff --git a/test/test_ai-code-terminal-completion.el b/test/test_ai-code-terminal-completion.el new file mode 100644 index 00000000..8772439b --- /dev/null +++ b/test/test_ai-code-terminal-completion.el @@ -0,0 +1,140 @@ +;;; test_ai-code-terminal-completion.el --- Terminal completion tests -*- lexical-binding: t; -*- + +;; SPDX-License-Identifier: Apache-2.0 + +;;; Commentary: +;; Check that terminal completion sends input through the terminal adapter, +;; leaves the read-only terminal untouched, and refuses stale input. + +;;; Code: + +(require 'ert) +(require 'cl-lib) +(require 'ai-code-terminal-completion) + +(defvar company-candidates) +(defvar company-selection) + +(ert-deftest ai-code-terminal-completion-test-accepts-prompt-without-submitting () + "A matching prefix expands by terminal I/O without changing the buffer." + (with-temp-buffer + (insert "> explain the cur") + (goto-char (point-max)) + (let ((ai-code-terminal-completion-mode t) + (ai-code-terminal-completion--input "explain the cur") + (company-candidates '("current code")) + (company-selection 0) + (buffer-read-only t) + (sent nil) + (index (list '("current code") + (let ((table (make-hash-table :test #'equal))) + (puthash "current code" "explain the current code" + table) + table)))) + (cl-letf (((symbol-function 'ai-code-prompt-completion--ensure-index) + (lambda () index)) + ((symbol-function 'ai-code-backends-infra--terminal-send-backspace) + (lambda () (push 'backspace sent))) + ((symbol-function 'ai-code-backends-infra--terminal-send-string) + (lambda (string &optional paste) + (push (list string paste) sent))) + ((symbol-function 'company-abort) (lambda () nil))) + (ai-code-terminal-completion-accept)) + (should (equal (buffer-string) "> explain the cur")) + (should (equal (car sent) '("explain the current code" t))) + (should (= (length (cdr sent)) (length "explain the cur"))) + (should (string-empty-p ai-code-terminal-completion--input))))) + +(ert-deftest ai-code-terminal-completion-test-stale-terminal-text-is-not-replaced () + "If the CLI redraws a different input, do not send any backspaces." + (with-temp-buffer + (insert "> changed by CLI") + (goto-char (point-max)) + (let ((ai-code-terminal-completion-mode t) + (ai-code-terminal-completion--input "explain the cur") + (company-candidates '("current code")) + (sent nil)) + (cl-letf (((symbol-function 'ai-code-backends-infra--terminal-send-backspace) + (lambda () (setq sent t))) + ((symbol-function 'company-abort) (lambda () nil))) + (ai-code-terminal-completion-accept)) + (should-not sent)))) + +(ert-deftest ai-code-terminal-completion-test-unknown-edit-discards-tracking () + "History and cursor operations never leave a stale tracked prefix." + (with-temp-buffer + (let ((ai-code-terminal-completion-mode t) + (ai-code-terminal-completion--input "explain the cur") + (this-command 'vterm-send-up) + (company-candidates nil)) + (cl-letf (((symbol-function 'this-command-keys-vector) + (lambda () [up]))) + (ai-code-terminal-completion--post-command)) + (should (string-empty-p ai-code-terminal-completion--input))))) + +(ert-deftest ai-code-terminal-completion-test-leaves-agent-native-menus-alone () + "Do not offer Company candidates inside @file or /command tokens." + (dolist (input '("@src/file" "/help" "open @project")) + (with-temp-buffer + (insert input) + (let ((ai-code-terminal-completion-mode t) + (ai-code-terminal-completion--input input)) + (should-not (ai-code-terminal-completion--word)))))) + +(ert-deftest ai-code-terminal-completion-test-schedules-before-cli-redraw () + "Typing can schedule completion before the terminal echoes the key." + (with-temp-buffer + (insert "> explai") + (goto-char (point-max)) + (let ((ai-code-terminal-completion-mode t) + (ai-code-terminal-completion--input "explain")) + (unwind-protect + (progn + (should-not (ai-code-terminal-completion--word)) + (ai-code-terminal-completion--schedule) + (should (timerp ai-code-terminal-completion--timer))) + (ai-code-terminal-completion--cancel-timer))))) + +(ert-deftest ai-code-terminal-completion-test-no-multiline-candidates () + "Do not feed multi-line history entries into terminal input." + (with-temp-buffer + (insert "explain") + (let ((ai-code-terminal-completion-mode t) + (ai-code-terminal-completion--input "explain")) + (cl-letf (((symbol-function 'ai-code-prompt-completion--ensure-index) + (lambda () (list '("explain the code" "explain\nmore") nil))) + ((symbol-function 'ai-code-terminal-completion--dict) + (lambda (_) nil))) + (should (equal (ai-code-terminal-completion--company 'candidates "explain") + '("explain the code"))))))) + +(ert-deftest ai-code-terminal-completion-test-company-opens-over-read-only-terminal () + "Company can show candidates while the terminal remains read-only." + (skip-unless (require 'company nil t)) + (let ((buffer (generate-new-buffer " *terminal completion test*"))) + (unwind-protect + (save-window-excursion + (switch-to-buffer buffer) + (insert "> explain") + (goto-char (point-max)) + (setq-local ai-code-backends-infra--session-terminal-backend 'vterm) + (ai-code-terminal-completion-mode 1) + (setq ai-code-terminal-completion--input "explain" + buffer-read-only t) + (cl-letf (((symbol-function 'ai-code-prompt-completion--ensure-index) + (lambda () (list '("explain the current code") + (make-hash-table :test #'equal)))) + ((symbol-function 'ai-code-terminal-completion--dict) + (lambda (_) nil))) + (let ((buffer-read-only nil)) + (should (company-auto-begin))) + (should (equal company-candidates '("explain the current code"))) + (should (equal (buffer-string) "> explain")))) + (when (buffer-live-p buffer) + (with-current-buffer buffer + (setq buffer-read-only nil) + (ai-code-terminal-completion-mode -1)) + (kill-buffer buffer))))) + +(provide 'test_ai-code-terminal-completion) +;;; test_ai-code-terminal-completion.el ends here From 5bdbaba35f299c05165be36060a691663e91b607 Mon Sep 17 00:00:00 2001 From: Kang Tu Date: Sat, 26 Sep 2026 09:40:54 -0700 Subject: [PATCH 2/3] Fix prompt completion result handling with Cape --- ai-code-prompt-completion.el | 2 +- test/test_ai-code-prompt-completion.el | 47 ++++++++++++++++++++------ 2 files changed, 38 insertions(+), 11 deletions(-) diff --git a/ai-code-prompt-completion.el b/ai-code-prompt-completion.el index f7fb0a04..6ab26fdc 100644 --- a/ai-code-prompt-completion.el +++ b/ai-code-prompt-completion.el @@ -504,7 +504,7 @@ ai-code-prompt-completion.el for `cape' and `company' setups." Falls back to `ai-code-prompt-completion-capf' alone when cape is not installed, so the prompt candidates are there either way." (if (fboundp 'cape-wrap-super) - (funcall (cape-wrap-super #'ai-code-prompt-completion-capf #'cape-dict)) + (cape-wrap-super #'ai-code-prompt-completion-capf #'cape-dict) (ai-code-prompt-completion-capf))) (defun ai-code-prompt-completion--enable () diff --git a/test/test_ai-code-prompt-completion.el b/test/test_ai-code-prompt-completion.el index 52d5f9f2..619e1741 100644 --- a/test/test_ai-code-prompt-completion.el +++ b/test/test_ai-code-prompt-completion.el @@ -16,6 +16,8 @@ ;; Declared so the tests can bind it whether or not projectile is loaded. (defvar projectile-known-projects nil) +(defvar cape-dict-file) + (defun ai-code-prompt-completion-test--make-root (content &optional nested) "Create a temp project root holding a prompt file with CONTENT. When NESTED is non-nil the file goes under `ai-code-files-dir-name'." @@ -665,16 +667,41 @@ walk through the release checklist (ert-deftest ai-code-prompt-completion-test-dict-capf-works-without-cape () "Without cape loaded the merged capf still offers prompt candidates." - (should-not (fboundp 'cape-wrap-super)) - (ai-code-prompt-completion-test--with-roots - (list (ai-code-prompt-completion-test--make-root - ai-code-prompt-completion-test--corpus)) - (with-temp-buffer - (insert "Go") - (let ((result (ai-code-prompt-completion-dict-capf))) - (should result) - (should (member "Go ahead with the suggested refactoring" - (nth 2 result))))))) + (cl-letf (((symbol-function 'cape-wrap-super) nil)) + (ai-code-prompt-completion-test--with-roots + (list (ai-code-prompt-completion-test--make-root + ai-code-prompt-completion-test--corpus)) + (with-temp-buffer + (insert "Go") + (let ((result (ai-code-prompt-completion-dict-capf))) + (should result) + (should (member "Go ahead with the suggested refactoring" + (nth 2 result)))))))) + +(ert-deftest ai-code-prompt-completion-test-dict-capf-with-cape () + "Merge real Cape dictionary candidates and expand a selected prompt." + (skip-unless (require 'cape nil t)) + (let ((cape-dict-file (make-temp-file "ai-code-dict" nil nil "Goody\n"))) + (unwind-protect + (ai-code-prompt-completion-test--with-roots + (list (ai-code-prompt-completion-test--make-root + ai-code-prompt-completion-test--corpus)) + (with-temp-buffer + (insert "Go") + (let* ((result (ai-code-prompt-completion-dict-capf)) + (candidates (all-completions "Go" (nth 2 result)))) + (should (equal (list (car result) (cadr result)) '(1 3))) + (should (member "Goody" candidates)) + (should (member "Go ahead with the suggested refactoring" + candidates))) + (erase-buffer) + (insert "Go ahead with the suggested refact") + (let ((completion-at-point-functions + '(ai-code-prompt-completion-dict-capf))) + (completion-at-point)) + (should (equal (buffer-string) + "Go ahead with the suggested refactoring")))) + (delete-file cape-dict-file)))) (ert-deftest ai-code-prompt-completion-test-setup-installs-capf () "Setup installs the capf buffer-locally and does not duplicate it." From 2eb640edd9b79e44f759401969829f409d38b263 Mon Sep 17 00:00:00 2001 From: Kang Tu Date: Sun, 27 Sep 2026 19:32:36 -0700 Subject: [PATCH 3/3] Use tracked-word for terminal completion --- ai-code-terminal-completion.el | 23 +++---- test/test_ai-code-terminal-completion.el | 80 ++++++++++++++++++++---- 2 files changed, 78 insertions(+), 25 deletions(-) diff --git a/ai-code-terminal-completion.el b/ai-code-terminal-completion.el index e7c5283f..4796987f 100644 --- a/ai-code-terminal-completion.el +++ b/ai-code-terminal-completion.el @@ -22,6 +22,7 @@ "ai-code-backends-infra" ()) (declare-function company-mode "company" (&optional arg)) (declare-function company-auto-begin "company" ()) +(declare-function company-post-command "company" ()) (declare-function company-abort "company" ()) (declare-function company-select-next "company" ()) (declare-function company-select-previous "company" ()) @@ -76,14 +77,6 @@ ai-code-terminal-completion--input)) (match-string 1 ai-code-terminal-completion--input))) -(defun ai-code-terminal-completion--word () - "Return the tracked word only if it is visible at the terminal cursor." - (when-let* ((word (ai-code-terminal-completion--tracked-word))) - (when (and (>= (point) (+ (point-min) (length word))) - (string= word (buffer-substring-no-properties - (- (point) (length word)) (point)))) - word))) - (defun ai-code-terminal-completion--dict (prefix) "Collect English dictionary candidates matching PREFIX when Cape exists." (when (fboundp 'cape-dict) @@ -96,7 +89,7 @@ (defun ai-code-terminal-completion--company (command &optional arg &rest _ignored) "Company backend for tracked terminal text; COMMAND and ARG are its API." (pcase command - ('prefix (ai-code-terminal-completion--word)) + ('prefix (ai-code-terminal-completion--tracked-word)) ('candidates (let* ((index (ai-code-prompt-completion--ensure-index)) (prompts (cl-remove-if-not @@ -132,11 +125,15 @@ (with-current-buffer buffer (when (and ai-code-terminal-completion-mode (equal ai-code-terminal-completion--input input) - (ai-code-terminal-completion--word)) + (ai-code-terminal-completion--tracked-word)) ;; Vterm is read-only. Company needs this binding for ;; its eligibility check, but never owns an insertion. - (let ((buffer-read-only nil)) - (company-auto-begin)))))))))))) + (when (let ((buffer-read-only nil)) + (company-auto-begin)) + ;; Match `company-idle-begin': render the tooltip + ;; and install its keymap after starting completion. + (let ((this-command 'company-idle-begin)) + (company-post-command))))))))))))) (defun ai-code-terminal-completion--post-command () "Track only plain keys that the terminal itself processed." @@ -163,7 +160,7 @@ (defun ai-code-terminal-completion-accept () "Insert the selected Company candidate into the CLI without submitting." (interactive) - (let* ((word (ai-code-terminal-completion--word)) + (let* ((word (ai-code-terminal-completion--tracked-word)) (candidate (and word company-candidates (nth (or company-selection 0) company-candidates))) (full (and candidate diff --git a/test/test_ai-code-terminal-completion.el b/test/test_ai-code-terminal-completion.el index 8772439b..f7f5fce9 100644 --- a/test/test_ai-code-terminal-completion.el +++ b/test/test_ai-code-terminal-completion.el @@ -14,6 +14,7 @@ (defvar company-candidates) (defvar company-selection) +(defvar company-pseudo-tooltip-overlay) (ert-deftest ai-code-terminal-completion-test-accepts-prompt-without-submitting () "A matching prefix expands by terminal I/O without changing the buffer." @@ -45,20 +46,53 @@ (should (= (length (cdr sent)) (length "explain the cur"))) (should (string-empty-p ai-code-terminal-completion--input))))) -(ert-deftest ai-code-terminal-completion-test-stale-terminal-text-is-not-replaced () - "If the CLI redraws a different input, do not send any backspaces." +(ert-deftest ai-code-terminal-completion-test-accept-trusts-tracked-input-despite-tui-redraw () + "A TUI redraw never echoes the suffix, yet Tab still expands it. +Gating acceptance on buffer text made Tab a no-op in vterm sessions; +resetting the tracked input on unknown commands is the staleness guard." (with-temp-buffer (insert "> changed by CLI") (goto-char (point-max)) (let ((ai-code-terminal-completion-mode t) (ai-code-terminal-completion--input "explain the cur") (company-candidates '("current code")) + (company-selection 0) + (sent nil) + (index (list nil (make-hash-table :test #'equal)))) + (cl-letf (((symbol-function 'ai-code-prompt-completion--ensure-index) + (lambda () index)) + ((symbol-function 'ai-code-backends-infra--terminal-send-backspace) + (lambda () (push 'backspace sent))) + ((symbol-function 'ai-code-backends-infra--terminal-send-string) + (lambda (string &optional paste) + (push (list string paste) sent))) + ((symbol-function 'company-abort) (lambda () nil))) + (ai-code-terminal-completion-accept)) + (should (equal (buffer-string) "> changed by CLI")) + (should (equal (car sent) '("current code" t))) + (should (= (length (cdr sent)) (length "cur"))) + (should (string-empty-p ai-code-terminal-completion--input))))) + +(ert-deftest ai-code-terminal-completion-test-accept-refuses-multiline-candidate () + "A multi-line candidate never reaches terminal input." + (with-temp-buffer + (insert "> explain") + (goto-char (point-max)) + (let ((ai-code-terminal-completion-mode t) + (ai-code-terminal-completion--input "explain") + (company-candidates '("explain\nmore")) + (company-selection 0) (sent nil)) - (cl-letf (((symbol-function 'ai-code-backends-infra--terminal-send-backspace) + (cl-letf (((symbol-function 'ai-code-prompt-completion--ensure-index) + (lambda () (list nil (make-hash-table :test #'equal)))) + ((symbol-function 'ai-code-backends-infra--terminal-send-backspace) (lambda () (setq sent t))) + ((symbol-function 'ai-code-backends-infra--terminal-send-string) + (lambda (&rest _) (setq sent t))) ((symbol-function 'company-abort) (lambda () nil))) (ai-code-terminal-completion-accept)) - (should-not sent)))) + (should-not sent) + (should (string-empty-p ai-code-terminal-completion--input))))) (ert-deftest ai-code-terminal-completion-test-unknown-edit-discards-tracking () "History and cursor operations never leave a stale tracked prefix." @@ -79,10 +113,10 @@ (insert input) (let ((ai-code-terminal-completion-mode t) (ai-code-terminal-completion--input input)) - (should-not (ai-code-terminal-completion--word)))))) + (should-not (ai-code-terminal-completion--tracked-word)))))) (ert-deftest ai-code-terminal-completion-test-schedules-before-cli-redraw () - "Typing can schedule completion before the terminal echoes the key." + "Typing schedules completion before the terminal echoes the key." (with-temp-buffer (insert "> explai") (goto-char (point-max)) @@ -90,11 +124,22 @@ (ai-code-terminal-completion--input "explain")) (unwind-protect (progn - (should-not (ai-code-terminal-completion--word)) + (should (equal (ai-code-terminal-completion--tracked-word) + "explain")) (ai-code-terminal-completion--schedule) (should (timerp ai-code-terminal-completion--timer))) (ai-code-terminal-completion--cancel-timer))))) +(ert-deftest ai-code-terminal-completion-test-prefix-follows-tracked-input-in-tui () + "A TUI redraw never echoes the suffix, yet the prefix still completes." + (with-temp-buffer + (insert "+-- ask --+\n| > expla |") + (goto-char (point-max)) + (let ((ai-code-terminal-completion-mode t) + (ai-code-terminal-completion--input "explain")) + (should (equal (ai-code-terminal-completion--company 'prefix) + "explain"))))) + (ert-deftest ai-code-terminal-completion-test-no-multiline-candidates () "Do not feed multi-line history entries into terminal input." (with-temp-buffer @@ -109,13 +154,13 @@ '("explain the code"))))))) (ert-deftest ai-code-terminal-completion-test-company-opens-over-read-only-terminal () - "Company can show candidates while the terminal remains read-only." + "The timer displays a tooltip and installs Tab in a read-only terminal." (skip-unless (require 'company nil t)) (let ((buffer (generate-new-buffer " *terminal completion test*"))) (unwind-protect (save-window-excursion (switch-to-buffer buffer) - (insert "> explain") + (insert "| > expla |") (goto-char (point-max)) (setq-local ai-code-backends-infra--session-terminal-backend 'vterm) (ai-code-terminal-completion-mode 1) @@ -126,10 +171,21 @@ (make-hash-table :test #'equal)))) ((symbol-function 'ai-code-terminal-completion--dict) (lambda (_) nil))) - (let ((buffer-read-only nil)) - (should (company-auto-begin))) + (ai-code-terminal-completion--schedule) + (let ((timer ai-code-terminal-completion--timer) + (this-command nil)) + (should (timerp timer)) + ;; Run the scheduled callback without depending on wall time. + (cancel-timer timer) + (apply (timer--function timer) (timer--args timer))) (should (equal company-candidates '("explain the current code"))) - (should (equal (buffer-string) "> explain")))) + (should (overlayp company-pseudo-tooltip-overlay)) + (should (eq (overlay-buffer company-pseudo-tooltip-overlay) buffer)) + (should (overlay-get company-pseudo-tooltip-overlay 'before-string)) + (should (eq (key-binding (kbd "TAB")) + #'ai-code-terminal-completion-accept)) + (should buffer-read-only) + (should (equal (buffer-string) "| > expla |")))) (when (buffer-live-p buffer) (with-current-buffer buffer (setq buffer-read-only nil)