Skip to content

Commit 8287173

Browse files
feat(tui): show suggested slash parameters in the popup (#1163)
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.
1 parent 15aea04 commit 8287173

12 files changed

Lines changed: 653 additions & 66 deletions

‎src/tui/command-catalog.test.ts‎

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ import {
44
filterPaletteCommands,
55
formatPaletteRows,
66
paletteLabels,
7+
slashArgItems,
8+
stripUneditedSlashHint,
9+
type PaletteCommand,
710
} from "./command-catalog";
811
import { BUNDLED_PLUGIN_MARKER } from "../plugins/origin-marker.js";
912
import { stringWidth } from "./view/height.js";
@@ -120,3 +123,88 @@ describe("command origin markers", () => {
120123
}
121124
});
122125
});
126+
127+
describe("slashArgItems", () => {
128+
const hintCmd: PaletteCommand = {
129+
id: "release",
130+
label: "/release",
131+
argumentHint: "<id>",
132+
};
133+
const subCmd: PaletteCommand = {
134+
id: "scale",
135+
label: "/scale",
136+
subcommands: [
137+
{ name: "high", description: "Scale high" },
138+
{ name: "low", description: "Scale low" },
139+
],
140+
};
141+
const bareCmd: PaletteCommand = { id: "mcp", label: "/mcp" };
142+
143+
test("hint command offers the hint as a single row on the empty tail", () => {
144+
const rows = slashArgItems(hintCmd, "");
145+
expect(rows).toHaveLength(1);
146+
expect(rows[0]).toMatchObject({
147+
parentId: "release",
148+
argValue: "<id>",
149+
argKind: "hint",
150+
});
151+
});
152+
153+
test("hint command offers nothing once the tail is being typed over", () => {
154+
expect(slashArgItems(hintCmd, "abc")).toEqual([]);
155+
});
156+
157+
test("arg-less command offers no rows", () => {
158+
expect(slashArgItems(bareCmd, "")).toEqual([]);
159+
});
160+
161+
test("subcommands filter by name prefix", () => {
162+
expect(slashArgItems(subCmd, "").map((r) => r.argValue)).toEqual([
163+
"high",
164+
"low",
165+
]);
166+
expect(slashArgItems(subCmd, "h").map((r) => r.argValue)).toEqual(["high"]);
167+
expect(slashArgItems(subCmd, "zzz")).toEqual([]);
168+
});
169+
170+
test("multi-token tail matches no rows — the popup dismisses instead", () => {
171+
// The dismiss decision itself lives in openSlashArgRows; the catalog half
172+
// is that a second token can never prefix-match a single subcommand name
173+
// or an empty-tail hint.
174+
expect(slashArgItems(subCmd, "high --force")).toEqual([]);
175+
expect(slashArgItems(hintCmd, "abc def")).toEqual([]);
176+
});
177+
});
178+
179+
describe("stripUneditedSlashHint", () => {
180+
const catalog: readonly PaletteCommand[] = [
181+
{ id: "release", label: "/release", argumentHint: "<id>" },
182+
{
183+
id: "scale",
184+
label: "/scale",
185+
subcommands: [{ name: "high", description: "Scale high" }],
186+
},
187+
{ id: "mcp", label: "/mcp" },
188+
];
189+
190+
test("exact untouched hint strips to the bare base, selection or not", () => {
191+
// Selection state is irrelevant: an arrow key drops the untouched
192+
// selection without editing, and the shape is still the placeholder.
193+
expect(stripUneditedSlashHint(catalog, "/release <id>")).toBe("/release ");
194+
});
195+
196+
test("edited text past the hint is real content, never stripped", () => {
197+
expect(stripUneditedSlashHint(catalog, "/release <id> extra")).toBeNull();
198+
expect(stripUneditedSlashHint(catalog, "/release abc")).toBeNull();
199+
});
200+
201+
test("subcommand accepts and bare bases never strip", () => {
202+
expect(stripUneditedSlashHint(catalog, "/scale high ")).toBeNull();
203+
expect(stripUneditedSlashHint(catalog, "/release ")).toBeNull();
204+
expect(stripUneditedSlashHint(catalog, "release <id>")).toBeNull();
205+
});
206+
207+
test("unknown commands never strip", () => {
208+
expect(stripUneditedSlashHint(catalog, "/nope <id>")).toBeNull();
209+
});
210+
});

‎src/tui/command-catalog.ts‎

Lines changed: 135 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,27 @@ import { withOriginMarker } from "../plugins/origin-marker.js";
1111
import type { PluginOrigin } from "../trust/project-trust.js";
1212
import { sliceToWidth, stringWidth } from "./view/height.js";
1313

