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
88 changes: 88 additions & 0 deletions src/tui/command-catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -120,3 +123,88 @@ describe("command origin markers", () => {
}
});
});

describe("slashArgItems", () => {
const hintCmd: PaletteCommand = {
id: "release",
label: "/release",
argumentHint: "<id>",
};
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: "<id>",
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>" },
{
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 <id>")).toBe("/release ");
});

test("edited text past the hint is real content, never stripped", () => {
expect(stripUneditedSlashHint(catalog, "/release <id> 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 <id>")).toBeNull();
});

test("unknown commands never strip", () => {
expect(stripUneditedSlashHint(catalog, "/nope <id>")).toBeNull();
});
});
148 changes: 135 additions & 13 deletions src/tui/command-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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
* <hint>` 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);
}

/**
Expand All @@ -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<PaletteCommand, "label" | "hintLabel">[],
): 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 {
Expand Down
4 changes: 2 additions & 2 deletions src/tui/commands/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down
17 changes: 14 additions & 3 deletions src/tui/overlay-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -33,7 +37,10 @@ export interface OverlayListPresentation {
readonly kind: PrimaryOverlayKind | null;
readonly items: readonly string[];
readonly itemIds?: readonly string[];
readonly paletteCommands: readonly Pick<PaletteCommand, "label">[];
readonly paletteCommands: readonly Pick<
PaletteCommand,
"label" | "hintLabel"
>[];
readonly list: OverlayList | null;
readonly bodyLines: readonly string[];
readonly bodyFgs: readonly string[];
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading