From fd9565299c8c45837f11c27c59840876b80ba2ac Mon Sep 17 00:00:00 2001 From: Jeremy McSpadden Date: Tue, 17 Mar 2026 19:27:17 -0500 Subject: [PATCH] feat(autocomplete): add /thinking completions, GSD subcommand descriptions, and test coverage (#1019) - Add argument completions for /thinking command with all 6 levels (off, minimal, low, medium, high, xhigh) and descriptions - Add descriptions to all GSD 2nd-level subcommand completions across 14 subcommand groups (auto, mode, parallel, setup, prefs, remote, next, history, undo, export, cleanup, knowledge, doctor, dispatch) - Add 35 new tests for autocomplete and fuzzy matching systems --- .plans/autocomplete-qol-improvements.md | 46 +++++ .../src/modes/interactive/interactive-mode.ts | 17 ++ .../pi-tui/src/__tests__/autocomplete.test.ts | 186 ++++++++++++++++++ packages/pi-tui/src/__tests__/fuzzy.test.ts | 112 +++++++++++ src/resources/extensions/gsd/commands.ts | 157 +++++++++++---- 5 files changed, 478 insertions(+), 40 deletions(-) create mode 100644 .plans/autocomplete-qol-improvements.md create mode 100644 packages/pi-tui/src/__tests__/autocomplete.test.ts create mode 100644 packages/pi-tui/src/__tests__/fuzzy.test.ts diff --git a/.plans/autocomplete-qol-improvements.md b/.plans/autocomplete-qol-improvements.md new file mode 100644 index 000000000..288093830 --- /dev/null +++ b/.plans/autocomplete-qol-improvements.md @@ -0,0 +1,46 @@ +# Plan: Autocomplete QOL Improvements + +## Goal +Maximize quality-of-life for the autocomplete system by adding missing argument completions, improving discoverability with descriptions, and adding test coverage. + +## Changes + +### 1. Add `/thinking` argument completions (interactive-mode.ts) +- Add `getArgumentCompletions` to the `thinking` builtin command +- Values: `off`, `minimal`, `low`, `medium`, `high`, `xhigh` with descriptions +- Location: `setupAutocomplete()` in interactive-mode.ts, after the `/model` block + +### 2. Add descriptions to GSD 2nd-level subcommand completions (commands.ts) +- Currently `/gsd auto --verbose` shows label only, no description +- Add descriptions to all 2nd-level completion items across: + - `auto` flags: --verbose, --debug + - `mode` subcommands: global, project + - `parallel` subcommands: start, status, stop, pause, resume, merge + - `setup` subcommands: llm, search, remote, keys, prefs + - `prefs` subcommands: global, project, status, wizard, setup, import-claude + - `remote` subcommands: slack, discord, status, disconnect + - `next` flags: --verbose, --dry-run + - `history` flags: --cost, --phase, --model, 10, 20, 50 + - `undo`: --force + - `export` flags: --json, --markdown, --html, --html --all + - `cleanup` subcommands: branches, snapshots + - `knowledge` subcommands: rule, pattern, lesson + - `doctor` modes: fix, heal, audit + - `dispatch` phases: research, plan, execute, complete, reassess, uat, replan + +### 3. Add test coverage for autocomplete.ts and fuzzy.ts +- Test file: `packages/pi-tui/src/tests/autocomplete.test.ts` +- Cover: slash command completion, argument completion, @ file prefix extraction, path prefix extraction, apply completion +- Test file: `packages/pi-tui/src/tests/fuzzy.test.ts` +- Cover: basic matching, scoring, word boundaries, gap penalties, token splitting, alphanumeric swaps + +## Files Modified +- `packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts` — thinking completions +- `src/resources/extensions/gsd/commands.ts` — 2nd-level descriptions +- `packages/pi-tui/src/tests/autocomplete.test.ts` — new test file +- `packages/pi-tui/src/tests/fuzzy.test.ts` — new test file + +## Testing +- Run existing test suite to verify no regressions +- Run new test files +- Build to verify TypeScript compiles diff --git a/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts b/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts index 42831298e..c3db38b86 100644 --- a/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts @@ -319,6 +319,23 @@ export class InteractiveMode { }; } + // Add argument completions for /thinking + const thinkingCommand = slashCommands.find((command) => command.name === "thinking"); + if (thinkingCommand) { + thinkingCommand.getArgumentCompletions = (prefix: string): AutocompleteItem[] | null => { + const levels = [ + { value: "off", label: "off", description: "Disable extended thinking" }, + { value: "minimal", label: "minimal", description: "Minimal thinking budget" }, + { value: "low", label: "low", description: "Low thinking budget" }, + { value: "medium", label: "medium", description: "Medium thinking budget" }, + { value: "high", label: "high", description: "High thinking budget" }, + { value: "xhigh", label: "xhigh", description: "Maximum thinking budget" }, + ]; + const filtered = levels.filter((l) => l.value.startsWith(prefix.trim().toLowerCase())); + return filtered.length > 0 ? filtered : null; + }; + } + // Convert prompt templates to SlashCommand format for autocomplete const templateCommands: SlashCommand[] = this.session.promptTemplates.map((cmd) => ({ name: cmd.name, diff --git a/packages/pi-tui/src/__tests__/autocomplete.test.ts b/packages/pi-tui/src/__tests__/autocomplete.test.ts new file mode 100644 index 000000000..c3c44ac74 --- /dev/null +++ b/packages/pi-tui/src/__tests__/autocomplete.test.ts @@ -0,0 +1,186 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { CombinedAutocompleteProvider } from "../autocomplete.js"; +import type { SlashCommand } from "../autocomplete.js"; + +function makeProvider(commands: SlashCommand[] = [], basePath: string = "/tmp") { + return new CombinedAutocompleteProvider(commands, basePath); +} + +const sampleCommands: SlashCommand[] = [ + { name: "settings", description: "Open settings menu" }, + { name: "model", description: "Select model" }, + { name: "session", description: "Show session info" }, + { name: "export", description: "Export session" }, + { name: "thinking", description: "Set thinking level" }, +]; + +describe("CombinedAutocompleteProvider — slash commands", () => { + it("returns all commands for bare /", () => { + const provider = makeProvider(sampleCommands); + const result = provider.getSuggestions(["/"], 0, 1); + assert.ok(result, "should return suggestions"); + assert.equal(result!.items.length, sampleCommands.length); + assert.equal(result!.prefix, "/"); + }); + + it("filters commands by typed prefix", () => { + const provider = makeProvider(sampleCommands); + const result = provider.getSuggestions(["/se"], 0, 3); + assert.ok(result); + assert.equal(result!.items.length, 2); // settings, session + assert.ok(result!.items.some((i) => i.value === "settings")); + assert.ok(result!.items.some((i) => i.value === "session")); + }); + + it("returns null when no commands match", () => { + const provider = makeProvider(sampleCommands); + const result = provider.getSuggestions(["/zzz"], 0, 4); + assert.equal(result, null); + }); + + it("includes description in suggestions", () => { + const provider = makeProvider(sampleCommands); + const result = provider.getSuggestions(["/mod"], 0, 4); + assert.ok(result); + assert.equal(result!.items[0]?.description, "Select model"); + }); + + it("does not trigger slash commands mid-line", () => { + const provider = makeProvider(sampleCommands); + // "/" not at position 0 in the line — should not match slash commands + const result = provider.getSuggestions(["hello /se"], 0, 9); + assert.equal(result, null); + }); +}); + +describe("CombinedAutocompleteProvider — argument completions", () => { + it("returns argument completions for commands that support them", () => { + const commands: SlashCommand[] = [ + { + name: "thinking", + description: "Set thinking level", + getArgumentCompletions: (prefix) => { + const levels = ["off", "low", "medium", "high"]; + const filtered = levels + .filter((l) => l.startsWith(prefix.trim())) + .map((l) => ({ value: l, label: l })); + return filtered.length > 0 ? filtered : null; + }, + }, + ]; + const provider = makeProvider(commands); + const result = provider.getSuggestions(["/thinking m"], 0, 11); + assert.ok(result); + assert.equal(result!.items.length, 1); + assert.equal(result!.items[0]?.value, "medium"); + }); + + it("returns null for commands without argument completions", () => { + const provider = makeProvider(sampleCommands); + const result = provider.getSuggestions(["/settings foo"], 0, 13); + assert.equal(result, null); + }); + + it("returns all arg completions for empty prefix after space", () => { + const commands: SlashCommand[] = [ + { + name: "test", + description: "Test command", + getArgumentCompletions: (prefix) => { + const subs = ["start", "stop", "status"]; + const filtered = subs + .filter((s) => s.startsWith(prefix.trim())) + .map((s) => ({ value: s, label: s })); + return filtered.length > 0 ? filtered : null; + }, + }, + ]; + const provider = makeProvider(commands); + const result = provider.getSuggestions(["/test "], 0, 6); + assert.ok(result); + assert.equal(result!.items.length, 3); + }); +}); + +describe("CombinedAutocompleteProvider — @ file prefix extraction", () => { + it("detects @ at start of line", () => { + const provider = makeProvider(); + // @ triggers fuzzy file search — we can't test the actual file results + // but we can test that getSuggestions returns null (no files in /tmp matching) + // rather than crashing + const result = provider.getSuggestions(["@nonexistent_xyz"], 0, 16); + // May return null or empty — the key thing is it doesn't crash + assert.ok(result === null || result.items.length >= 0); + }); + + it("detects @ after space", () => { + const provider = makeProvider(); + const result = provider.getSuggestions(["check @nonexistent_xyz"], 0, 22); + assert.ok(result === null || result.items.length >= 0); + }); +}); + +describe("CombinedAutocompleteProvider — applyCompletion", () => { + it("applies slash command completion with trailing space", () => { + const provider = makeProvider(sampleCommands); + const result = provider.applyCompletion(["/se"], 0, 3, { value: "settings", label: "settings" }, "/se"); + assert.equal(result.lines[0], "/settings "); + assert.equal(result.cursorCol, 10); // after "/settings " + }); + + it("applies file path completion for @ prefix", () => { + const provider = makeProvider(); + const result = provider.applyCompletion( + ["@src/"], + 0, + 5, + { value: "@src/index.ts", label: "index.ts" }, + "@src/", + ); + assert.equal(result.lines[0], "@src/index.ts "); + }); + + it("applies directory completion without trailing space", () => { + const provider = makeProvider(); + const result = provider.applyCompletion( + ["@sr"], + 0, + 3, + { value: "@src/", label: "src/" }, + "@sr", + ); + // Directories should not get trailing space so user can continue typing + assert.ok(!result.lines[0]!.endsWith(" ")); + }); + + it("preserves text after cursor", () => { + const provider = makeProvider(sampleCommands); + const result = provider.applyCompletion( + ["/se and more text"], + 0, + 3, + { value: "settings", label: "settings" }, + "/se", + ); + assert.ok(result.lines[0]!.includes("and more text")); + }); +}); + +describe("CombinedAutocompleteProvider — force file suggestions", () => { + it("does not trigger for slash commands", () => { + const provider = makeProvider(sampleCommands); + const result = provider.getForceFileSuggestions(["/set"], 0, 4); + assert.equal(result, null); + }); + + it("shouldTriggerFileCompletion returns false for slash commands", () => { + const provider = makeProvider(sampleCommands); + assert.equal(provider.shouldTriggerFileCompletion(["/set"], 0, 4), false); + }); + + it("shouldTriggerFileCompletion returns true for regular text", () => { + const provider = makeProvider(); + assert.equal(provider.shouldTriggerFileCompletion(["some text"], 0, 9), true); + }); +}); diff --git a/packages/pi-tui/src/__tests__/fuzzy.test.ts b/packages/pi-tui/src/__tests__/fuzzy.test.ts new file mode 100644 index 000000000..b576ebfdb --- /dev/null +++ b/packages/pi-tui/src/__tests__/fuzzy.test.ts @@ -0,0 +1,112 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { fuzzyMatch, fuzzyFilter } from "../fuzzy.js"; + +describe("fuzzyMatch", () => { + it("matches exact string", () => { + const result = fuzzyMatch("hello", "hello"); + assert.equal(result.matches, true); + }); + + it("matches substring characters in order", () => { + const result = fuzzyMatch("hlo", "hello"); + assert.equal(result.matches, true); + }); + + it("does not match when characters are out of order", () => { + const result = fuzzyMatch("olh", "hello"); + assert.equal(result.matches, false); + }); + + it("empty query matches everything", () => { + const result = fuzzyMatch("", "anything"); + assert.equal(result.matches, true); + assert.equal(result.score, 0); + }); + + it("does not match when query is longer than text", () => { + const result = fuzzyMatch("toolong", "short"); + assert.equal(result.matches, false); + }); + + it("is case insensitive", () => { + const result = fuzzyMatch("ABC", "abcdef"); + assert.equal(result.matches, true); + }); + + it("rewards consecutive matches with lower score", () => { + const consecutive = fuzzyMatch("hel", "hello"); + const gapped = fuzzyMatch("hlo", "hello"); + assert.ok(consecutive.score < gapped.score, "consecutive matches should score lower (better)"); + }); + + it("rewards word boundary matches", () => { + const boundary = fuzzyMatch("sc", "slash-command"); + const nonBoundary = fuzzyMatch("sc", "describe"); + assert.ok(boundary.score < nonBoundary.score, "word boundary matches should score lower (better)"); + }); + + it("handles alphanumeric swap (e.g., opus3 matches opus-3)", () => { + const result = fuzzyMatch("opus3", "opus-3"); + assert.equal(result.matches, true); + }); + + it("handles numeric-alpha swap", () => { + const result = fuzzyMatch("3opus", "opus-3"); + assert.equal(result.matches, true); + }); + + it("does not match completely unrelated strings", () => { + const result = fuzzyMatch("xyz", "hello"); + assert.equal(result.matches, false); + }); +}); + +describe("fuzzyFilter", () => { + const items = ["settings", "session", "share", "model", "compact", "export"]; + + it("returns all items for empty query", () => { + const result = fuzzyFilter(items, "", (x) => x); + assert.equal(result.length, items.length); + }); + + it("filters to matching items only", () => { + const result = fuzzyFilter(items, "se", (x) => x); + assert.ok(result.includes("settings")); + assert.ok(result.includes("session")); + assert.ok(!result.includes("model")); + }); + + it("sorts by match quality (best first)", () => { + const result = fuzzyFilter(items, "ex", (x) => x); + assert.equal(result[0], "export"); + }); + + it("supports space-separated tokens (all must match)", () => { + const data = ["anthropic/opus", "anthropic/sonnet", "openai/gpt4"]; + const result = fuzzyFilter(data, "ant opus", (x) => x); + assert.equal(result.length, 1); + assert.equal(result[0], "anthropic/opus"); + }); + + it("returns empty array when no items match", () => { + const result = fuzzyFilter(items, "zzz", (x) => x); + assert.equal(result.length, 0); + }); + + it("works with custom getText function", () => { + const objects = [ + { name: "alpha", id: 1 }, + { name: "beta", id: 2 }, + { name: "gamma", id: 3 }, + ]; + const result = fuzzyFilter(objects, "bet", (o) => o.name); + assert.equal(result.length, 1); + assert.equal(result[0]?.name, "beta"); + }); + + it("handles whitespace-only query as empty", () => { + const result = fuzzyFilter(items, " ", (x) => x); + assert.equal(result.length, items.length); + }); +}); diff --git a/src/resources/extensions/gsd/commands.ts b/src/resources/extensions/gsd/commands.ts index a945e9bd0..5884364b3 100644 --- a/src/resources/extensions/gsd/commands.ts +++ b/src/resources/extensions/gsd/commands.ts @@ -131,93 +131,161 @@ export function registerGSDCommand(pi: ExtensionAPI): void { if (parts[0] === "auto" && parts.length <= 2) { const flagPrefix = parts[1] ?? ""; - return ["--verbose", "--debug"] - .filter((f) => f.startsWith(flagPrefix)) - .map((f) => ({ value: `auto ${f}`, label: f })); + const flags = [ + { flag: "--verbose", desc: "Show detailed execution output" }, + { flag: "--debug", desc: "Enable debug logging" }, + ]; + return flags + .filter((f) => f.flag.startsWith(flagPrefix)) + .map((f) => ({ value: `auto ${f.flag}`, label: f.flag, description: f.desc })); } if (parts[0] === "mode" && parts.length <= 2) { const subPrefix = parts[1] ?? ""; - return ["global", "project"] - .filter((cmd) => cmd.startsWith(subPrefix)) - .map((cmd) => ({ value: `mode ${cmd}`, label: cmd })); + const modes = [ + { cmd: "global", desc: "Edit global workflow mode" }, + { cmd: "project", desc: "Edit project-specific workflow mode" }, + ]; + return modes + .filter((m) => m.cmd.startsWith(subPrefix)) + .map((m) => ({ value: `mode ${m.cmd}`, label: m.cmd, description: m.desc })); } if (parts[0] === "parallel" && parts.length <= 2) { const subPrefix = parts[1] ?? ""; - return ["start", "status", "stop", "pause", "resume", "merge"] - .filter((cmd) => cmd.startsWith(subPrefix)) - .map((cmd) => ({ value: `parallel ${cmd}`, label: cmd })); + const subs = [ + { cmd: "start", desc: "Start parallel milestone orchestration" }, + { cmd: "status", desc: "Show parallel worker statuses" }, + { cmd: "stop", desc: "Stop all parallel workers" }, + { cmd: "pause", desc: "Pause a specific worker" }, + { cmd: "resume", desc: "Resume a paused worker" }, + { cmd: "merge", desc: "Merge completed milestone branches" }, + ]; + return subs + .filter((s) => s.cmd.startsWith(subPrefix)) + .map((s) => ({ value: `parallel ${s.cmd}`, label: s.cmd, description: s.desc })); } if (parts[0] === "setup" && parts.length <= 2) { const subPrefix = parts[1] ?? ""; - return ["llm", "search", "remote", "keys", "prefs"] - .filter((cmd) => cmd.startsWith(subPrefix)) - .map((cmd) => ({ value: `setup ${cmd}`, label: cmd })); + const subs = [ + { cmd: "llm", desc: "Configure LLM provider settings" }, + { cmd: "search", desc: "Configure web search provider" }, + { cmd: "remote", desc: "Configure remote integrations" }, + { cmd: "keys", desc: "Manage API keys" }, + { cmd: "prefs", desc: "Configure global preferences" }, + ]; + return subs + .filter((s) => s.cmd.startsWith(subPrefix)) + .map((s) => ({ value: `setup ${s.cmd}`, label: s.cmd, description: s.desc })); } if (parts[0] === "prefs" && parts.length <= 2) { const subPrefix = parts[1] ?? ""; - return ["global", "project", "status", "wizard", "setup", "import-claude"] - .filter((cmd) => cmd.startsWith(subPrefix)) - .map((cmd) => ({ value: `prefs ${cmd}`, label: cmd })); + const subs = [ + { cmd: "global", desc: "Edit global preferences file" }, + { cmd: "project", desc: "Edit project preferences file" }, + { cmd: "status", desc: "Show effective preferences" }, + { cmd: "wizard", desc: "Interactive preferences wizard" }, + { cmd: "setup", desc: "First-time preferences setup" }, + { cmd: "import-claude", desc: "Import settings from Claude Code" }, + ]; + return subs + .filter((s) => s.cmd.startsWith(subPrefix)) + .map((s) => ({ value: `prefs ${s.cmd}`, label: s.cmd, description: s.desc })); } if (parts[0] === "remote" && parts.length <= 2) { const subPrefix = parts[1] ?? ""; - return ["slack", "discord", "status", "disconnect"] - .filter((cmd) => cmd.startsWith(subPrefix)) - .map((cmd) => ({ value: `remote ${cmd}`, label: cmd })); + const subs = [ + { cmd: "slack", desc: "Configure Slack integration" }, + { cmd: "discord", desc: "Configure Discord integration" }, + { cmd: "status", desc: "Show remote connection status" }, + { cmd: "disconnect", desc: "Disconnect remote integrations" }, + ]; + return subs + .filter((s) => s.cmd.startsWith(subPrefix)) + .map((s) => ({ value: `remote ${s.cmd}`, label: s.cmd, description: s.desc })); } if (parts[0] === "next" && parts.length <= 2) { const flagPrefix = parts[1] ?? ""; - return ["--verbose", "--dry-run"] - .filter((f) => f.startsWith(flagPrefix)) - .map((f) => ({ value: `next ${f}`, label: f })); + const flags = [ + { flag: "--verbose", desc: "Show detailed step output" }, + { flag: "--dry-run", desc: "Preview next step without executing" }, + ]; + return flags + .filter((f) => f.flag.startsWith(flagPrefix)) + .map((f) => ({ value: `next ${f.flag}`, label: f.flag, description: f.desc })); } if (parts[0] === "history" && parts.length <= 2) { const flagPrefix = parts[1] ?? ""; - return ["--cost", "--phase", "--model", "10", "20", "50"] - .filter((f) => f.startsWith(flagPrefix)) - .map((f) => ({ value: `history ${f}`, label: f })); + const flags = [ + { flag: "--cost", desc: "Show cost breakdown per entry" }, + { flag: "--phase", desc: "Filter by phase type" }, + { flag: "--model", desc: "Filter by model used" }, + { flag: "10", desc: "Show last 10 entries" }, + { flag: "20", desc: "Show last 20 entries" }, + { flag: "50", desc: "Show last 50 entries" }, + ]; + return flags + .filter((f) => f.flag.startsWith(flagPrefix)) + .map((f) => ({ value: `history ${f.flag}`, label: f.flag, description: f.desc })); } if (parts[0] === "undo" && parts.length <= 2) { - return [{ value: "undo --force", label: "--force" }]; + return [{ value: "undo --force", label: "--force", description: "Skip confirmation prompt" }]; } if (parts[0] === "export" && parts.length <= 2) { const flagPrefix = parts[1] ?? ""; - return ["--json", "--markdown", "--html", "--html --all"] - .filter((f) => f.startsWith(flagPrefix)) - .map((f) => ({ value: `export ${f}`, label: f })); + const flags = [ + { flag: "--json", desc: "Export as JSON" }, + { flag: "--markdown", desc: "Export as Markdown" }, + { flag: "--html", desc: "Export as HTML" }, + { flag: "--html --all", desc: "Export all milestones as HTML" }, + ]; + return flags + .filter((f) => f.flag.startsWith(flagPrefix)) + .map((f) => ({ value: `export ${f.flag}`, label: f.flag, description: f.desc })); } if (parts[0] === "cleanup" && parts.length <= 2) { const subPrefix = parts[1] ?? ""; - return ["branches", "snapshots"] - .filter((cmd) => cmd.startsWith(subPrefix)) - .map((cmd) => ({ value: `cleanup ${cmd}`, label: cmd })); + const subs = [ + { cmd: "branches", desc: "Remove merged milestone branches" }, + { cmd: "snapshots", desc: "Remove old execution snapshots" }, + ]; + return subs + .filter((s) => s.cmd.startsWith(subPrefix)) + .map((s) => ({ value: `cleanup ${s.cmd}`, label: s.cmd, description: s.desc })); } if (parts[0] === "knowledge" && parts.length <= 2) { const subPrefix = parts[1] ?? ""; - return ["rule", "pattern", "lesson"] - .filter((cmd) => cmd.startsWith(subPrefix)) - .map((cmd) => ({ value: `knowledge ${cmd}`, label: cmd })); + const subs = [ + { cmd: "rule", desc: "Add a project rule (always/never do X)" }, + { cmd: "pattern", desc: "Add a code pattern to follow" }, + { cmd: "lesson", desc: "Record a lesson learned" }, + ]; + return subs + .filter((s) => s.cmd.startsWith(subPrefix)) + .map((s) => ({ value: `knowledge ${s.cmd}`, label: s.cmd, description: s.desc })); } if (parts[0] === "doctor") { const modePrefix = parts[1] ?? ""; - const modes = ["fix", "heal", "audit"]; + const modes = [ + { cmd: "fix", desc: "Auto-fix detected issues" }, + { cmd: "heal", desc: "AI-driven deep healing" }, + { cmd: "audit", desc: "Run health audit without fixing" }, + ]; if (parts.length <= 2) { return modes - .filter((cmd) => cmd.startsWith(modePrefix)) - .map((cmd) => ({ value: `doctor ${cmd}`, label: cmd })); + .filter((m) => m.cmd.startsWith(modePrefix)) + .map((m) => ({ value: `doctor ${m.cmd}`, label: m.cmd, description: m.desc })); } return []; @@ -225,9 +293,18 @@ export function registerGSDCommand(pi: ExtensionAPI): void { if (parts[0] === "dispatch" && parts.length <= 2) { const phasePrefix = parts[1] ?? ""; - return ["research", "plan", "execute", "complete", "reassess", "uat", "replan"] - .filter((cmd) => cmd.startsWith(phasePrefix)) - .map((cmd) => ({ value: `dispatch ${cmd}`, label: cmd })); + const phases = [ + { cmd: "research", desc: "Run research phase" }, + { cmd: "plan", desc: "Run planning phase" }, + { cmd: "execute", desc: "Run execution phase" }, + { cmd: "complete", desc: "Run completion phase" }, + { cmd: "reassess", desc: "Reassess current progress" }, + { cmd: "uat", desc: "Run user acceptance testing" }, + { cmd: "replan", desc: "Replan the current slice" }, + ]; + return phases + .filter((p) => p.cmd.startsWith(phasePrefix)) + .map((p) => ({ value: `dispatch ${p.cmd}`, label: p.cmd, description: p.desc })); } return [];