14+
/** Minimal subcommand shape — mirrors `SubcommandDefinition` without importing it. */
15+
export interface RegistrySubcommandSource {
16+
readonly name: string;
17+
readonly description: string;
18+
}
19+
1420
/** Minimal registry shape — matches `listCommands()` entries without importing them. */
1521
export interface RegistryCommandSource {
1622
readonly name: string;
1723
readonly description: string;
1824
/** Discovery origin of the contributing plugin, when the command has one. */
1925
readonly origin?: PluginOrigin;
26+
/**
27+
* Free-form arg guidance (frontmatter `argument-hint`). Shown greyed in the
28+
* `/` popup row and spliced into the prompt as selected text on Tab so
29+
* typing replaces it. `undefined` means the command takes no params and
30+
* keeps today's bare `/id` accept behavior.
31+
*/
32+
readonly argumentHint?: string;
33+
/** Named subcommands (frontmatter `subcommands`); offered as arg rows. */
34+
readonly subcommands?: readonly RegistrySubcommandSource[];
2035
}
2136

2237
/** One entry in the `/` command list: registry command name + display label. */
@@ -27,22 +42,127 @@ export interface PaletteCommand {
2742
readonly keywords?: readonly string[];
2843
/** Registry description for the overlay zone; rows stay name-only. */
2944
readonly description?: string;
45+
/** Carried arg guidance; rendered after the name in `/` rows. */
46+
readonly argumentHint?: string;
47+
/** Carried subcommands; offered as second-stage arg rows. */
48+
readonly subcommands?: readonly RegistrySubcommandSource[];
49+
/**
50+
* Render suffix for `/` rows (`/yolo [on|off|toggle]`); `label` itself
51+
* stays `/name` so the name-prefix filter is unchanged.
52+
*/
53+
readonly hintLabel?: string;
54+
/** Second-stage arg rows only: the owning slash command id. */
55+
readonly parentId?: string;
56+
/** Second-stage rows only: text spliced after `/id ` on accept. */
57+
readonly argValue?: string;
58+
/** Second-stage rows only: subcommand choice vs hint reminder. */
59+
readonly argKind?: "subcommand" | "hint";
3060
}
3161

3262
/** Map registry command definitions to `/` list items. */
3363
export function commandItemsFromRegistry(
3464
commands: readonly RegistryCommandSource[],
3565
): PaletteCommand[] {
36-
return commands.map((c) => ({
37-
id: c.name,
38-
// Name-only rows keep the slash popup scannable; description is a
39-
// dedicated field for the overlay zone and stays in keywords so typed
40-
// filter still finds prose matches. Plugin rows carry their origin
41-
// marker ([bundled] for bundled, origin label otherwise).
42-
label: withOriginMarker(`/${c.name}`, c.origin),
43-
description: c.description,
44-
keywords: [c.name, c.description, "slash", "command"],
45-
}));
66+
return commands.map((c) => {
67+
const subcommands =
68+
c.subcommands !== undefined && c.subcommands.length > 0
69+
? [...c.subcommands]
70+
: undefined;
71+
// Explicit hint wins; otherwise derive `[a|b]` from subcommand names so
72+
// stage 1 still advertises that the command takes an argument.
73+
const hintLabel =
74+
c.argumentHint ??
75+
(subcommands !== undefined
76+
? `[${subcommands.map((s) => s.name).join("|")}]`
77+
: undefined);
78+
const keywords = [c.name, c.description, "slash", "command"];
79+
if (c.argumentHint !== undefined) keywords.push(c.argumentHint);
80+
if (subcommands !== undefined) {
81+
for (const s of subcommands) keywords.push(s.name, s.description);
82+
}
83+
return {
84+
id: c.name,
85+
// Name-only rows keep the slash popup scannable; description is a
86+
// dedicated field for the overlay zone and stays in keywords so typed
87+
// filter still finds prose matches. Plugin rows carry their origin
88+
// marker ([bundled] for bundled, origin label otherwise).
89+
label: withOriginMarker(`/${c.name}`, c.origin),
90+
description: c.description,
91+
keywords,
92+
...(c.argumentHint !== undefined ? { argumentHint: c.argumentHint } : {}),
93+
...(subcommands !== undefined ? { subcommands } : {}),
94+
...(hintLabel !== undefined ? { hintLabel } : {}),
95+
};
96+
});
97+
}
98+
99+
/**
100+
* Second-stage arg rows for a command with params: subcommand choices
101+
* prefix-filtered by the typed arg, or the free-form hint as a single
102+
* reminder row while the arg is still empty. Pure; the popup branch in
103+
* `openSlashCommands` reuses these with in-place refresh.
104+
*/
105+
export function slashArgItems(
106+
cmd: PaletteCommand,
107+
arg: string,
108+
): PaletteCommand[] {
109+
const q = arg.trim().toLowerCase();
110+
const subcommands = cmd.subcommands ?? [];
111+
if (subcommands.length > 0) {
112+
return subcommands
113+
.filter((s) => s.name.toLowerCase().startsWith(q))
114+
.map((s) => ({
115+
id: `${cmd.id}:${s.name}`,
116+
label: s.name,
117+
keywords: [s.name, s.description],
118+
description: s.description,
119+
parentId: cmd.id,
120+
argValue: s.name,
121+
argKind: "subcommand" as const,
122+
}));
123+
}
124+
if (cmd.argumentHint === undefined || q.length > 0) return [];
125+
return [
126+
{
127+
id: `${cmd.id}:hint`,
128+
label: cmd.argumentHint,
129+
keywords: [cmd.argumentHint],
130+
...(cmd.description !== undefined
131+
? { description: cmd.description }
132+
: {}),
133+
parentId: cmd.id,
134+
argValue: cmd.argumentHint,
135+
argKind: "hint" as const,
136+
},
137+
];
138+
}
139+
140+
/**
141+
* Bare base text (`/id `) when a value about to be submitted is still exactly
142+
* a Tab-accepted free-form hint: the hint lands as selected text so typing
143+
* replaces it, but submitting it untouched would send the placeholder as the
144+
* argument. The guard is deliberately shape-only, not selection-gated — the
145+
* untouched selection is trivially lost without editing (one arrow key), and
146+
* after that a bare Enter would still submit the literal. An exact `/id
147+
* <hint>` match is always the placeholder no matter how the selection was
148+
* lost: real arguments never equal the hint byte-for-byte. Pure;
149+
* `submitPrompt` applies the result. Returns null when the value is real
150+
* content (subcommand accepts, typed text, unknown commands, bare bases).
151+
*/
152+
export function stripUneditedSlashHint(
153+
catalog: readonly PaletteCommand[],
154+
value: string,
155+
): string | null {
156+
if (!value.startsWith("/")) return null;
157+
const space = value.indexOf(" ");
158+
if (space < 0) return null;
159+
const tail = value.slice(space + 1);
160+
if (tail.length === 0) return null;
161+
const cmd = catalog.find(
162+
(c) => c.id.toLowerCase() === value.slice(1, space).toLowerCase(),
163+
);
164+
if (cmd?.argumentHint === undefined || cmd.argumentHint !== tail) return null;
165+
return value.slice(0, space + 1);
46166
}
47167

