Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/tui/command-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
6 changes: 5 additions & 1 deletion src/tui/runner/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ export async function runTUI(initialConfig: Config): Promise<number> {
hasAttachments: attachments !== undefined && attachments.length > 0,
feedbackPending: isFeedbackCapturePending(),
feedbackCaptureEnabled: true,
knownCommands: listCommands().map((c) => c.name),
}),
interrupt: lifecycle.interrupt,
deliver: createDeliverRouting(state, services, live),
Expand Down Expand Up @@ -173,7 +174,10 @@ export async function runTUI(initialConfig: Config): Promise<number> {
: {}),
})),
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);
Expand Down
83 changes: 63 additions & 20 deletions src/tui/runner/submit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]);

Expand All @@ -63,24 +64,55 @@ 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<string>
| (() => readonly string[] | ReadonlySet<string>);

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.
*
* 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,
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: name.toLowerCase(), args };
}

export interface SubmitHandlerDeps {
Expand All @@ -89,6 +121,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;
/**
Expand All @@ -105,29 +139,32 @@ 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.
*/
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,
options: {
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";
Expand All @@ -148,14 +185,17 @@ 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;
const outcome = classifySubmission(text, {
hasAttachments,
feedbackPending,
feedbackCaptureEnabled,
...(deps.knownCommands !== undefined
? { knownCommands: deps.knownCommands }
: {}),
});

// Empty Enter while /feedback is armed cancels instead of trapping the
Expand Down Expand Up @@ -375,6 +415,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(), {
Expand Down
12 changes: 9 additions & 3 deletions src/tui/shell/internals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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),
};
}
Expand Down
Loading
Loading