@@ -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,50 @@ 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+ export function routeSubmission (
96+ raw : string ,
97+ knownCommands ?: KnownCommandNames ,
98+ ) : SubmissionRoute {
7199 const trimmed = raw . trim ( ) ;
72100 if ( trimmed . length === 0 ) return { kind : "empty" } ;
73- const body = trimmed . startsWith ( "/" ) ? trimmed . slice ( 1 ) . trim ( ) : trimmed ;
74101 if ( ! trimmed . startsWith ( "/" ) ) return { kind : "prompt" , text : trimmed } ;
102+ const body = trimmed . slice ( 1 ) . trim ( ) ;
75103 if ( body . length === 0 ) return { kind : "empty" } ;
76104 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- } ;
105+ const name = sep === - 1 ? body : body . slice ( 0 , sep ) ;
106+ const args = sep === - 1 ? "" : body . slice ( sep + 1 ) . trim ( ) ;
107+ if ( knownCommands !== undefined && ! hasKnownCommand ( knownCommands , name ) ) {
108+ return { kind : "prompt" , text : trimmed } ;
109+ }
110+ return { kind : "command" , name, args } ;
84111}
85112
86113export interface SubmitHandlerDeps {
@@ -89,6 +116,8 @@ export interface SubmitHandlerDeps {
89116 text : string ,
90117 attachments ?: readonly PendingImageAttachment [ ] ,
91118 ) => void ;
119+ /** Registry names a leading-`/` token may dispatch to (see routeSubmission). */
120+ knownCommands ?: KnownCommandNames ;
92121 /** Consent-by-proceeding hook: runs only for real prompts, never commands. */
93122 onPromptSubmitted ?: ( ) => void ;
94123 /**
@@ -105,29 +134,32 @@ export interface SubmitHandlerDeps {
105134}
106135
107136/**
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.
137+ * Composer submit handler. Leading-`/` input dispatches against the command
138+ * registry only on a registered-id hit; anything else is sent to the model
139+ * as a prompt. When feedback capture is armed (bare `/feedback`), the next
140+ * non-command line is captured as survey text.
111141 *
112142 * Returns an outcome so the session bridge can keep local-only submits off the
113143 * agent busy path and out of the mid-run queue.
114144 */
115145export type SubmitOutcome = "agent" | "local" | "empty" ;
116146
117147/**
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.
148+ * Classify a composer line without side effects. Local = registered slash
149+ * command or armed multi-turn feedback text; empty = no-op (or
150+ * cancel-feedback); agent = real model turn (including paths and unknown
151+ * slash names when the registry set is provided).
121152 */
122153export function classifySubmission (
123154 text : string ,
124155 options : {
125156 hasAttachments ?: boolean ;
126157 feedbackPending ?: boolean ;
127158 feedbackCaptureEnabled ?: boolean ;
159+ knownCommands ?: KnownCommandNames ;
128160 } = { } ,
129161) : SubmitOutcome {
130- const route = routeSubmission ( text ) ;
162+ const route = routeSubmission ( text , options . knownCommands ) ;
131163 const hasAttachments = options . hasAttachments === true ;
132164 if ( route . kind === "empty" && ! hasAttachments ) return "empty" ;
133165 if ( route . kind === "command" ) return "local" ;
@@ -148,14 +180,17 @@ export function createSubmitHandler(
148180 attachments ?: readonly PendingImageAttachment [ ] ,
149181) => SubmitOutcome {
150182 return ( text , attachments ) => {
151- const route = routeSubmission ( text ) ;
183+ const route = routeSubmission ( text , deps . knownCommands ) ;
152184 const hasAttachments = attachments !== undefined && attachments . length > 0 ;
153185 const feedbackPending = deps . isFeedbackCapturePending ?.( ) === true ;
154186 const feedbackCaptureEnabled = deps . onFeedbackText !== undefined ;
155187 const outcome = classifySubmission ( text , {
156188 hasAttachments,
157189 feedbackPending,
158190 feedbackCaptureEnabled,
191+ ...( deps . knownCommands !== undefined
192+ ? { knownCommands : deps . knownCommands }
193+ : { } ) ,
159194 } ) ;
160195
161196 // Empty Enter while /feedback is armed cancels instead of trapping the
@@ -375,6 +410,9 @@ export function createSubmitPath(
375410
376411 const send = createSubmitHandler ( {
377412 dispatchCommand : ( name , args ) => state . dispatchCommand ?.( name , args ) ,
413+ // Live registry names: a leading `/` dispatches only on an exact id hit,
414+ // so absolute paths (`/Users/…`) fall through to the model as prompts.
415+ knownCommands : ( ) => listCommands ( ) . map ( ( c ) => c . name ) ,
378416 sendPrompt : ( text , attachments ) => {
379417 void sendUserPrompt ( text , attachments ?? [ ] ) . catch ( ( error : unknown ) => {
380418 handleSendFailure ( error , live . attemptIdentity ( ) , {
0 commit comments