From a4d4c1c1f6d71ebff54b8a6d8b37b405b81c90d8 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Wed, 23 Sep 2026 16:38:13 -0700 Subject: [PATCH] feat(tui): show suggested slash parameters in the popup Suggested parameters were stored on the command and never reached the popup, so accepting a command left a bare name with nothing to type over. --- src/tui/command-catalog.test.ts | 88 +++++++++++ src/tui/command-catalog.ts | 148 ++++++++++++++++-- src/tui/commands/registry.ts | 4 +- src/tui/overlay-view.ts | 17 ++- src/tui/prompt-slash-exit.test.ts | 98 ++++++++++++ src/tui/runner/index.ts | 7 + src/tui/shell/internals.ts | 25 ++++ src/tui/shell/keys.ts | 11 +- src/tui/shell/overlay-host.ts | 14 +- src/tui/shell/palette.ts | 240 +++++++++++++++++++++++++----- src/tui/shell/prompt.ts | 21 ++- src/tui/slash-popup-gate.test.ts | 46 +++++- 12 files changed, 653 insertions(+), 66 deletions(-) diff --git a/src/tui/command-catalog.test.ts b/src/tui/command-catalog.test.ts index 8a961b5b7..b84db7620 100644 --- a/src/tui/command-catalog.test.ts +++ b/src/tui/command-catalog.test.ts @@ -4,6 +4,9 @@ import { filterPaletteCommands, formatPaletteRows, paletteLabels, + slashArgItems, + stripUneditedSlashHint, + type PaletteCommand, } from "./command-catalog"; import { BUNDLED_PLUGIN_MARKER } from "../plugins/origin-marker.js"; import { stringWidth } from "./view/height.js"; @@ -120,3 +123,88 @@ describe("command origin markers", () => { } }); }); + +describe("slashArgItems", () => { + const hintCmd: PaletteCommand = { + id: "release", + label: "/release", + argumentHint: "", + }; + const subCmd: PaletteCommand = { + id: "scale", + label: "/scale", + subcommands: [ + { name: "high", description: "Scale high" }, + { name: "low", description: "Scale low" }, + ], + }; + const bareCmd: PaletteCommand = { id: "mcp", label: "/mcp" }; + + test("hint command offers the hint as a single row on the empty tail", () => { + const rows = slashArgItems(hintCmd, ""); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + parentId: "release", + argValue: "", + argKind: "hint", + }); + }); + + test("hint command offers nothing once the tail is being typed over", () => { + expect(slashArgItems(hintCmd, "abc")).toEqual([]); + }); + + test("arg-less command offers no rows", () => { + expect(slashArgItems(bareCmd, "")).toEqual([]); + }); + + test("subcommands filter by name prefix", () => { + expect(slashArgItems(subCmd, "").map((r) => r.argValue)).toEqual([ + "high", + "low", + ]); + expect(slashArgItems(subCmd, "h").map((r) => r.argValue)).toEqual(["high"]); + expect(slashArgItems(subCmd, "zzz")).toEqual([]); + }); + + test("multi-token tail matches no rows — the popup dismisses instead", () => { + // The dismiss decision itself lives in openSlashArgRows; the catalog half + // is that a second token can never prefix-match a single subcommand name + // or an empty-tail hint. + expect(slashArgItems(subCmd, "high --force")).toEqual([]); + expect(slashArgItems(hintCmd, "abc def")).toEqual([]); + }); +}); + +describe("stripUneditedSlashHint", () => { + const catalog: readonly PaletteCommand[] = [ + { id: "release", label: "/release", argumentHint: "" }, + { + id: "scale", + label: "/scale", + subcommands: [{ name: "high", description: "Scale high" }], + }, + { id: "mcp", label: "/mcp" }, + ]; + + test("exact untouched hint strips to the bare base, selection or not", () => { + // Selection state is irrelevant: an arrow key drops the untouched + // selection without editing, and the shape is still the placeholder. + expect(stripUneditedSlashHint(catalog, "/release ")).toBe("/release "); + }); + + test("edited text past the hint is real content, never stripped", () => { + expect(stripUneditedSlashHint(catalog, "/release extra")).toBeNull(); + expect(stripUneditedSlashHint(catalog, "/release abc")).toBeNull(); + }); + + test("subcommand accepts and bare bases never strip", () => { + expect(stripUneditedSlashHint(catalog, "/scale high ")).toBeNull(); + expect(stripUneditedSlashHint(catalog, "/release ")).toBeNull(); + expect(stripUneditedSlashHint(catalog, "release ")).toBeNull(); + }); + + test("unknown commands never strip", () => { + expect(stripUneditedSlashHint(catalog, "/nope ")).toBeNull(); + }); +}); diff --git a/src/tui/command-catalog.ts b/src/tui/command-catalog.ts index 43ab4774b..75392fd2d 100644 --- a/src/tui/command-catalog.ts +++ b/src/tui/command-catalog.ts @@ -11,12 +11,27 @@ import { withOriginMarker } from "../plugins/origin-marker.js"; import type { PluginOrigin } from "../trust/project-trust.js"; import { sliceToWidth, stringWidth } from "./view/height.js"; +/** Minimal subcommand shape — mirrors `SubcommandDefinition` without importing it. */ +export interface RegistrySubcommandSource { + readonly name: string; + readonly description: string; +} + /** Minimal registry shape — matches `listCommands()` entries without importing them. */ export interface RegistryCommandSource { readonly name: string; readonly description: string; /** Discovery origin of the contributing plugin, when the command has one. */ readonly origin?: PluginOrigin; + /** + * Free-form arg guidance (frontmatter `argument-hint`). Shown greyed in the + * `/` popup row and spliced into the prompt as selected text on Tab so + * typing replaces it. `undefined` means the command takes no params and + * keeps today's bare `/id` accept behavior. + */ + readonly argumentHint?: string; + /** Named subcommands (frontmatter `subcommands`); offered as arg rows. */ + readonly subcommands?: readonly RegistrySubcommandSource[]; } /** One entry in the `/` command list: registry command name + display label. */ @@ -27,22 +42,127 @@ export interface PaletteCommand { readonly keywords?: readonly string[]; /** Registry description for the overlay zone; rows stay name-only. */ readonly description?: string; + /** Carried arg guidance; rendered after the name in `/` rows. */ + readonly argumentHint?: string; + /** Carried subcommands; offered as second-stage arg rows. */ + readonly subcommands?: readonly RegistrySubcommandSource[]; + /** + * Render suffix for `/` rows (`/yolo [on|off|toggle]`); `label` itself + * stays `/name` so the name-prefix filter is unchanged. + */ + readonly hintLabel?: string; + /** Second-stage arg rows only: the owning slash command id. */ + readonly parentId?: string; + /** Second-stage rows only: text spliced after `/id ` on accept. */ + readonly argValue?: string; + /** Second-stage rows only: subcommand choice vs hint reminder. */ + readonly argKind?: "subcommand" | "hint"; } /** Map registry command definitions to `/` list items. */ export function commandItemsFromRegistry( commands: readonly RegistryCommandSource[], ): PaletteCommand[] { - return commands.map((c) => ({ - id: c.name, - // Name-only rows keep the slash popup scannable; description is a - // dedicated field for the overlay zone and stays in keywords so typed - // filter still finds prose matches. Plugin rows carry their origin - // marker ([bundled] for bundled, origin label otherwise). - label: withOriginMarker(`/${c.name}`, c.origin), - description: c.description, - keywords: [c.name, c.description, "slash", "command"], - })); + return commands.map((c) => { + const subcommands = + c.subcommands !== undefined && c.subcommands.length > 0 + ? [...c.subcommands] + : undefined; + // Explicit hint wins; otherwise derive `[a|b]` from subcommand names so + // stage 1 still advertises that the command takes an argument. + const hintLabel = + c.argumentHint ?? + (subcommands !== undefined + ? `[${subcommands.map((s) => s.name).join("|")}]` + : undefined); + const keywords = [c.name, c.description, "slash", "command"]; + if (c.argumentHint !== undefined) keywords.push(c.argumentHint); + if (subcommands !== undefined) { + for (const s of subcommands) keywords.push(s.name, s.description); + } + return { + id: c.name, + // Name-only rows keep the slash popup scannable; description is a + // dedicated field for the overlay zone and stays in keywords so typed + // filter still finds prose matches. Plugin rows carry their origin + // marker ([bundled] for bundled, origin label otherwise). + label: withOriginMarker(`/${c.name}`, c.origin), + description: c.description, + keywords, + ...(c.argumentHint !== undefined ? { argumentHint: c.argumentHint } : {}), + ...(subcommands !== undefined ? { subcommands } : {}), + ...(hintLabel !== undefined ? { hintLabel } : {}), + }; + }); +} + +/** + * Second-stage arg rows for a command with params: subcommand choices + * prefix-filtered by the typed arg, or the free-form hint as a single + * reminder row while the arg is still empty. Pure; the popup branch in + * `openSlashCommands` reuses these with in-place refresh. + */ +export function slashArgItems( + cmd: PaletteCommand, + arg: string, +): PaletteCommand[] { + const q = arg.trim().toLowerCase(); + const subcommands = cmd.subcommands ?? []; + if (subcommands.length > 0) { + return subcommands + .filter((s) => s.name.toLowerCase().startsWith(q)) + .map((s) => ({ + id: `${cmd.id}:${s.name}`, + label: s.name, + keywords: [s.name, s.description], + description: s.description, + parentId: cmd.id, + argValue: s.name, + argKind: "subcommand" as const, + })); + } + if (cmd.argumentHint === undefined || q.length > 0) return []; + return [ + { + id: `${cmd.id}:hint`, + label: cmd.argumentHint, + keywords: [cmd.argumentHint], + ...(cmd.description !== undefined + ? { description: cmd.description } + : {}), + parentId: cmd.id, + argValue: cmd.argumentHint, + argKind: "hint" as const, + }, + ]; +} + +/** + * Bare base text (`/id `) when a value about to be submitted is still exactly + * a Tab-accepted free-form hint: the hint lands as selected text so typing + * replaces it, but submitting it untouched would send the placeholder as the + * argument. The guard is deliberately shape-only, not selection-gated — the + * untouched selection is trivially lost without editing (one arrow key), and + * after that a bare Enter would still submit the literal. An exact `/id + * ` match is always the placeholder no matter how the selection was + * lost: real arguments never equal the hint byte-for-byte. Pure; + * `submitPrompt` applies the result. Returns null when the value is real + * content (subcommand accepts, typed text, unknown commands, bare bases). + */ +export function stripUneditedSlashHint( + catalog: readonly PaletteCommand[], + value: string, +): string | null { + if (!value.startsWith("/")) return null; + const space = value.indexOf(" "); + if (space < 0) return null; + const tail = value.slice(space + 1); + if (tail.length === 0) return null; + const cmd = catalog.find( + (c) => c.id.toLowerCase() === value.slice(1, space).toLowerCase(), + ); + if (cmd?.argumentHint === undefined || cmd.argumentHint !== tail) return null; + return value.slice(0, space + 1); } /** @@ -62,11 +182,13 @@ export function filterPaletteCommands( }); } -/** Labels for the shared list viewport. */ +/** Labels for the shared list viewport. Hint suffixes ride along on `/` rows. */ export function paletteLabels( - commands: readonly PaletteCommand[], + commands: readonly Pick[], ): readonly string[] { - return commands.map((c) => c.label); + return commands.map((c) => + c.hintLabel !== undefined ? `${c.label} ${c.hintLabel}` : c.label, + ); } function fitLabel(label: string, width: number): string { diff --git a/src/tui/commands/registry.ts b/src/tui/commands/registry.ts index a1a2c1723..5003ec895 100644 --- a/src/tui/commands/registry.ts +++ b/src/tui/commands/registry.ts @@ -78,8 +78,8 @@ export interface CommandDefinition { pluginOrigin?: PluginOrigin; /** * Claude Code–compatible free-form arg guidance (frontmatter `argument-hint`). - * Shown greyed next to the command and after `/cmd ` until the operator types. - * Never inserted into the prompt on Tab. + * Shown greyed next to the command in the `/` popup; on Tab it is spliced + * into the prompt after `/cmd ` as selected text so typing replaces it. */ argumentHint?: string; subcommands?: readonly SubcommandDefinition[]; diff --git a/src/tui/overlay-view.ts b/src/tui/overlay-view.ts index 36b5e798b..d198df32f 100644 --- a/src/tui/overlay-view.ts +++ b/src/tui/overlay-view.ts @@ -4,7 +4,11 @@ import { type RenderContext, } from "@opentui/core"; import { middleEllipsis } from "./command-display.js"; -import { formatPaletteRows, type PaletteCommand } from "./command-catalog.js"; +import { + formatPaletteRows, + paletteLabels, + type PaletteCommand, +} from "./command-catalog.js"; import type { OverlayList, ItemDescription, @@ -33,7 +37,10 @@ export interface OverlayListPresentation { readonly kind: PrimaryOverlayKind | null; readonly items: readonly string[]; readonly itemIds?: readonly string[]; - readonly paletteCommands: readonly Pick[]; + readonly paletteCommands: readonly Pick< + PaletteCommand, + "label" | "hintLabel" + >[]; readonly list: OverlayList | null; readonly bodyLines: readonly string[]; readonly bodyFgs: readonly string[]; @@ -313,9 +320,13 @@ export function createOverlayView(ctx: RenderContext) { list: OverlayList, contentWidth: number, ): void { + // Hint suffixes (`/yolo [on|off]`) paint as plain row text: the select + // widget takes unstyled string options, so a dimmed suffix would need a + // custom row renderer. Unselected rows already paint dim, which carries + // most of the "greyed hint" read. const interior = overlayInteriorWidth(contentWidth); const lines = formatPaletteRows( - commands.map((command) => command.label), + paletteLabels(commands), Math.max(4, interior - 1), ); list.setHeight(list.height, 1); diff --git a/src/tui/prompt-slash-exit.test.ts b/src/tui/prompt-slash-exit.test.ts index a536efd7f..7b3f91c99 100644 --- a/src/tui/prompt-slash-exit.test.ts +++ b/src/tui/prompt-slash-exit.test.ts @@ -30,6 +30,23 @@ const CATALOG: readonly PaletteCommand[] = [ }, { id: "mcp", label: "/mcp" }, { id: "compact", label: "/compact" }, + { + id: "release", + label: "/release", + description: "Tag a build", + keywords: ["release", "tag", "slash"], + argumentHint: "", + }, + { + id: "scale", + label: "/scale", + description: "Raise the bar", + keywords: ["scale", "slash"], + subcommands: [ + { name: "high", description: "First rung" }, + { name: "low", description: "Last rung" }, + ], + }, ]; interface Ctx { @@ -199,6 +216,87 @@ describe("slash command popup", () => { expect(frame()).toContain("(no matches)"); }); }); + + test("Tab-accepting a hint then Enter dispatches bare, not the placeholder", async () => { + await withShell(async ({ shell, press }) => { + for (const ch of "/release") press(ch); + expect(isSlashPopupOpen(shell)).toBe(true); + press("Tab"); + // A param command completes the name and opens the hint row as stage two. + expect(shell.prompt.value).toBe("/release "); + expect(isSlashPopupOpen(shell)).toBe(true); + press("Tab"); + // The hint lands as text so it stays visible, carrying a selection. + expect(shell.prompt.value).toBe("/release "); + expect(isSlashPopupOpen(shell)).toBe(false); + expect(shell.prompt.hasSelection()).toBe(true); + // One arrow key drops the untouched selection without editing — collapse + // it the same way — and the shape is still the placeholder. Submitting + // it must dispatch the bare command, not the literal placeholder. + shell.prompt.setSelection( + shell.prompt.value.length, + shell.prompt.value.length, + ); + expect(shell.prompt.hasSelection()).toBe(false); + // Past the un-bracketed-paste burst window, so Enter sends. + await Bun.sleep(30); + press("Enter"); + expect(shell.prompt.value).toBe(""); + expect(shell.sentHistory.sent).toEqual(["/release"]); + }); + }); + + test("a second arg token dismisses the popup instead of holding it dead", async () => { + await withShell(async ({ shell, press }) => { + for (const ch of "/scale high") press(ch); + // A single-token tail still filters the subcommand rows in place. + expect(isSlashPopupOpen(shell)).toBe(true); + expect(shell.paletteCommands.map((c) => c.id)).toEqual(["scale:high"]); + for (const ch of " --force") press(ch); + // The popup's filtering job is over — real arguments are being typed — + // so it dismisses and leaves the prompt alone. + expect(shell.prompt.value).toBe("/scale high --force"); + expect(isSlashPopupOpen(shell)).toBe(false); + expect(shell.overlayList).toBeNull(); + }); + }); + + test("backspace out of the arg stage returns to the name stage", async () => { + await withShell(async ({ shell, press }) => { + for (const ch of "/release") press(ch); + press("Tab"); + expect(shell.prompt.value).toBe("/release "); + expect(isSlashPopupOpen(shell)).toBe(true); + press("Backspace"); + expect(shell.prompt.value).toBe("/release"); + expect(isSlashPopupOpen(shell)).toBe(true); + expect(shell.paletteCommands.map((c) => c.id)).toEqual(["release"]); + }); + }); + + test("Esc clears the popup so a later cycle opens fresh", async () => { + await withShell(async ({ shell, dispatched, press, render }) => { + for (const ch of "/release") press(ch); + press("Tab"); + expect(isSlashPopupOpen(shell)).toBe(true); + press("Escape"); + await render(); + await Bun.sleep(60); + expect(isSlashPopupOpen(shell)).toBe(false); + expect(shell.overlayList).toBeNull(); + // The typed text survives; submitting it still sends cleanly. `release` + // is a catalog fixture, not a registry command, so the send — not a + // registry dispatch — is the signal. + press("Enter"); + expect(shell.prompt.value).toBe(""); + expect(shell.sentHistory.sent).toEqual(["/release"]); + // No stale popup entry survives: a fresh cycle claims keys and dispatches. + for (const ch of "/model") press(ch); + expect(isSlashPopupOpen(shell)).toBe(true); + press("Enter"); + expect(dispatched).toEqual(["model"]); + }); + }); }); describe("Ctrl+C exit", () => { diff --git a/src/tui/runner/index.ts b/src/tui/runner/index.ts index f013f9c46..4594f40a7 100644 --- a/src/tui/runner/index.ts +++ b/src/tui/runner/index.ts @@ -163,6 +163,13 @@ export async function runTUI(initialConfig: Config): Promise { name: c.name, description: c.description, ...(c.pluginOrigin !== undefined ? { origin: c.pluginOrigin } : {}), + // Carried so the `/` popup can show hints and offer arg rows. + ...(c.argumentHint !== undefined + ? { argumentHint: c.argumentHint } + : {}), + ...(c.subcommands !== undefined && c.subcommands.length > 0 + ? { subcommands: c.subcommands } + : {}), })), onCommand: (name) => { const route = routeSubmission(name); diff --git a/src/tui/shell/internals.ts b/src/tui/shell/internals.ts index 023b635fe..c1e6c7d73 100644 --- a/src/tui/shell/internals.ts +++ b/src/tui/shell/internals.ts @@ -1052,6 +1052,31 @@ export function slashPopupQuery(shell: AppShell): string | null { return /\s/.test(head) ? null : head; } +/** Second-stage arg parse: `/name` + whitespace + typed arg tail. */ +export interface SlashArgQuery { + /** Command name after the leading `/` (before the first whitespace). */ + readonly name: string; + /** Typed argument tail after the first whitespace run (may be empty). */ + readonly arg: string; +} + +/** + * Arg-stage parse for the `/` popup. Null when the prompt is not `/name` + * followed by whitespace — the `slashPopupQuery` null-on-whitespace contract + * is unchanged; this is the separate second stage built on top of it. + */ +export function slashArgQuery(shell: AppShell): SlashArgQuery | null { + const value = shell.prompt.value; + if (!value.startsWith("/")) return null; + const head = value.slice(1); + const gap = /\s/.exec(head); + if (gap === null) return null; + return { + name: head.slice(0, gap.index), + arg: head.slice(gap.index + gap[0].length), + }; +} + export function shellInternals(shell: AppShell): ShellInternals | undefined { return internals.get(shell); } diff --git a/src/tui/shell/keys.ts b/src/tui/shell/keys.ts index d78c1af64..d711305bb 100644 --- a/src/tui/shell/keys.ts +++ b/src/tui/shell/keys.ts @@ -49,6 +49,7 @@ import { submitPrompt, } from "./prompt.js"; import { + closeSlashPopup, handleListFilterKey, handleMentionPopupKey, handlePaletteFilterKey, @@ -288,7 +289,15 @@ export function createShellKeyHandlers( if (shell.overlayList) { key.preventDefault(); abortOverlayHostReservations(shell); - closeInsetOverlay(shell); + // closeSlashPopup owns the slash entry's cleanup and closes the inset + // overlay itself; a second closeInsetOverlay after it would idle-notify + // twice and kill a gate the first notify drains. Non-slash overlays + // carry no entry, so closeSlashPopup no-ops on them (same list still + // open) and the shared close handles those. + const list = shell.overlayList; + closeSlashPopup(shell); + if (list !== null && shell.overlayList === list) + closeInsetOverlay(shell); return; } if (shellInternals(shell)?.overlayHostReservations) { diff --git a/src/tui/shell/overlay-host.ts b/src/tui/shell/overlay-host.ts index 3d85873e2..627f02d74 100644 --- a/src/tui/shell/overlay-host.ts +++ b/src/tui/shell/overlay-host.ts @@ -394,7 +394,10 @@ export function handleOverlayAnswerKey( } /** Close overlay/palette if open; restore prior focus (or prior overlay under palette). */ -export function closeInsetOverlay(shell: AppShell): void { +export function closeInsetOverlay( + shell: AppShell, + opts?: { readonly suppressIdleNotify?: boolean }, +): void { if (!shell.overlayList) return; // Esc (or any other dismiss) must also drop the `/` and `@` popups' key claim. slashPopups.delete(shell); @@ -477,7 +480,14 @@ export function closeInsetOverlay(shell: AppShell): void { relayout(shell, { overlayMode: "closed" }); applyFocus(shell); if (bag) bag.overlayGeneration += 1; - if (isOverlayHostIdle(shell)) notifyOverlayClosed(shell); + // Keystroke-driven `/` re-parse dismisses (empty arg stage, multi-token + // tail) suppress this: the operator is mid-word, and the notify is what a + // queued permission/operator gate waits on to drain. The gate stays queued + // until the next genuine dismiss or submit. Deferred command surfaces still + // flush below — only the gate-draining notify is suppressed. + if (!opts?.suppressIdleNotify && isOverlayHostIdle(shell)) { + notifyOverlayClosed(shell); + } try { onDispose?.(); onCancel?.(); diff --git a/src/tui/shell/palette.ts b/src/tui/shell/palette.ts index 87e1b5c42..bec2fa648 100644 --- a/src/tui/shell/palette.ts +++ b/src/tui/shell/palette.ts @@ -9,6 +9,7 @@ import { spliceMentionCompletion } from "../prompt-attachments.js"; import { filterPaletteCommands, paletteLabels, + slashArgItems, type PaletteCommand, } from "../command-catalog.js"; import { helpItems } from "../keybindings.js"; @@ -31,6 +32,7 @@ import { type OverlaySelection, shellInternals, shellMentionSource, + slashArgQuery, slashPopupQuery, slashPopups, } from "./internals.js"; @@ -502,10 +504,13 @@ export function handleMentionPopupKey(shell: AppShell, key: KeyEvent): boolean { return true; } -export function closeSlashPopup(shell: AppShell): void { +export function closeSlashPopup( + shell: AppShell, + opts?: { readonly suppressIdleNotify?: boolean }, +): void { if (!slashPopups.has(shell)) return; slashPopups.delete(shell); - if (shell.overlayList) closeInsetOverlay(shell); + if (shell.overlayList) closeInsetOverlay(shell, opts); } /** @@ -515,44 +520,101 @@ export function closeSlashPopup(shell: AppShell): void { */ export function openSlashCommands(shell: AppShell): boolean { const query = slashPopupQuery(shell); - if (query === null) { - closeSlashPopup(shell); - return false; - } - // Name-prefix, not the palette's fuzzy label match: at the prompt the - // operator is typing the command they already mean. - const q = query.toLowerCase(); - const matches = resolvePaletteCatalog(shell).filter((cmd) => - cmd.id.toLowerCase().startsWith(q), - ); + if (query !== null) { + // Name-prefix, not the palette's fuzzy label match: at the prompt the + // operator is typing the command they already mean. + const q = query.toLowerCase(); + const matches = resolvePaletteCatalog(shell).filter((cmd) => + cmd.id.toLowerCase().startsWith(q), + ); - // Every keystroke lands here while the popup is already open. Closing and - // reopening released the overlay host between the two calls (closeSlashPopup - // routes through closeInsetOverlay, which idle-notifies) — long enough for a - // queued permission/operator gate to drain onto it. Refreshing the open - // palette in place never releases the host, so a queued gate has nothing to - // drain into. priorOverlay stacking is untouched here (it is only ever - // written by openListOverlay's stack-on-open path), so a palette stacked - // over a prior overlay keeps that snapshot across the refresh. - // - // A typo that zeroes the matches must not fall through to closeSlashPopup - // while the popup is already open — that closes through the same idle-notify - // path and drains a queued gate mid-filter. Instead this refreshes in place - // to a "(no matches)" row, same as the general palette does, and holds the - // host until a real dismiss (deleting the `/`, Esc, accept) or a backspace - // that restores matches. - if (isSlashPopupOpen(shell) && shell.overlayKind === "palette") { - refreshSlashPopupInPlace(shell, matches); + // Every keystroke lands here while the popup is already open. Closing and + // reopening released the overlay host between the two calls (closeSlashPopup + // routes through closeInsetOverlay, which idle-notifies) — long enough for a + // queued permission/operator gate to drain onto it. Refreshing the open + // palette in place never releases the host, so a queued gate has nothing to + // drain into. priorOverlay stacking is untouched here (it is only ever + // written by openListOverlay's stack-on-open path), so a palette stacked + // over a prior overlay keeps that snapshot across the refresh. + // + // A typo that zeroes the matches must not fall through to closeSlashPopup + // while the popup is already open — that closes through the same idle-notify + // path and drains a queued gate mid-filter. Instead this refreshes in place + // to a "(no matches)" row, same as the general palette does, and holds the + // host until a real dismiss (deleting the `/`, Esc, accept) or a backspace + // that restores matches. + if (isSlashPopupOpen(shell) && shell.overlayKind === "palette") { + refreshSlashPopupInPlace(shell, matches); + return true; + } + + if (matches.length === 0) { + closeSlashPopup(shell); + return false; + } + + closeSlashPopup(shell); + openPalette(shell, { catalog: matches, title: "commands · /" }); + slashPopups.add(shell); return true; } + return openSlashArgRows(shell); +} - if (matches.length === 0) { +/** + * Second stage: `/name` is settled (whitespace follows) and the tail filters + * the command's arg rows — subcommand choices by name prefix, or the + * free-form hint as a single reminder row while the tail is still empty. + * Unknown names and arg-less commands (`/mcp `) dismiss the popup, keeping + * today's dismiss for commands that take no params — but silently: this runs + * on keystroke re-parses while the operator is mid-word, and the default + * idle-notify would drain a queued permission/operator gate onto the host. + */ +function openSlashArgRows(shell: AppShell): boolean { + const argQuery = slashArgQuery(shell); + if (argQuery === null) { closeSlashPopup(shell); return false; } - + // Two or more tokens past the name (`/deploy prod --force`): the popup's + // filtering job is over — subcommand rows only ever match a single prefix + // token and a hint row only shows on the empty tail — so dismiss instead of + // holding a dead "(no matches)" while real arguments are typed. A trailing + // space after one token (`/deploy prod `) still filters; only genuinely + // multi-token tails dismiss. + if (/\s/.test(argQuery.arg.trim())) { + closeSlashPopup(shell, { suppressIdleNotify: true }); + return false; + } + const cmd = resolvePaletteCatalog(shell).find( + (c) => c.id.toLowerCase() === argQuery.name.toLowerCase(), + ); + const rows = cmd !== undefined ? slashArgItems(cmd, argQuery.arg) : []; + if (rows.length === 0) { + // Unlike the name stage, an empty arg stage usually means "nothing to + // offer" (arg-less command, hint already being typed over) rather than a + // recoverable typo, so dismiss instead of holding a dead "(no matches)" + // while free-form args are typed. Subcommand filtering is the exception: + // a zeroed single-token prefix is still recoverable by typing, so hold + // the host exactly like the name stage does. + const filterable = (cmd?.subcommands?.length ?? 0) > 0; + if ( + filterable && + isSlashPopupOpen(shell) && + shell.overlayKind === "palette" + ) { + refreshSlashPopupInPlace(shell, rows); + return true; + } + closeSlashPopup(shell, { suppressIdleNotify: true }); + return false; + } + if (isSlashPopupOpen(shell) && shell.overlayKind === "palette") { + refreshSlashPopupInPlace(shell, rows); + return true; + } closeSlashPopup(shell); - openPalette(shell, { catalog: matches, title: "commands · /" }); + openPalette(shell, { catalog: rows, title: "commands · /" }); slashPopups.add(shell); return true; } @@ -596,23 +658,80 @@ export function setPromptText(shell: AppShell, value: string): void { shell.sentHistory = sentHistoryOnEdit(shell.sentHistory); } +/** + * setPromptText plus a selected span. Choice A from the popup-params notes: + * the hint lands as real selected text (not ghost paint) because the + * textarea already owns selection — setSelection/insertText/deleteSelection + * all exist on the widget (see the yank-rotation path in keys.ts) — and the + * next keystroke's insert replaces the span, so typing over the hint works + * with no extra bookkeeping. Ghost paint-only would need a custom prompt-box + * renderer with no precedent in the tree. + */ +function setPromptTextWithSelection( + shell: AppShell, + value: string, + start: number, + end: number, +): void { + setPromptText(shell, value); + shell.prompt.setSelection(start, end); +} + +/** + * Complete a second-stage arg row into the prompt. Arg rows are fragments, + * not runnable commands, so they never dispatch: a subcommand completes to + * `/parent sub ` with the caret parked past the space, while a free-form + * hint completes to selected text (choice A above) so typing replaces it. + */ +function completeSlashArgRow(shell: AppShell, row: PaletteCommand): void { + const base = `/${row.parentId ?? row.id} `; + if (row.argKind === "hint" && row.argValue !== undefined) { + setPromptTextWithSelection( + shell, + `${base}${row.argValue}`, + base.length, + base.length + row.argValue.length, + ); + return; + } + setPromptText(shell, `${base}${row.argValue ?? ""} `); +} + /** * Keys the `/` popup claims while open. Returns true when handled. * - * Enter runs the highlighted command with no arguments; Tab instead completes - * the name and leaves the popup so arguments can be typed — a command that - * needs arguments should not fire bare just because its name matched. + * Enter runs the highlighted command with no arguments (bare dispatch, even + * for param commands — the typed `/name` already says what to run, and an + * untouched Tab-accepted hint is stripped at submit so it never arrives as a + * literal argument). Tab instead completes the name so arguments can be + * typed. Commands carrying an + * argumentHint or subcommands complete to `/id ` and open the second-stage + * arg rows; param-less commands keep the bare `/id ` accept and close. */ export function handleSlashPopupKey(shell: AppShell, key: KeyEvent): boolean { if (!isSlashPopupOpen(shell) || shell.overlayList === null) return false; if (key.name === "backspace" && !key.ctrl && !key.meta && !key.option) { - setPromptText(shell, shell.prompt.value.slice(0, -1)); + // A Tab-accepted hint sits selected; backspace clears the span itself so + // the stage re-parse below lands back on the arg rows, not on truncated + // text with a stale selection. + if (shell.prompt.hasSelection()) { + shell.prompt.deleteSelection(); + shell.sentHistory = sentHistoryOnEdit(shell.sentHistory); + } else { + setPromptText(shell, shell.prompt.value.slice(0, -1)); + } openSlashCommands(shell); return true; } const active = shell.paletteCommands[shell.overlayList.activeIndex]; + const activeArgRow = + active !== undefined && + active.parentId !== undefined && + active.argValue !== undefined + ? active + : undefined; if ( key.name === "tab" && @@ -621,7 +740,27 @@ export function handleSlashPopupKey(shell: AppShell, key: KeyEvent): boolean { !key.meta && !key.option ) { - if (active) setPromptText(shell, `/${active.id} `); + if (activeArgRow !== undefined) { + completeSlashArgRow(shell, activeArgRow); + closeSlashPopup(shell); + return true; + } + if (active === undefined) { + closeSlashPopup(shell); + return true; + } + if ( + active.argumentHint !== undefined || + (active.subcommands !== undefined && active.subcommands.length > 0) + ) { + // Param command: complete the name and open the second stage so the + // hint/subcommand rows stay visible while args are typed. Param-less + // commands keep today's bare `/id ` accept below. + setPromptText(shell, `/${active.id} `); + openSlashCommands(shell); + return true; + } + setPromptText(shell, `/${active.id} `); closeSlashPopup(shell); return true; } @@ -634,6 +773,11 @@ export function handleSlashPopupKey(shell: AppShell, key: KeyEvent): boolean { ) { // Genuine dismiss (zero matches) still notifies immediately so a queued // gate can drain. Accept-with-match keeps the host until dispatch settles. + if (activeArgRow !== undefined) { + completeSlashArgRow(shell, activeArgRow); + closeSlashPopup(shell); + return true; + } if (!active) { closeSlashPopup(shell); return true; @@ -660,9 +804,23 @@ export function handleSlashPopupKey(shell: AppShell, key: KeyEvent): boolean { !key.option; if (!printable) return false; - setPromptText(shell, shell.prompt.value + seq); - // Whitespace ends the name; keep the popup out of the way while args are typed. - if (/\s/.test(seq)) closeSlashPopup(shell); - else openSlashCommands(shell); + // A selected span (manual select, or a just-completed hint row whose popup + // stayed open) is replaced by the typed character; otherwise append as + // before. The no-selection path is byte-for-byte today's behavior. + if (shell.prompt.hasSelection()) { + shell.prompt.deleteSelection(); + shell.prompt.insertText(seq); + shell.sentHistory = sentHistoryOnEdit(shell.sentHistory); + } else { + setPromptText(shell, shell.prompt.value + seq); + } + if (/\s/.test(seq)) { + // Whitespace settles the name; re-parse into the second stage instead of + // closing so subcommand/hint rows offer themselves while args are typed. + // openSlashArgRows closes itself for unknown names and arg-less commands. + openSlashCommands(shell); + return true; + } + openSlashCommands(shell); return true; } diff --git a/src/tui/shell/prompt.ts b/src/tui/shell/prompt.ts index 1054e5ddf..7016d50fc 100644 --- a/src/tui/shell/prompt.ts +++ b/src/tui/shell/prompt.ts @@ -14,7 +14,11 @@ import { userRowText, type PendingImageAttachment, } from "../image-attachments.js"; -import { createSentHistoryBrowse } from "../sent-message-history.js"; +import { + createSentHistoryBrowse, + sentHistoryOnEdit, +} from "../sent-message-history.js"; +import { stripUneditedSlashHint } from "../command-catalog.js"; import { resolvePromptHighlightSpans, resolvePromptRecognitionMatcher, @@ -48,6 +52,7 @@ import { paintPromptBorder, setStatusFlash, } from "./chrome.js"; +import { resolvePaletteCatalog } from "./palette.js"; /** Queue an image for the next submit and reflect it on the notice row. */ export function addPendingAttachment( @@ -282,6 +287,20 @@ export function submitPrompt( shell: AppShell, kind: "queue" | "steer" | "reinject" = "queue", ): void { + // A Tab-accepted free-form hint sits in the prompt as placeholder text; bare + // Enter must dispatch the command, not submit the placeholder literal as + // its argument. The strip is shape-only: the untouched selection is lost to + // a single arrow key, so only the exact `/id ` match strips — real + // arguments never equal the hint byte-for-byte. + const hintBase = stripUneditedSlashHint( + resolvePaletteCatalog(shell), + shell.prompt.value, + ); + if (hintBase !== null) { + shell.prompt.value = hintBase; + shell.prompt.cursorOffset = hintBase.length; + shell.sentHistory = sentHistoryOnEdit(shell.sentHistory); + } const text = shell.prompt.value; const t = text.trim(); const attachments = shell.pendingAttachments; diff --git a/src/tui/slash-popup-gate.test.ts b/src/tui/slash-popup-gate.test.ts index 1c79a56c0..dd70e7be4 100644 --- a/src/tui/slash-popup-gate.test.ts +++ b/src/tui/slash-popup-gate.test.ts @@ -158,7 +158,7 @@ function settingsOnCommand( describe("/ popup keeps a queued gate queued across a filter refresh", () => { test("filter keystroke while a gate is queued", async () => { - await withShell(async ({ shell, press }) => { + await withShell(async ({ shell, press, render }) => { const emitter = new EventEmitter(); const dispose = wireGates(emitter, shell); // The host going idle (onOverlayClosed) is what the queued gate waits @@ -227,9 +227,11 @@ describe("/ popup keeps a queued gate queued across a filter refresh", () => { expect(resolved).toBeUndefined(); expect(closedCount).toBe(0); - // A true dismiss still drains the queue as before. + // A true dismiss still drains the queue as before. A bare ESC is held + // by the input parser until it cannot be a sequence, so render + hold. press("Escape"); - await Bun.sleep(20); + await render(); + await Bun.sleep(60); expect(shell.overlayKind).toBe("permissions"); expect(resolved).toBeUndefined(); expect(closedCount).toBe(1); @@ -304,6 +306,44 @@ describe("/ popup keeps a queued gate queued across a filter refresh", () => { } }); }); + + test("space into an arg-less command dismisses without draining a queued gate", async () => { + await withShell(async ({ shell, press }) => { + const emitter = new EventEmitter(); + const dispose = wireGates(emitter, shell); + let closedCount = 0; + const disposeClosedSpy = onOverlayClosed(shell, () => { + closedCount++; + }); + try { + typePrompt(press, "/mcp"); + expect(isSlashPopupOpen(shell)).toBe(true); + + let resolved: unknown; + emitPermissionGate(emitter, (outcome) => { + resolved = outcome; + }); + expect(shell.overlayKind).toBe("palette"); + expect(resolved).toBeUndefined(); + expect(closedCount).toBe(0); + + // `/mcp ` takes no params, so the popup dismisses — but silently: the + // operator is mid-word, and the idle-notify is what the queued gate + // waits on to drain. It stays queued behind the idle host. + press(" "); + expect(shell.prompt.value).toBe("/mcp "); + expect(isSlashPopupOpen(shell)).toBe(false); + + await Bun.sleep(20); + expect(shell.overlayKind).not.toBe("permissions"); + expect(resolved).toBeUndefined(); + expect(closedCount).toBe(0); + } finally { + disposeClosedSpy(); + dispose(); + } + }); + }); }); describe("slash/palette accept holds the host until dispatch settles", () => {