@@ -54,6 +54,7 @@ import {
5454} from "./state.js" ;
5555import { LOG_NAMESPACE_ROOT } from "../../branding.js" ;
5656import { buildCredentialRecoveryAlternatives } from "./credential-recovery.js" ;
57+ import { listCommands } from "../commands/registry.js" ;
5758
5859const tuiLogger = getLogger ( [ LOG_NAMESPACE_ROOT , "tui" ] ) ;
5960
@@ -63,24 +64,55 @@ export type SubmissionRoute =
6364 | { kind : "prompt" ; text : string } ;
6465
6566/**
66- * Decide what a submitted composer line is. A leading `/` means a slash command
67- * — it must never reach the model as a prompt, whether it was typed directly or
68- * picked from the palette.
67+ * Command names a leading-`/` token may dispatch to. Call sites own the set —
68+ * registry `listCommands()` names, the same source the `/` popup catalog
69+ * (`stripUneditedSlashHint`) searches. A supplier stays fresh across registry
70+ * reloads; a plain set or array is a snapshot.
6971 */
70- export function routeSubmission ( raw : string ) : SubmissionRoute {
72+ export type KnownCommandNames =
73+ | readonly string [ ]
74+ | ReadonlySet < string >
75+ | ( ( ) => readonly string [ ] | ReadonlySet < string > ) ;
76+
77+ function hasKnownCommand ( known : KnownCommandNames , name : string ) : boolean {
78+ const raw = typeof known === "function" ? known ( ) : known ;
79+ const wanted = name . toLowerCase ( ) ;
80+ for ( const candidate of raw ) {
81+ if ( candidate . toLowerCase ( ) === wanted ) return true ;
82+ }
83+ return false ;
84+ }
85+
86+ /**
87+ * Decide what a submitted composer line is. A leading `/` is a slash command
88+ * only when its first token (to whitespace, lowercased) exactly matches a
89+ * registered command id; anything else — paths like `/Users/you/notes`,
90+ * typos like `/cler` — is a model prompt and reaches it verbatim. Bare `/`
91+ * stays empty. Callers that omit `knownCommands` (tests and non-registry
92+ * surfaces) keep the legacy any-leading-slash-is-a-command rule; every
93+ * product call site passes the registry set.
94+ *
95+ * Case is lowered once here: the returned command name is the canonical
96+ * lowercase registry id, so the downstream exact-`Map.get` lookup
97+ * (`getCommand`) hits for mixed-case input like `/CLEAR`. Matching stays
98+ * case-insensitive via `hasKnownCommand`.
99+ */
100+ export function routeSubmission (
101+ raw : string ,
102+ knownCommands ?: KnownCommandNames ,
103+ ) : SubmissionRoute {
71104 const trimmed = raw . trim ( ) ;
72105 if ( trimmed . length === 0 ) return { kind : "empty" } ;
73- const body = trimmed . startsWith ( "/" ) ? trimmed . slice ( 1 ) . trim ( ) : trimmed ;
74106 if ( ! trimmed . startsWith ( "/" ) ) return { kind : "prompt" , text : trimmed } ;
107+ const body = trimmed . slice ( 1 ) . trim ( ) ;
75108 if ( body . length === 0 ) return { kind : "empty" } ;
76109 const sep = body . search ( / \s / ) ;
77- return sep === - 1
78- ? { kind : "command" , name : body , args : "" }
79- : {
80- kind : "command" ,
81- name : body . slice ( 0 , sep ) ,
82- args : body . slice ( sep + 1 ) . trim ( ) ,
83- } ;
110+ const name = sep === - 1 ? body : body . slice ( 0 , sep ) ;
111+ const args = sep === - 1 ? "" : body . slice ( sep + 1 ) . trim ( ) ;
112+ if ( knownCommands !== undefined && ! hasKnownCommand ( knownCommands , name ) ) {
113+ return { kind : "prompt" , text : trimmed } ;
114+ }
115+ return { kind : "command" , name : name . toLowerCase ( ) , args } ;
84116}
85117
86118export interface SubmitHandlerDeps {
@@ -89,6 +121,8 @@ export interface SubmitHandlerDeps {
89121 text : string ,
90122 attachments ?: readonly PendingImageAttachment [ ] ,
91123 ) => void ;
124+ /** Registry names a leading-`/` token may dispatch to (see routeSubmission). */
125+ knownCommands ?: KnownCommandNames ;
92126 /** Consent-by-proceeding hook: runs only for real prompts, never commands. */
93127 onPromptSubmitted ?: ( ) => void ;
94128 /**
@@ -105,29 +139,32 @@ export interface SubmitHandlerDeps {
105139}
106140
107141/**
108- * Composer submit handler. Slash input is dispatched against the command
109- * registry instead of being sent to the model. When feedback capture is armed
110- * (bare `/feedback`), the next non-command line is captured as survey text.
142+ * Composer submit handler. Leading-`/` input dispatches against the command
143+ * registry only on a registered-id hit; anything else is sent to the model
144+ * as a prompt. When feedback capture is armed (bare `/feedback`), the next
145+ * non-command line is captured as survey text.
111146 *
112147 * Returns an outcome so the session bridge can keep local-only submits off the
113148 * agent busy path and out of the mid-run queue.
114149 */
115150export type SubmitOutcome = "agent" | "local" | "empty" ;
116151
117152/**
118- * Classify a composer line without side effects. Local = slash command or
119- * armed multi-turn feedback text; empty = no-op (or cancel-feedback); agent =
120- * real model turn.
153+ * Classify a composer line without side effects. Local = registered slash
154+ * command or armed multi-turn feedback text; empty = no-op (or
155+ * cancel-feedback); agent = real model turn (including paths and unknown
156+ * slash names when the registry set is provided).
121157 */
122158export function classifySubmission (
123159 text : string ,
124160 options : {
125161 hasAttachments ?: boolean ;
126162 feedbackPending ?: boolean ;
127163 feedbackCaptureEnabled ?: boolean ;
164+ knownCommands ?: KnownCommandNames ;
128165 } = { } ,
129166) : SubmitOutcome {
130- const route = routeSubmission ( text ) ;
167+ const route = routeSubmission ( text , options . knownCommands ) ;
131168 const hasAttachments = options . hasAttachments === true ;
132169 if ( route . kind === "empty" && ! hasAttachments ) return "empty" ;
133170 if ( route . kind === "command" ) return "local" ;
@@ -148,14 +185,17 @@ export function createSubmitHandler(
148185 attachments ?: readonly PendingImageAttachment [ ] ,
149186) => SubmitOutcome {
150187 return ( text , attachments ) => {
151- const route = routeSubmission ( text ) ;
188+ const route = routeSubmission ( text , deps . knownCommands ) ;
152189 const hasAttachments = attachments !== undefined && attachments . length > 0 ;
153190 const feedbackPending = deps . isFeedbackCapturePending ?.( ) === true ;
154191 const feedbackCaptureEnabled = deps . onFeedbackText !== undefined ;
155192 const outcome = classifySubmission ( text , {
156193 hasAttachments,
157194 feedbackPending,
158195 feedbackCaptureEnabled,
196+ ...( deps . knownCommands !== undefined
197+ ? { knownCommands : deps . knownCommands }
198+ : { } ) ,
159199 } ) ;
160200
161201 // Empty Enter while /feedback is armed cancels instead of trapping the
@@ -375,6 +415,9 @@ export function createSubmitPath(
375415
376416 const send = createSubmitHandler ( {
377417 dispatchCommand : ( name , args ) => state . dispatchCommand ?.( name , args ) ,
418+ // Live registry names: a leading `/` dispatches only on an exact id hit,
419+ // so absolute paths (`/Users/…`) fall through to the model as prompts.
420+ knownCommands : ( ) => listCommands ( ) . map ( ( c ) => c . name ) ,
378421 sendPrompt : ( text , attachments ) => {
379422 void sendUserPrompt ( text , attachments ?? [ ] ) . catch ( ( error : unknown ) => {
380423 handleSendFailure ( error , live . attemptIdentity ( ) , {
0 commit comments