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
This commit is contained in:
Jeremy McSpadden 2026-03-17 19:27:17 -05:00 committed by GitHub
parent 2e34e83a26
commit fd9565299c
5 changed files with 478 additions and 40 deletions

View file

@ -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

View file

@ -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,

View file

@ -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);
});
});

View file

@ -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);
});
});

View file

@ -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 [];