Skip to content

Commit 96ca656

Browse files
committed
fix(tui): route leading-slash lines by registered command id
A leading slash dispatches a slash command only when its first token (to whitespace, lowercased) exactly matches a registered command id. Absolute paths (/Users/you/notes) and unknown names now reach the model verbatim as prompts; bare slash stays empty. All three submit sites share one registry-owned set, and the slash popup, arg-stage parse, and unedited-hint strip treat path heads as prompts. Unknown-slash choice: typos go to the model (no did-you-mean notice, no fuzzy matcher); multi-slash input always did.
1 parent 4a03fb5 commit 96ca656

5 files changed

Lines changed: 212 additions & 33 deletions

File tree

‎src/tui/command-catalog.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,8 @@ export function stripUneditedSlashHint(
156156
if (!value.startsWith("/")) return null;
157157
const space = value.indexOf(" ");
158158
if (space < 0) return null;
159+
// Path-like heads (`/Users/you …`) are prompts, never hint bases.
160+
if (value.slice(1, space).includes("/")) return null;
159161
const tail = value.slice(space + 1);
160162
if (tail.length === 0) return null;
161163
const cmd = catalog.find(

‎src/tui/runner/index.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ export async function runTUI(initialConfig: Config): Promise<number> {
119119
hasAttachments: attachments !== undefined && attachments.length > 0,
120120
feedbackPending: isFeedbackCapturePending(),
121121
feedbackCaptureEnabled: true,
122+
knownCommands: listCommands().map((c) => c.name),
122123
}),
123124
interrupt: lifecycle.interrupt,
124125
deliver: createDeliverRouting(state, services, live),
@@ -173,7 +174,10 @@ export async function runTUI(initialConfig: Config): Promise<number> {
173174
: {}),
174175
})),
175176
onCommand: (name) => {
176-
const route = routeSubmission(name);
177+
const route = routeSubmission(
178+
name,
179+
listCommands().map((c) => c.name),
180+
);
177181
if (route.kind === "empty") return;
178182
if (route.kind === "command") {
179183
state.dispatchCommand?.(route.name, route.args);

‎src/tui/runner/submit.ts‎

Lines changed: 58 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ import {
5454
} from "./state.js";
5555
import { LOG_NAMESPACE_ROOT } from "../../branding.js";
5656
import { buildCredentialRecoveryAlternatives } from "./credential-recovery.js";
57+
import { listCommands } from "../commands/registry.js";
5758

5859
const 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

86113
export 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
*/
115145
export 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
*/
122153
export 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(), {

‎src/tui/shell/internals.ts‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1050,13 +1050,16 @@ export function isSlashPopupOpen(shell: AppShell): boolean {
10501050

10511051
/**
10521052
* Popup query = prompt text after the leading `/`. Null once the operator has
1053-
* typed whitespace: at that point the name is settled and the rest is arguments.
1053+
* typed whitespace (the name is settled, the rest is arguments) or a second
1054+
* `/` (a path like `/Users/…`, never a command).
10541055
*/
10551056
export function slashPopupQuery(shell: AppShell): string | null {
10561057
const value = shell.prompt.value;
10571058
if (!value.startsWith("/")) return null;
10581059
const head = value.slice(1);
1059-
return /\s/.test(head) ? null : head;
1060+
if (/\s/.test(head)) return null;
1061+
if (head.includes("/")) return null;
1062+
return head;
10601063
}
10611064

10621065
/** Second-stage arg parse: `/name` + whitespace + typed arg tail. */
@@ -1078,8 +1081,11 @@ export function slashArgQuery(shell: AppShell): SlashArgQuery | null {
10781081
const head = value.slice(1);
10791082
const gap = /\s/.exec(head);
10801083
if (gap === null) return null;
1084+
const name = head.slice(0, gap.index);
1085+
// Path heads (`/Users/you notes`) are prompts, never arg-stage commands.
1086+
if (name.includes("/")) return null;
10811087
return {
1082-
name: head.slice(0, gap.index),
1088+
name,
10831089
arg: head.slice(gap.index + gap[0].length),
10841090
};
10851091
}

0 commit comments

Comments
 (0)