From 96ca656a7511055fa32d411982df066ab5ffc9af Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 28 Sep 2026 07:41:17 -0700 Subject: [PATCH 1/2] 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. --- src/tui/command-catalog.ts | 2 + src/tui/runner/index.ts | 6 +- src/tui/runner/submit.ts | 78 ++++++++++++----- src/tui/shell/internals.ts | 12 ++- src/tui/submit-handler.test.ts | 147 +++++++++++++++++++++++++++++++-- 5 files changed, 212 insertions(+), 33 deletions(-) diff --git a/src/tui/command-catalog.ts b/src/tui/command-catalog.ts index 75392fd2d..401b042b8 100644 --- a/src/tui/command-catalog.ts +++ b/src/tui/command-catalog.ts @@ -156,6 +156,8 @@ export function stripUneditedSlashHint( if (!value.startsWith("/")) return null; const space = value.indexOf(" "); if (space < 0) return null; + // Path-like heads (`/Users/you …`) are prompts, never hint bases. + if (value.slice(1, space).includes("/")) return null; const tail = value.slice(space + 1); if (tail.length === 0) return null; const cmd = catalog.find( diff --git a/src/tui/runner/index.ts b/src/tui/runner/index.ts index 07eb3e0f6..aeb34f317 100644 --- a/src/tui/runner/index.ts +++ b/src/tui/runner/index.ts @@ -119,6 +119,7 @@ export async function runTUI(initialConfig: Config): Promise { hasAttachments: attachments !== undefined && attachments.length > 0, feedbackPending: isFeedbackCapturePending(), feedbackCaptureEnabled: true, + knownCommands: listCommands().map((c) => c.name), }), interrupt: lifecycle.interrupt, deliver: createDeliverRouting(state, services, live), @@ -173,7 +174,10 @@ export async function runTUI(initialConfig: Config): Promise { : {}), })), onCommand: (name) => { - const route = routeSubmission(name); + const route = routeSubmission( + name, + listCommands().map((c) => c.name), + ); if (route.kind === "empty") return; if (route.kind === "command") { state.dispatchCommand?.(route.name, route.args); diff --git a/src/tui/runner/submit.ts b/src/tui/runner/submit.ts index 8310591bc..a50655eba 100644 --- a/src/tui/runner/submit.ts +++ b/src/tui/runner/submit.ts @@ -54,6 +54,7 @@ import { } from "./state.js"; import { LOG_NAMESPACE_ROOT } from "../../branding.js"; import { buildCredentialRecoveryAlternatives } from "./credential-recovery.js"; +import { listCommands } from "../commands/registry.js"; const tuiLogger = getLogger([LOG_NAMESPACE_ROOT, "tui"]); @@ -63,24 +64,50 @@ export type SubmissionRoute = | { kind: "prompt"; text: string }; /** - * Decide what a submitted composer line is. A leading `/` means a slash command - * — it must never reach the model as a prompt, whether it was typed directly or - * picked from the palette. + * Command names a leading-`/` token may dispatch to. Call sites own the set — + * registry `listCommands()` names, the same source the `/` popup catalog + * (`stripUneditedSlashHint`) searches. A supplier stays fresh across registry + * reloads; a plain set or array is a snapshot. */ -export function routeSubmission(raw: string): SubmissionRoute { +export type KnownCommandNames = + | readonly string[] + | ReadonlySet + | (() => readonly string[] | ReadonlySet); + +function hasKnownCommand(known: KnownCommandNames, name: string): boolean { + const raw = typeof known === "function" ? known() : known; + const wanted = name.toLowerCase(); + for (const candidate of raw) { + if (candidate.toLowerCase() === wanted) return true; + } + return false; +} + +/** + * Decide what a submitted composer line is. A leading `/` is a slash command + * only when its first token (to whitespace, lowercased) exactly matches a + * registered command id; anything else — paths like `/Users/you/notes`, + * typos like `/cler` — is a model prompt and reaches it verbatim. Bare `/` + * stays empty. Callers that omit `knownCommands` (tests and non-registry + * surfaces) keep the legacy any-leading-slash-is-a-command rule; every + * product call site passes the registry set. + */ +export function routeSubmission( + raw: string, + knownCommands?: KnownCommandNames, +): SubmissionRoute { const trimmed = raw.trim(); if (trimmed.length === 0) return { kind: "empty" }; - const body = trimmed.startsWith("/") ? trimmed.slice(1).trim() : trimmed; if (!trimmed.startsWith("/")) return { kind: "prompt", text: trimmed }; + const body = trimmed.slice(1).trim(); if (body.length === 0) return { kind: "empty" }; const sep = body.search(/\s/); - return sep === -1 - ? { kind: "command", name: body, args: "" } - : { - kind: "command", - name: body.slice(0, sep), - args: body.slice(sep + 1).trim(), - }; + const name = sep === -1 ? body : body.slice(0, sep); + const args = sep === -1 ? "" : body.slice(sep + 1).trim(); + if (knownCommands !== undefined && !hasKnownCommand(knownCommands, name)) { + return { kind: "prompt", text: trimmed }; + } + return { kind: "command", name, args }; } export interface SubmitHandlerDeps { @@ -89,6 +116,8 @@ export interface SubmitHandlerDeps { text: string, attachments?: readonly PendingImageAttachment[], ) => void; + /** Registry names a leading-`/` token may dispatch to (see routeSubmission). */ + knownCommands?: KnownCommandNames; /** Consent-by-proceeding hook: runs only for real prompts, never commands. */ onPromptSubmitted?: () => void; /** @@ -105,9 +134,10 @@ export interface SubmitHandlerDeps { } /** - * Composer submit handler. Slash input is dispatched against the command - * registry instead of being sent to the model. When feedback capture is armed - * (bare `/feedback`), the next non-command line is captured as survey text. + * Composer submit handler. Leading-`/` input dispatches against the command + * registry only on a registered-id hit; anything else is sent to the model + * as a prompt. When feedback capture is armed (bare `/feedback`), the next + * non-command line is captured as survey text. * * Returns an outcome so the session bridge can keep local-only submits off the * agent busy path and out of the mid-run queue. @@ -115,9 +145,10 @@ export interface SubmitHandlerDeps { export type SubmitOutcome = "agent" | "local" | "empty"; /** - * Classify a composer line without side effects. Local = slash command or - * armed multi-turn feedback text; empty = no-op (or cancel-feedback); agent = - * real model turn. + * Classify a composer line without side effects. Local = registered slash + * command or armed multi-turn feedback text; empty = no-op (or + * cancel-feedback); agent = real model turn (including paths and unknown + * slash names when the registry set is provided). */ export function classifySubmission( text: string, @@ -125,9 +156,10 @@ export function classifySubmission( hasAttachments?: boolean; feedbackPending?: boolean; feedbackCaptureEnabled?: boolean; + knownCommands?: KnownCommandNames; } = {}, ): SubmitOutcome { - const route = routeSubmission(text); + const route = routeSubmission(text, options.knownCommands); const hasAttachments = options.hasAttachments === true; if (route.kind === "empty" && !hasAttachments) return "empty"; if (route.kind === "command") return "local"; @@ -148,7 +180,7 @@ export function createSubmitHandler( attachments?: readonly PendingImageAttachment[], ) => SubmitOutcome { return (text, attachments) => { - const route = routeSubmission(text); + const route = routeSubmission(text, deps.knownCommands); const hasAttachments = attachments !== undefined && attachments.length > 0; const feedbackPending = deps.isFeedbackCapturePending?.() === true; const feedbackCaptureEnabled = deps.onFeedbackText !== undefined; @@ -156,6 +188,9 @@ export function createSubmitHandler( hasAttachments, feedbackPending, feedbackCaptureEnabled, + ...(deps.knownCommands !== undefined + ? { knownCommands: deps.knownCommands } + : {}), }); // Empty Enter while /feedback is armed cancels instead of trapping the @@ -375,6 +410,9 @@ export function createSubmitPath( const send = createSubmitHandler({ dispatchCommand: (name, args) => state.dispatchCommand?.(name, args), + // Live registry names: a leading `/` dispatches only on an exact id hit, + // so absolute paths (`/Users/…`) fall through to the model as prompts. + knownCommands: () => listCommands().map((c) => c.name), sendPrompt: (text, attachments) => { void sendUserPrompt(text, attachments ?? []).catch((error: unknown) => { handleSendFailure(error, live.attemptIdentity(), { diff --git a/src/tui/shell/internals.ts b/src/tui/shell/internals.ts index b83dec431..39ab494ba 100644 --- a/src/tui/shell/internals.ts +++ b/src/tui/shell/internals.ts @@ -1050,13 +1050,16 @@ export function isSlashPopupOpen(shell: AppShell): boolean { /** * Popup query = prompt text after the leading `/`. Null once the operator has - * typed whitespace: at that point the name is settled and the rest is arguments. + * typed whitespace (the name is settled, the rest is arguments) or a second + * `/` (a path like `/Users/…`, never a command). */ export function slashPopupQuery(shell: AppShell): string | null { const value = shell.prompt.value; if (!value.startsWith("/")) return null; const head = value.slice(1); - return /\s/.test(head) ? null : head; + if (/\s/.test(head)) return null; + if (head.includes("/")) return null; + return head; } /** Second-stage arg parse: `/name` + whitespace + typed arg tail. */ @@ -1078,8 +1081,11 @@ export function slashArgQuery(shell: AppShell): SlashArgQuery | null { const head = value.slice(1); const gap = /\s/.exec(head); if (gap === null) return null; + const name = head.slice(0, gap.index); + // Path heads (`/Users/you notes`) are prompts, never arg-stage commands. + if (name.includes("/")) return null; return { - name: head.slice(0, gap.index), + name, arg: head.slice(gap.index + gap[0].length), }; } diff --git a/src/tui/submit-handler.test.ts b/src/tui/submit-handler.test.ts index 0376e44d1..97112421c 100644 --- a/src/tui/submit-handler.test.ts +++ b/src/tui/submit-handler.test.ts @@ -8,7 +8,13 @@ import { } from "./runner/submit.js"; import { telemetryStartupNotice } from "./runner/settings.js"; import type { PendingImageAttachment } from "./image-attachments.js"; +import { stripUneditedSlashHint } from "./command-catalog.js"; import { TELEMETRY_NOTICE } from "../telemetry/index.js"; +import { + slashArgQuery, + slashPopupQuery, + type AppShell, +} from "./shell/internals.js"; import { armFeedbackCapture, cancelFeedbackCapture, @@ -25,11 +31,14 @@ interface Dispatched { args: string; } +const KNOWN_COMMANDS = ["clear", "rename", "help", "feedback", "model"]; + function harness(options?: { isFeedbackCapturePending?: () => boolean; onFeedbackText?: (text: string) => string; cancelFeedbackCapture?: () => void; onSystemNotice?: (text: string) => void; + knownCommands?: readonly string[]; }) { const dispatched: Dispatched[] = []; const prompts: string[] = []; @@ -41,6 +50,7 @@ function harness(options?: { onPromptSubmitted: () => { promptSubmissions += 1; }, + knownCommands: options?.knownCommands ?? KNOWN_COMMANDS, ...(options?.isFeedbackCapturePending !== undefined ? { isFeedbackCapturePending: options.isFeedbackCapturePending } : {}), @@ -81,13 +91,59 @@ describe("composer submit handler", () => { expect(h.prompts).toEqual([]); }); - test("dispatches unknown slash names so the registry can report them", () => { + test("unknown slash names reach the model as prompts, not the registry", () => { + const h = harness(); + expect(h.submit("/not-a-command")).toBe("agent"); + expect(h.dispatched).toEqual([]); + expect(h.prompts).toEqual(["/not-a-command"]); + expect(h.telemetry()).toBe(1); + }); + + test("absolute paths reach the model verbatim as prompts", () => { + const h = harness(); + expect(h.submit(" /Users/you/notes.txt ")).toBe("agent"); + expect(h.prompts).toEqual(["/Users/you/notes.txt"]); + expect(h.dispatched).toEqual([]); + expect(h.telemetry()).toBe(1); + }); + + test("short absolute paths reach the model as prompts", () => { const h = harness(); - expect(h.submit("/not-a-command")).toBe("local"); - expect(h.dispatched).toEqual([{ name: "not-a-command", args: "" }]); + expect(h.submit("/tmp/x")).toBe("agent"); + expect(h.prompts).toEqual(["/tmp/x"]); + expect(h.dispatched).toEqual([]); + }); + + test("multi-slash input reaches the model as a prompt", () => { + const h = harness(); + expect(h.submit("//not-a-command")).toBe("agent"); + expect(h.prompts).toEqual(["//not-a-command"]); + expect(h.dispatched).toEqual([]); + }); + + test("command matching ignores case", () => { + const h = harness(); + expect(h.submit("/CLEAR")).toBe("local"); + expect(h.dispatched).toEqual([{ name: "CLEAR", args: "" }]); expect(h.prompts).toEqual([]); }); + test("a leading-slash path is captured as feedback while armed", () => { + const feedbackTexts: string[] = []; + const h = harness({ + isFeedbackCapturePending: () => isFeedbackCapturePending(), + onFeedbackText: (text) => { + feedbackTexts.push(text); + return "Thanks — feedback sent."; + }, + }); + armFeedbackCapture(); + expect(h.submit("/tmp/x")).toBe("local"); + expect(feedbackTexts).toEqual(["/tmp/x"]); + expect(h.prompts).toEqual([]); + expect(h.dispatched).toEqual([]); + }); + test("sends ordinary prompts to the agent", () => { const h = harness(); expect(h.submit(" refactor the parser ")).toBe("agent"); @@ -166,9 +222,27 @@ describe("composer submit handler", () => { }); describe("classifySubmission", () => { - test("slash commands are local", () => { - expect(classifySubmission("/feedback hi")).toBe("local"); - expect(classifySubmission("/clear")).toBe("local"); + test("registered slash commands are local", () => { + expect( + classifySubmission("/feedback hi", { knownCommands: KNOWN_COMMANDS }), + ).toBe("local"); + expect( + classifySubmission("/clear", { knownCommands: KNOWN_COMMANDS }), + ).toBe("local"); + }); + + test("paths and unknown slash names are agent turns", () => { + expect( + classifySubmission("/Users/you/notes.txt", { + knownCommands: KNOWN_COMMANDS, + }), + ).toBe("agent"); + expect( + classifySubmission("/tmp/x", { knownCommands: KNOWN_COMMANDS }), + ).toBe("agent"); + expect( + classifySubmission("/not-a-command", { knownCommands: KNOWN_COMMANDS }), + ).toBe("agent"); }); test("ordinary prompts are agent", () => { @@ -197,8 +271,8 @@ describe("classifySubmission", () => { }); describe("routeSubmission", () => { - test("classifies leading slash as a command", () => { - expect(routeSubmission("/model gpt")).toEqual({ + test("classifies a registered leading slash as a command", () => { + expect(routeSubmission("/model gpt", KNOWN_COMMANDS)).toEqual({ kind: "command", name: "model", args: "gpt", @@ -206,11 +280,66 @@ describe("routeSubmission", () => { }); test("classifies plain text as a prompt", () => { - expect(routeSubmission("do the thing")).toEqual({ + expect(routeSubmission("do the thing", KNOWN_COMMANDS)).toEqual({ kind: "prompt", text: "do the thing", }); }); + + test("routes paths and unknown slash names to the model verbatim", () => { + expect(routeSubmission("/Users/you/notes.txt", KNOWN_COMMANDS)).toEqual({ + kind: "prompt", + text: "/Users/you/notes.txt", + }); + expect(routeSubmission(" /tmp/x ", KNOWN_COMMANDS)).toEqual({ + kind: "prompt", + text: "/tmp/x", + }); + expect(routeSubmission("/not-a-command", KNOWN_COMMANDS)).toEqual({ + kind: "prompt", + text: "/not-a-command", + }); + expect(routeSubmission("//double", KNOWN_COMMANDS)).toEqual({ + kind: "prompt", + text: "//double", + }); + }); + + test("bare slash stays empty", () => { + expect(routeSubmission("/", KNOWN_COMMANDS)).toEqual({ kind: "empty" }); + expect(routeSubmission(" / ", KNOWN_COMMANDS)).toEqual({ kind: "empty" }); + }); +}); + +function fakeShellWithPrompt(value: string): AppShell { + return { prompt: { value } } as unknown as AppShell; +} + +describe("slash routing guards", () => { + test("stripUneditedSlashHint leaves path-like values alone", () => { + const catalog = [ + { id: "release", label: "/release", argumentHint: "" }, + ]; + expect(stripUneditedSlashHint(catalog, "/Users/you ")).toBeNull(); + expect(stripUneditedSlashHint(catalog, "/a/b ")).toBeNull(); + expect(stripUneditedSlashHint(catalog, "/release ")).toBe("/release "); + }); + + test("slashPopupQuery is null for path heads", () => { + expect(slashPopupQuery(fakeShellWithPrompt("/Users/you"))).toBeNull(); + expect(slashPopupQuery(fakeShellWithPrompt("/tmp/x"))).toBeNull(); + expect(slashPopupQuery(fakeShellWithPrompt("/model"))).toBe("model"); + expect(slashPopupQuery(fakeShellWithPrompt("/"))).toBe(""); + expect(slashPopupQuery(fakeShellWithPrompt("plain"))).toBeNull(); + }); + + test("slashArgQuery is null for path heads", () => { + expect(slashArgQuery(fakeShellWithPrompt("/Users/you notes"))).toBeNull(); + expect(slashArgQuery(fakeShellWithPrompt("/model gpt"))).toEqual({ + name: "model", + arg: "gpt", + }); + }); }); describe("telemetryStartupNotice", () => { From f587afc1a7f97792aa252470c9ea5c308dbfdbc2 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 28 Sep 2026 08:13:39 -0700 Subject: [PATCH 2/2] 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. --- src/tui/runner/submit.ts | 7 ++++++- src/tui/submit-handler.test.ts | 24 +++++++++++++++++++++++- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/tui/runner/submit.ts b/src/tui/runner/submit.ts index a50655eba..3c7f28c7d 100644 --- a/src/tui/runner/submit.ts +++ b/src/tui/runner/submit.ts @@ -91,6 +91,11 @@ function hasKnownCommand(known: KnownCommandNames, name: string): boolean { * stays empty. Callers that omit `knownCommands` (tests and non-registry * surfaces) keep the legacy any-leading-slash-is-a-command rule; every * product call site passes the registry set. + * + * Case is lowered once here: the returned command name is the canonical + * lowercase registry id, so the downstream exact-`Map.get` lookup + * (`getCommand`) hits for mixed-case input like `/CLEAR`. Matching stays + * case-insensitive via `hasKnownCommand`. */ export function routeSubmission( raw: string, @@ -107,7 +112,7 @@ export function routeSubmission( if (knownCommands !== undefined && !hasKnownCommand(knownCommands, name)) { return { kind: "prompt", text: trimmed }; } - return { kind: "command", name, args }; + return { kind: "command", name: name.toLowerCase(), args }; } export interface SubmitHandlerDeps { diff --git a/src/tui/submit-handler.test.ts b/src/tui/submit-handler.test.ts index 97112421c..86c13f280 100644 --- a/src/tui/submit-handler.test.ts +++ b/src/tui/submit-handler.test.ts @@ -21,6 +21,8 @@ import { isFeedbackCapturePending, resetFeedbackStateForTests, } from "../telemetry/feedback.js"; +import { registerBuiltInCommands } from "./commands/built-in.js"; +import { getCommand, listCommands } from "./commands/registry.js"; afterEach(() => { resetFeedbackStateForTests(); @@ -124,10 +126,30 @@ describe("composer submit handler", () => { test("command matching ignores case", () => { const h = harness(); expect(h.submit("/CLEAR")).toBe("local"); - expect(h.dispatched).toEqual([{ name: "CLEAR", args: "" }]); + expect(h.dispatched).toEqual([{ name: "clear", args: "" }]); expect(h.prompts).toEqual([]); }); + test("/CLEAR dispatches clear through the real registry", () => { + registerBuiltInCommands(); + const dispatched: Dispatched[] = []; + const resolved: (string | undefined)[] = []; + const submit = createSubmitHandler({ + dispatchCommand: (name, args) => { + dispatched.push({ name, args }); + resolved.push(getCommand(name)?.name); + }, + sendPrompt: () => { + throw new Error("/CLEAR must dispatch, not prompt"); + }, + onPromptSubmitted: () => undefined, + knownCommands: () => listCommands().map((c) => c.name), + }); + expect(submit("/CLEAR")).toBe("local"); + expect(dispatched).toEqual([{ name: "clear", args: "" }]); + expect(resolved).toEqual(["clear"]); + }); + test("a leading-slash path is captured as feedback while armed", () => { const feedbackTexts: string[] = []; const h = harness({