Skip to content

Commit 1d38a20

Browse files
authored
feat: Dispatch built-in slash commands client-side (#143) (#148)
Built-in slash commands (/new, /model, /compact, /session, /name, /fork, /resume, /reload, /export, /copy, /quit) were not autocompleted or dispatched locally — they were sent verbatim as prompts, causing the LLM to respond about them instead of executing them. Dispatch table and autocomplete Data-driven table in ui.el maps command names to existing handler functions with an :args spec (nil, optional, required). Autocomplete in input.el merges builtin names with RPC-provided commands. Dispatch in render.el replaces the old /compact-only special case. Fix session state reads in menu functions Menu functions (model description, thinking description, select-model) read pi-coding-agent--state directly, but state is buffer-local in the chat buffer. When called from the input buffer, state was nil giving 'unknown'. New --menu-state accessor reads via buffer-local-value. Model selector improvements Shortened display names (Opus 4.6 not Claude Opus 4.6), matching the header-line. Case-insensitive flex completion so 'opus' matches 'Opus 4.6' and 'code' matches 'GPT-5.1 Codex Max'. Unique flex match auto-selects without opening the picker (/model op46 sets Opus 4.6 directly). No match prints a message; multiple matches open the picker pre-filled. Argument passing for /model and /export /model accepts optional search text as initial-input. /export accepts optional output path, sent as :outputPath in the RPC. Interactive C-c C-p e now prompts for export path (RET for default).
1 parent 17f7e6e commit 1d38a20

7 files changed

+404
-29
lines changed

pi-coding-agent-input.el

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -352,13 +352,15 @@ cancels, the session remains intact."
352352
(defun pi-coding-agent--command-capf ()
353353
"Completion-at-point function for /commands in input buffer.
354354
Returns completion data when point is after / at start of buffer.
355-
Uses commands from pi's `get_commands' RPC."
355+
Includes both built-in commands and commands from pi's `get_commands' RPC."
356356
(when (and (eq (char-after (point-min)) ?/)
357357
(> (point) (point-min)))
358358
(let* ((start (1+ (point-min)))
359359
(end (point))
360-
(commands (mapcar (lambda (cmd) (plist-get cmd :name))
361-
pi-coding-agent--commands)))
360+
(builtin-names (mapcar #'car pi-coding-agent--builtin-commands))
361+
(rpc-names (mapcar (lambda (cmd) (plist-get cmd :name))
362+
pi-coding-agent--commands))
363+
(commands (delete-dups (append builtin-names rpc-names))))
362364
(list start end commands :exclusive 'no))))
363365

364366
;;;; Editor Features: File Reference (@)

pi-coding-agent-menu.el

Lines changed: 59 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -71,14 +71,24 @@ PROC is the pi process. CALLBACK receives the command list on success."
7171

7272
;;;; Session Management
7373

74+
(defun pi-coding-agent--menu-state ()
75+
"Return session state from the chat buffer.
76+
State is buffer-local in the chat buffer; this accessor works
77+
from either chat or input buffer."
78+
(let ((chat-buf (pi-coding-agent--get-chat-buffer)))
79+
(and chat-buf (buffer-local-value 'pi-coding-agent--state chat-buf))))
80+
7481
(defun pi-coding-agent--menu-model-description ()
7582
"Return model description for transient menu."
76-
(let ((model (plist-get (plist-get pi-coding-agent--state :model) :name)))
77-
(format "Model: %s" (or model "unknown"))))
83+
(let* ((state (pi-coding-agent--menu-state))
84+
(model (plist-get (plist-get state :model) :name))
85+
(short (and model (pi-coding-agent--shorten-model-name model))))
86+
(format "Model: %s" (or short "unknown"))))
7887

7988
(defun pi-coding-agent--menu-thinking-description ()
8089
"Return thinking level description for transient menu."
81-
(let ((level (plist-get pi-coding-agent--state :thinking-level)))
90+
(let* ((state (pi-coding-agent--menu-state))
91+
(level (plist-get state :thinking-level)))
8292
(format "Thinking: %s" (or level "off"))))
8393

8494
;;;###autoload
@@ -414,26 +424,51 @@ The name is displayed in the resume picker and header-line."
414424
(message "Pi: Failed to set session name: %s"
415425
(or (plist-get response :error) "unknown error")))))))))
416426

417-
(defun pi-coding-agent-select-model ()
418-
"Select a model interactively."
427+
(defun pi-coding-agent-select-model (&optional initial-input)
428+
"Select a model interactively.
429+
Optional INITIAL-INPUT pre-fills the completion prompt for filtering."
419430
(interactive)
420431
(let ((proc (pi-coding-agent--get-process))
421432
(chat-buf (pi-coding-agent--get-chat-buffer)))
422433
(unless proc
423434
(user-error "No pi process running"))
424-
(let* ((response (pi-coding-agent--rpc-sync proc '(:type "get_available_models") 5))
435+
(let* ((state (pi-coding-agent--menu-state))
436+
(response (pi-coding-agent--rpc-sync proc '(:type "get_available_models") 5))
425437
(data (plist-get response :data))
426438
(models (plist-get data :models))
427-
(current-name (plist-get (plist-get pi-coding-agent--state :model) :name))
428-
;; Build alist of (display-name . model-plist) for selection
439+
(current-name (plist-get (plist-get state :model) :name))
440+
(current-short (and current-name
441+
(pi-coding-agent--shorten-model-name current-name)))
442+
;; Build alist of (short-name . model-plist) for selection
429443
(model-alist (mapcar (lambda (m)
430-
(cons (plist-get m :name) m))
444+
(cons (pi-coding-agent--shorten-model-name
445+
(plist-get m :name))
446+
m))
431447
models))
432448
(names (mapcar #'car model-alist))
433-
(choice (completing-read
434-
(format "Model (current: %s): " (or current-name "unknown"))
435-
names nil t)))
436-
(when (and choice (not (equal choice current-name)))
449+
(choice (let ((completion-ignore-case t)
450+
(completion-styles '(basic flex)))
451+
(if initial-input
452+
;; Try auto-selecting on unique match
453+
(let ((matches (completion-all-completions
454+
initial-input names nil
455+
(length initial-input))))
456+
(when (consp matches)
457+
(setcdr (last matches) nil))
458+
(cond
459+
((= (length matches) 1) (car matches))
460+
((null matches)
461+
(message "Pi: No model matching \"%s\"" initial-input)
462+
nil)
463+
(t (completing-read
464+
(format "Model (current: %s): "
465+
(or current-short "unknown"))
466+
names nil t initial-input))))
467+
(completing-read
468+
(format "Model (current: %s): "
469+
(or current-short "unknown"))
470+
names nil t)))))
471+
(when (and choice (not (equal choice current-short)))
437472
(let* ((selected-model (cdr (assoc choice model-alist)))
438473
(model-id (plist-get selected-model :id))
439474
(provider (plist-get selected-model :provider)))
@@ -561,11 +596,18 @@ Optional CUSTOM-INSTRUCTIONS provide guidance for the compaction summary."
561596
(lambda (response)
562597
(pi-coding-agent--handle-manual-compaction-response chat-buf response))))))))
563598

564-
(defun pi-coding-agent-export-html ()
565-
"Export session to HTML file."
566-
(interactive)
599+
(defun pi-coding-agent-export-html (&optional output-path)
600+
"Export session to HTML file.
601+
Optional OUTPUT-PATH specifies where to save; nil uses pi's default."
602+
(interactive
603+
(list (let ((path (read-string "Export path (RET for default): ")))
604+
(and (not (string-empty-p path)) path))))
567605
(when-let ((proc (pi-coding-agent--get-process)))
568-
(pi-coding-agent--rpc-async proc '(:type "export_html")
606+
(pi-coding-agent--rpc-async proc
607+
(if output-path
608+
(list :type "export_html" :outputPath
609+
(expand-file-name output-path))
610+
'(:type "export_html"))
569611
(lambda (response)
570612
(if (plist-get response :success)
571613
(let* ((data (plist-get response :data))

pi-coding-agent-render.el

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -373,20 +373,38 @@ Note: status is set to `idle' by the event handler."
373373
(unless was-aborted
374374
(pi-coding-agent--process-followup-queue))))
375375

376+
(defun pi-coding-agent--dispatch-builtin-command (text)
377+
"Try to dispatch TEXT as a built-in slash command.
378+
Returns non-nil if TEXT matched a built-in command and was handled."
379+
(when (string-prefix-p "/" text)
380+
(let* ((without-slash (substring text 1))
381+
(words (split-string without-slash))
382+
(cmd-name (car words))
383+
(entry (assoc cmd-name pi-coding-agent--builtin-commands)))
384+
(when entry
385+
(let ((handler (plist-get (cdr entry) :handler))
386+
(args-spec (plist-get (cdr entry) :args))
387+
(arg-str (let ((rest (string-trim
388+
(substring without-slash (length cmd-name)))))
389+
(and (not (string-empty-p rest)) rest))))
390+
(pcase args-spec
391+
('optional (funcall handler arg-str))
392+
('required (if arg-str
393+
(funcall handler arg-str)
394+
(call-interactively handler)))
395+
(_ (funcall handler)))
396+
t)))))
397+
376398
(defun pi-coding-agent--prepare-and-send (text)
377399
"Prepare chat buffer state and send TEXT to pi.
378-
For slash commands: don't display locally, let pi send expanded content.
379-
For regular text: display locally for responsiveness.
380-
The /compact command is handled specially by calling `pi-coding-agent-compact'.
400+
Built-in slash commands are dispatched locally via the dispatch table.
401+
Other slash commands (extensions, skills, prompts) are sent to pi.
402+
Regular text is displayed locally for responsiveness, then sent.
381403
Must be called with chat buffer current.
382404
Status transitions are handled by pi events (agent_start, agent_end)."
383405
(cond
384-
;; /compact is handled locally, invoking `pi-coding-agent-compact' directly
385-
((or (string= text "/compact")
386-
(string-prefix-p "/compact " text))
387-
(let ((args (when (string-prefix-p "/compact " text)
388-
(string-trim (substring text (length "/compact "))))))
389-
(pi-coding-agent-compact (and args (not (string-empty-p args)) args))))
406+
;; Built-in slash commands: dispatch locally
407+
((pi-coding-agent--dispatch-builtin-command text))
390408
;; Other slash commands: don't display locally, send to pi
391409
((string-prefix-p "/" text)
392410
(pi-coding-agent--send-prompt text))

pi-coding-agent-ui.el

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -872,6 +872,28 @@ Extracted from session_info entries when session is loaded or switched.")
872872
Each entry is a plist with :name, :description, :source.
873873
Source is \"prompt\", \"extension\", or \"skill\".")
874874

875+
(defvar pi-coding-agent--builtin-commands
876+
'(("compact" :handler pi-coding-agent-compact :args optional)
877+
("new" :handler pi-coding-agent-new-session)
878+
("model" :handler pi-coding-agent-select-model :args optional)
879+
("session" :handler pi-coding-agent-session-stats)
880+
("name" :handler pi-coding-agent-set-session-name :args required)
881+
("fork" :handler pi-coding-agent-fork)
882+
("resume" :handler pi-coding-agent-resume-session)
883+
("reload" :handler pi-coding-agent-reload)
884+
("export" :handler pi-coding-agent-export-html :args optional)
885+
("copy" :handler pi-coding-agent-copy-last-message)
886+
("quit" :handler pi-coding-agent-quit))
887+
"Built-in slash commands dispatched client-side.
888+
Each entry is (NAME . PLIST) where PLIST has:
889+
:handler Function to call (symbol)
890+
:args nil (no args), `optional', or `required'
891+
892+
Commands with :args `optional' pass the trailing text (or nil) to the
893+
handler. Commands with :args `required' prompt interactively when no
894+
argument is given (the handler's `interactive' spec handles this).
895+
Descriptions come from the handler's docstring.")
896+
875897
(defun pi-coding-agent--set-commands (commands)
876898
"Set COMMANDS in current buffer and propagate to sibling session buffers.
877899
COMMANDS is a list of plists with :name, :description, :source.

test/pi-coding-agent-input-test.el

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2022,7 +2022,32 @@ only offer our own capfs (slash commands, file references, paths)."
20222022
(insert "Some context:\n/te")
20232023
(should-not (pi-coding-agent--command-capf))))
20242024

2025+
(ert-deftest pi-coding-agent-test-command-capf-includes-builtins ()
2026+
"Completion includes built-in commands even when RPC returns nothing."
2027+
(with-temp-buffer
2028+
(pi-coding-agent-input-mode)
2029+
(setq pi-coding-agent--commands nil)
2030+
(insert "/co")
2031+
(let ((result (pi-coding-agent--command-capf)))
2032+
(should result)
2033+
(should (member "compact" (nth 2 result)))
2034+
(should (member "new" (nth 2 result)))
2035+
(should (member "model" (nth 2 result))))))
20252036

2037+
(ert-deftest pi-coding-agent-test-command-capf-merges-builtins-and-rpc ()
2038+
"Completion merges built-in and RPC commands without duplicates."
2039+
(with-temp-buffer
2040+
(pi-coding-agent-input-mode)
2041+
(setq pi-coding-agent--commands '((:name "my-ext" :description "Extension")))
2042+
(insert "/")
2043+
(let* ((result (pi-coding-agent--command-capf))
2044+
(names (nth 2 result)))
2045+
;; Has built-in
2046+
(should (member "compact" names))
2047+
;; Has RPC command
2048+
(should (member "my-ext" names))
2049+
;; No duplicates
2050+
(should (= (length (seq-filter (lambda (n) (equal n "compact")) names)) 1)))))
20262051

20272052
(ert-deftest pi-coding-agent-test-send-prompt-sends-literal ()
20282053
"pi-coding-agent--send-prompt sends text literally (no expansion).

0 commit comments

Comments
 (0)