48168
/**
@@ -62,11 +182,13 @@ export function filterPaletteCommands(
62182
});
63183
}
64184

65-
/** Labels for the shared list viewport. */
185+
/** Labels for the shared list viewport. Hint suffixes ride along on `/` rows. */
66186
export function paletteLabels(
67-
commands: readonly PaletteCommand[],
187+
commands: readonly Pick<PaletteCommand, "label" | "hintLabel">[],
68188
): readonly string[] {
69-
return commands.map((c) => c.label);
189+
return commands.map((c) =>
190+
c.hintLabel !== undefined ? `${c.label} ${c.hintLabel}` : c.label,
191+
);
70192
}
71193

72194
function fitLabel(label: string, width: number): string {

‎src/tui/commands/registry.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,8 +78,8 @@ export interface CommandDefinition {
7878
pluginOrigin?: PluginOrigin;
7979
/**
8080
* Claude Code–compatible free-form arg guidance (frontmatter `argument-hint`).
81-
* Shown greyed next to the command and after `/cmd ` until the operator types.
82-
* Never inserted into the prompt on Tab.
81+
* Shown greyed next to the command in the `/` popup; on Tab it is spliced
82+
* into the prompt after `/cmd ` as selected text so typing replaces it.
8383
*/
8484
argumentHint?: string;
8585
subcommands?: readonly SubcommandDefinition[];

‎src/tui/overlay-view.ts‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@ import {
44
type RenderContext,
55
} from "@opentui/core";
66
import { middleEllipsis } from "./command-display.js";
7-
import { formatPaletteRows, type PaletteCommand } from "./command-catalog.js";
7+
import {
8+
formatPaletteRows,
9+
paletteLabels,
10+
type PaletteCommand,
11+
} from "./command-catalog.js";
812
import type {
913
OverlayList,
1014
ItemDescription,
@@ -33,7 +37,10 @@ export interface OverlayListPresentation {
3337
readonly kind: PrimaryOverlayKind | null;
3438
readonly items: readonly string[];
3539
readonly itemIds?: readonly string[];
36-
readonly paletteCommands: readonly Pick<PaletteCommand, "label">[];
40+
readonly paletteCommands: readonly Pick<
41+
PaletteCommand,
42+
"label" | "hintLabel"
43+
>[];
3744
readonly list: OverlayList | null;
3845
readonly bodyLines: readonly string[];
3946
readonly bodyFgs: readonly string[];
@@ -313,9 +320,13 @@ export function createOverlayView(ctx: RenderContext) {
313320
list: OverlayList,
314321
contentWidth: number,
315322
): void {
323+
// Hint suffixes (`/yolo [on|off]`) paint as plain row text: the select
324+
// widget takes unstyled string options, so a dimmed suffix would need a
325+
// custom row renderer. Unselected rows already paint dim, which carries
326+
// most of the "greyed hint" read.
316327
const interior = overlayInteriorWidth(contentWidth);
317328
const lines = formatPaletteRows(
318-
commands.map((command) => command.label),
329+
paletteLabels(commands),
319330
Math.max(4, interior - 1),
320331
);
321332
list.setHeight(list.height, 1);

0 commit comments

Comments
 (0)