Skip to content
Closed
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
19 changes: 19 additions & 0 deletions README.org
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
11 changes: 11 additions & 0 deletions ai-code-backends-infra.el
Original file line number Diff line number Diff line change
Expand Up @@ -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" ())
Expand All @@ -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)
Expand Down Expand Up @@ -542,6 +549,10 @@ ESCAPE-FN is bound to `C-<escape>' when non-nil.
MULTILINE-INPUT-SEQUENCE configures `S-<return>' and `C-<return>' 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
Expand Down
250 changes: 250 additions & 0 deletions ai-code-terminal-completion.el
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
;;; 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-post-command "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--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--tracked-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--tracked-word))
;; Vterm is read-only. Company needs this binding for
;; its eligibility check, but never owns an insertion.
(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."
(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--tracked-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 "<tab>") #'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 "<escape>") #'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
47 changes: 37 additions & 10 deletions test/test_ai-code-prompt-completion.el
Original file line number Diff line number Diff line change
Expand Up @@ -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'."
Expand Down Expand Up @@ -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."
Expand Down
Loading
Loading