Skip to content

Commit 1a7fa51

Browse files
fix(tui): route leading-slash lines by registered command id (#1205)
* 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. * fix(tui): lowercase slash command names once at dispatch Case-insensitive routing matched /CLEAR but forwarded the typed case, so the exact registry lookup missed and reported Unknown command. The router now returns the canonical lowercase id; the ignores-case test asserts the lowered name and a keeper test drives /CLEAR through the real registry.
1 parent 8454d5e commit 1a7fa51

5 files changed

Lines changed: 239 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
@@ -112,6 +112,7 @@ export async function runTUI(initialConfig: Config): Promise<number> {
112112
hasAttachments: attachments !== undefined && attachments.length > 0,
113113
feedbackPending: isFeedbackCapturePending(),
114114
feedbackCaptureEnabled: true,
115+
knownCommands: listCommands().map((c) => c.name),
115116
}),
116117
interrupt: lifecycle.interrupt,
117118
deliver: createDeliverRouting(state, services, live),
@@ -166,7 +167,10 @@ export async function runTUI(initialConfig: Config): Promise<number> {
166167
: {}),
167168
})),
168169
onCommand: (name) => {
169-
const route = routeSubmission(name);
170+
const route = routeSubmission(
171+
name,
172+
listCommands().map((c) => c.name),
173+
);
170174
if (route.kind === "empty") return;
171175
if (route.kind === "command") {
172176
state.dispatchCommand?.(route.name, route.args);

‎src/tui/runner/submit.ts‎

Lines changed: 63 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,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

86118
export 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
*/
115150
export 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
*/
122158
export 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(), {

‎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)