From ab1a1edcf97938dd1996542724a62378cf00eeeb Mon Sep 17 00:00:00 2001 From: Mikael Hugo Date: Thu, 14 May 2026 20:14:09 +0200 Subject: [PATCH] refactor: tier sf slash commands --- .../coding-agent/src/core/extensions/types.ts | 1 + .../src/modes/interactive/interactive-mode.ts | 20 +- .../tui/src/__tests__/autocomplete.test.ts | 25 ++ packages/tui/src/autocomplete.ts | 23 +- .../extensions/sf/commands/catalog.js | 33 ++- .../extensions/sf/commands/handlers/core.js | 272 ++++++++++-------- src/resources/extensions/sf/commands/index.js | 2 + .../sf/tests/direct-command-surface.test.mjs | 30 ++ 8 files changed, 252 insertions(+), 154 deletions(-) diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index 03e620631..42c1da75e 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -1161,6 +1161,7 @@ export type MessageRenderer = ( export interface RegisteredCommand { name: string; description?: string; + menuTier?: "primary" | "secondary" | "internal"; getArgumentCompletions?: ( argumentPrefix: string, ) => AutocompleteItem[] | null; diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 64ba9155e..5a935f2ea 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -118,6 +118,8 @@ import { UserMessageComponent } from "./components/user-message.js"; import { UserMessageSelectorComponent } from "./components/user-message-selector.js"; import { handleAgentEvent } from "./controllers/chat-controller.js"; import { createExtensionUIContext as buildExtensionUIContext } from "./controllers/extension-ui-controller.js"; +import { setupEditorSubmitHandler as setupEditorSubmitHandlerController } from "./controllers/input-controller.js"; +import { updateAvailableProviderCount as updateAvailableProviderCountController } from "./controllers/model-controller.js"; import { buildScopeGroups, formatDiagnostics, @@ -125,8 +127,6 @@ import { formatScopeGroups, getShortPath, } from "./resource-display.js"; -import { setupEditorSubmitHandler as setupEditorSubmitHandlerController } from "./controllers/input-controller.js"; -import { updateAvailableProviderCount as updateAvailableProviderCountController } from "./controllers/model-controller.js"; import { getAppKeyDisplay, type SlashCommandContext, @@ -603,6 +603,7 @@ export class InteractiveMode { ).map((cmd) => ({ name: cmd.name, description: cmd.description ?? "(extension command)", + menuTier: cmd.menuTier, getArgumentCompletions: cmd.getArgumentCompletions, })); @@ -1110,10 +1111,7 @@ export class InteractiveMode { ); if (collisionDiags.length > 0) { - const collisionLines = formatDiagnostics( - collisionDiags, - metadata, - ); + const collisionLines = formatDiagnostics(collisionDiags, metadata); this.chatContainer.addChild( new Text( `${theme.fg("warning", "[Skill conflicts]")}\n${collisionLines}`, @@ -1139,10 +1137,7 @@ export class InteractiveMode { const promptDiagnostics = promptsResult.diagnostics; if (promptDiagnostics.length > 0) { - const warningLines = formatDiagnostics( - promptDiagnostics, - metadata, - ); + const warningLines = formatDiagnostics(promptDiagnostics, metadata); this.chatContainer.addChild( new Text( `${theme.fg("warning", "[Prompt conflicts]")}\n${warningLines}`, @@ -1175,10 +1170,7 @@ export class InteractiveMode { extensionDiagnostics.push(...shortcutDiagnostics); if (extensionDiagnostics.length > 0) { - const warningLines = formatDiagnostics( - extensionDiagnostics, - metadata, - ); + const warningLines = formatDiagnostics(extensionDiagnostics, metadata); this.chatContainer.addChild( new Text( `${theme.fg("warning", "[Extension issues]")}\n${warningLines}`, diff --git a/packages/tui/src/__tests__/autocomplete.test.ts b/packages/tui/src/__tests__/autocomplete.test.ts index 070b48dbd..859bd30ce 100644 --- a/packages/tui/src/__tests__/autocomplete.test.ts +++ b/packages/tui/src/__tests__/autocomplete.test.ts @@ -30,6 +30,31 @@ describe("CombinedAutocompleteProvider — slash commands", () => { assert.equal(result!.prefix, "/"); }); + it("returns only primary commands for bare / when tiers are configured", () => { + const provider = makeProvider([ + { name: "help", description: "Help", menuTier: "primary" }, + { name: "next", description: "Step", menuTier: "primary" }, + { name: "queue", description: "Queue", menuTier: "secondary" }, + ]); + const result = provider.getSuggestions(["/"], 0, 1); + assert.ok(result); + assert.deepEqual(result!.items.map((item) => item.value).sort(), [ + "help", + "next", + ]); + }); + + it("searches secondary commands after a prefix is typed", () => { + const provider = makeProvider([ + { name: "quick", description: "Quick", menuTier: "primary" }, + { name: "queue", description: "Queue", menuTier: "secondary" }, + ]); + const result = provider.getSuggestions(["/q"], 0, 2); + assert.ok(result); + assert.ok(result!.items.some((item) => item.value === "quick")); + assert.ok(result!.items.some((item) => item.value === "queue")); + }); + it("filters commands by typed prefix", () => { const provider = makeProvider(sampleCommands); const result = provider.getSuggestions(["/se"], 0, 3); diff --git a/packages/tui/src/autocomplete.ts b/packages/tui/src/autocomplete.ts index c41a4e94d..e9a533471 100644 --- a/packages/tui/src/autocomplete.ts +++ b/packages/tui/src/autocomplete.ts @@ -114,6 +114,7 @@ export interface AutocompleteItem { export interface SlashCommand { name: string; description?: string; + menuTier?: "primary" | "secondary" | "internal"; // Function to get argument completions for this command // Returns null if no argument completion is available getArgumentCompletions?(argumentPrefix: string): AutocompleteItem[] | null; @@ -207,22 +208,30 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { name: "name" in cmd ? cmd.name : cmd.value, label: "name" in cmd ? cmd.name : cmd.label, description: cmd.description, + menuTier: "menuTier" in cmd ? cmd.menuTier : undefined, })); - const filtered = fuzzyFilter( - commandItems, - prefix, - (item) => item.name, - ).map((item) => ({ + let filtered = fuzzyFilter(commandItems, prefix, (item) => item.name); + + if (prefix.length === 0) { + const primary = filtered.filter( + (item) => item.menuTier === "primary", + ); + if (primary.length > 0) { + filtered = primary; + } + } + + const items = filtered.map((item) => ({ value: item.name, label: item.label, ...(item.description && { description: item.description }), })); - if (filtered.length === 0) return null; + if (items.length === 0) return null; return { - items: filtered, + items, prefix: `/${prefix}`, }; } else { diff --git a/src/resources/extensions/sf/commands/catalog.js b/src/resources/extensions/sf/commands/catalog.js index d56ba2c03..11440c10f 100644 --- a/src/resources/extensions/sf/commands/catalog.js +++ b/src/resources/extensions/sf/commands/catalog.js @@ -318,15 +318,14 @@ export const TOP_LEVEL_SUBCOMMANDS = [ ]; // Lean product surface (UOK control-plane convo 2026-05-14): operators -// pick modes, not personas, and SF runs implementation machinery (triage, -// agent picks, mode/work-mode shifts, orchestration internals) on its own. -// Commands not in this set stay callable for scripting/debug but don't -// show up in the slash catalog or help. +// pick workflows, not personas, and SF runs implementation machinery (agent +// picks, mode/work-mode shifts, orchestration internals) on its own. Commands +// not in this set stay callable for scripting/debug but don't show up in the +// slash catalog or help. // // Hidden by category: -// - /triage, /agent, /parallel, /cmux, /sidekicks — internal machinery +// - /agent, /parallel, /cmux, /sidekicks — internal orchestration machinery // - /mode, /control, /permission-profile, /model-mode — Shift+Tab + advanced axes -// - /repair — auto-detected work-mode shift, fold into /autonomous // - /hooks, /run-hook, /skill-health, /inspect, /recover — diagnostics // - /mcp, /extensions, /configure-agent, /experimental — platform plumbing // - /delegate, /pr-branch, /add-tests — lower-level conveniences reached @@ -370,6 +369,28 @@ export const PUBLIC_DIRECT_COMMANDS = new Set([ "workflow", ]); +/** + * Primary command tier — the 5 things an operator should memorize. Four + * product modes plus /help. Everything else is reachable via autocomplete + * (PUBLIC_DIRECT_COMMANDS) or operator-driven discovery (`/help all`). + * + * Rationale (2026-05-14 product-surface convo): a 34-command menu defeats + * the product-mode framing. Operators interact with modes (`/next`, + * `/discuss`, `/autonomous`) plus the non-workflow lane (`/quick`); SF + * surfaces relevant secondary commands inline when context warrants. + */ +export const PRIMARY_COMMANDS = new Set([ + "next", + "autonomous", + "discuss", + "quick", + "help", +]); + +export const PRIMARY_TOP_LEVEL_SUBCOMMANDS = TOP_LEVEL_SUBCOMMANDS.filter( + (command) => PRIMARY_COMMANDS.has(command.cmd), +); + export const PUBLIC_TOP_LEVEL_SUBCOMMANDS = TOP_LEVEL_SUBCOMMANDS.filter( (command) => PUBLIC_DIRECT_COMMANDS.has(command.cmd), ); diff --git a/src/resources/extensions/sf/commands/handlers/core.js b/src/resources/extensions/sf/commands/handlers/core.js index 3c07d61e0..e7e054952 100644 --- a/src/resources/extensions/sf/commands/handlers/core.js +++ b/src/resources/extensions/sf/commands/handlers/core.js @@ -25,138 +25,156 @@ import { formatProgressLine, } from "../../progress-score.js"; import { setSessionModelOverride } from "../../session-model-override.js"; +import { sfHome } from "../../sf-home.js"; import { formattedShortcutPair } from "../../shortcut-defs.js"; import { deriveState } from "../../state.js"; import { writeUokDiagnostics } from "../../uok/diagnostic-synthesis.js"; +import { PRIMARY_COMMANDS, PUBLIC_TOP_LEVEL_SUBCOMMANDS } from "../catalog.js"; import { projectRoot } from "../context.js"; -import { sfHome } from "../../sf-home.js"; + +const HELP_CATEGORY_ORDER = [ + { + title: "PRIMARY", + commands: ["next", "autonomous", "discuss", "quick", "help"], + }, + { + title: "WORKFLOW", + commands: [ + "start", + "templates", + "workflow", + "ship", + "backlog", + "schedule", + "triage", + ], + }, + { + title: "STATUS", + commands: ["status", "queue", "visualize", "history", "logs", "forensics"], + }, + { + title: "CONTROL", + commands: ["pause", "stop", "skip", "undo", "park", "unpark", "capture"], + }, + { + title: "SETUP", + commands: [ + "init", + "setup", + "doctor", + "repair", + "remote", + "knowledge", + "config", + "keys", + "model", + "prefs", + "update", + ], + }, +]; + +function commandByName() { + return new Map( + PUBLIC_TOP_LEVEL_SUBCOMMANDS.map((command) => [command.cmd, command]), + ); +} + +function commandLine(command) { + return ` /${command.cmd.padEnd(12)} ${command.desc ?? ""}`.trimEnd(); +} + +function groupedPublicHelpLines() { + const byName = commandByName(); + const emitted = new Set(); + const lines = ["SF — Singularity Forge workflow runtime\n"]; + for (const category of HELP_CATEGORY_ORDER) { + const categoryLines = category.commands + .map((name) => byName.get(name)) + .filter(Boolean) + .map((command) => { + emitted.add(command.cmd); + return commandLine(command); + }); + if (categoryLines.length === 0) continue; + lines.push(category.title, ...categoryLines, ""); + } + const other = PUBLIC_TOP_LEVEL_SUBCOMMANDS.filter( + (command) => !emitted.has(command.cmd), + ); + if (other.length > 0) { + lines.push("OTHER", ...other.map(commandLine), ""); + } + return lines; +} + +function keywordHelpLines(keyword) { + const needle = keyword.toLowerCase(); + const tokens = needle + .split(/[^a-z0-9-]+/i) + .map((token) => token.trim()) + .filter( + (token) => + token.length > 2 && + !new Set([ + "the", + "and", + "for", + "with", + "want", + "show", + "see", + "my", + "how", + "what", + "where", + ]).has(token), + ); + const matches = PUBLIC_TOP_LEVEL_SUBCOMMANDS.filter( + (command) => + command.cmd.toLowerCase().includes(needle) || + (command.desc ?? "").toLowerCase().includes(needle) || + tokens.some( + (token) => + command.cmd.toLowerCase().includes(token) || + (command.desc ?? "").toLowerCase().includes(token), + ), + ); + if (matches.length === 0) { + return [ + `No public SF command matched "${keyword}".`, + "Try /help all, or describe the work in /discuss.", + ]; + } + return [`SF command matches for "${keyword}"\n`, ...matches.map(commandLine)]; +} + export function showHelp(ctx, args = "") { - const summaryLines = [ - "SF — Singularity Forge\n", - "QUICK START", - " /start Start a workflow template", - " /next Run one assisted unit", - " /autonomous Run all queued product units continuously", - " /pause Pause autonomous mode", - " /autonomous stop Stop autonomous mode gracefully", - "", - "VISIBILITY", - ` /status Dashboard (${formattedShortcutPair("dashboard")})`, - ` /parallel watch Parallel monitor (${formattedShortcutPair("parallel")})`, - ` /notifications Notification history (${formattedShortcutPair("notifications")})`, - " /tasks Background work surface — units, workers, budget", - " /visualize Interactive 10-tab TUI", - " /queue Show queued/dispatched units", - " /research Force research stage", - " /plan Force planning stage", - " /implement Force implementation stage", - "", - "COURSE CORRECTION", - " /steer Apply user override to active work", - " /steer mode [scope] Change work mode (now|after-current-unit|next-milestone)", - " /steer permission-profile

[scope] Change permission profile", - " /steer model-mode Change model mode for next unit", - " /capture Quick-capture a thought to CAPTURES.md", - " /triage Classify and route pending captures", - " /undo Revert last completed unit [--force]", - " /rethink Conversational project reorganization", - "", - "SETUP", - " /init Project init wizard", - " /setup Global setup status [llm|search|remote|keys|prefs]", - " /reload Snapshot and reload agent with fresh extension code", - " /model Switch active session model", - " /prefs Manage preferences", - " /doctor Diagnose and repair .sf/ state", - " /repair Switch to repair work mode and run diagnostics", - " /tasks Background work surface", - " /skills List discovered skills [reload|--eval |--auto-create]", - " /cost Show cost summary [--session|--all|--prometheus]", - "", - "Use /help all for the complete command reference.", - ]; - const allLines = [ - "SF — Singularity Forge\n", - "WORKFLOW", - " /start Start a workflow template (bugfix, spike, feature, hotfix, etc.)", - " /templates List available workflow templates [info ]", - " /next Run one assisted unit", - " /next Assisted mode: execute next task, then pause [--dry-run] [--verbose]", - " /autonomous Run all queued product units continuously [--verbose]", - " /autonomous stop Stop autonomous mode gracefully", - " /pause Pause autonomous mode (preserves state, /autonomous to resume)", - " /discuss Start guided milestone/slice discussion", - " /new-milestone Create milestone from headless context (used by sf headless)", - "", - "VISIBILITY", - ` /status Show progress dashboard (${formattedShortcutPair("dashboard")})`, - ` /parallel watch Open parallel worker monitor (${formattedShortcutPair("parallel")})`, - " /visualize Interactive 10-tab TUI (progress, timeline, deps, metrics, health, agent, changes, knowledge, captures, export)", - " /queue Show queued/dispatched units and execution order", - " /tasks Background work surface — units, workers, budget, checkpoints", - " /research Force research stage for current unit", - " /plan Force planning stage for current unit", - " /implement Force implementation stage for current unit", - " /history View execution history [--cost] [--phase] [--model] [N]", - " /trajectory View execution trajectory — step-by-step trace with costs and errors", - " /changelog Show categorized release notes [version]", - ` /notifications View persistent notification history [clear|tail|filter] (${formattedShortcutPair("notifications")})`, - "", - "COURSE CORRECTION", - " /steer Apply user override to active work", - " /capture Quick-capture a thought to CAPTURES.md", - " /triage Classify and route pending captures", - " /skip Prevent a unit from autonomous mode dispatch", - " /undo Revert last completed unit [--force]", - " /rethink Conversational project reorganization — reorder, park, discard, add milestones", - " /park [id] Park a milestone — skip without deleting [reason]", - " /unpark [id] Reactivate a parked milestone", - "", - "PROJECT KNOWLEDGE", - " /knowledge Add rule, pattern, or lesson to KNOWLEDGE.md", - " /codebase [generate|update|stats|indexer] Manage CODEBASE.md and Sift code search", - "", - "SCHEDULE", - " /schedule add --in Schedule a follow-up item", - " /schedule list Show pending scheduled items", - " /schedule done <id> Mark an item complete", - "", - "SETUP & CONFIGURATION", - " /init Project init wizard — detect, configure, bootstrap .sf/", - " /setup Global setup status [llm|search|remote|keys|prefs]", - " /model Switch active session model [provider/model|model-id]", - " /mode Switch work mode (chat/plan/build/review/repair/research)", - " /control Switch run control (manual/assisted/autonomous)", - " /permission-profile Switch permission profile (restricted/normal/trusted/unrestricted)", - " /model-mode Switch model mode (fast/smart/deep)", - " /prefs Manage preferences [global|project|status|wizard|setup|import-claude]", - " /cmux Manage cmux integration [status|on|off|notifications|sidebar|splits|browser]", - " /config Set API keys for external tools", - " /keys API key manager [list|add|remove|test|rotate|doctor]", - " /show-config Show effective configuration (models, routing, toggles)", - " /hooks Show post-unit hook configuration", - " /extensions Manage extensions [list|enable|disable|info]", - " /fast Toggle OpenAI service tier [on|off|flex|status]", - " /mcp External MCP server status [status|check <server>|reload]", - "", - "MAINTENANCE", - " /doctor Diagnose and repair .sf/ state [audit|fix|heal] [scope]", - " /repair Switch to repair work mode and run diagnostics [--autonomous]", - " /tasks Background work surface [--refresh|--failed|--cancelled|--all]", - " /skills List discovered skills from .agents/skills/", - " /skills reload Reload skills from disk — picks up new/updated skill files", - " /skills --eval <name> Run eval cases for a skill", - " /reload Snapshot & reload agent, resume same session", - " /export Export milestone/slice results [--json|--markdown|--html] [--all]", - " /cleanup Remove merged branches or snapshots [branches|snapshots]", - " /worktree Manage worktrees from the TUI [list|merge|clean|remove]", - " /migrate Migrate .planning/ (v1) to .sf/ (v2) format", - " /remote Configure remote question delivery [slack|discord|status|disconnect]", - " /inspect Show SQLite DB diagnostics (schema, row counts, recent entries)", - " /update Update SF to the latest version via npm", - ]; - const showAll = args.trim().toLowerCase() === "all"; - ctx.ui.notify((showAll ? allLines : summaryLines).join("\n"), "info"); + const request = args.trim(); + if (request.length > 0 && request.toLowerCase() !== "all") { + ctx.ui.notify(keywordHelpLines(request).join("\n"), "info"); + return; + } + if (request.toLowerCase() === "all") { + ctx.ui.notify(groupedPublicHelpLines().join("\n"), "info"); + return; + } + const primary = PUBLIC_TOP_LEVEL_SUBCOMMANDS.filter((command) => + PRIMARY_COMMANDS.has(command.cmd), + ); + ctx.ui.notify( + [ + "SF — Singularity Forge workflow runtime\n", + "PRIMARY", + ...primary.map(commandLine), + "", + `Shift+Tab cycles runtime mode. ${formattedShortcutPair("dashboard")} opens /status.`, + "Type /<letters> to search secondary commands.", + "Use /help all for the grouped public command reference.", + ].join("\n"), + "info", + ); } export async function handleStatus(ctx) { const basePath = projectRoot(); diff --git a/src/resources/extensions/sf/commands/index.js b/src/resources/extensions/sf/commands/index.js index 954ec994c..ca826591f 100644 --- a/src/resources/extensions/sf/commands/index.js +++ b/src/resources/extensions/sf/commands/index.js @@ -2,6 +2,7 @@ import { importExtensionModule } from "@singularity-forge/coding-agent"; import { DIRECT_SF_COMMANDS, getSfTopLevelCommandCompletions, + PRIMARY_COMMANDS, SF_COMMAND_DESCRIPTION, } from "./catalog.js"; @@ -30,6 +31,7 @@ export function registerSFCommands(pi) { for (const command of DIRECT_SF_COMMANDS) { pi.registerCommand(command.cmd, { description: command.desc || SF_COMMAND_DESCRIPTION, + menuTier: PRIMARY_COMMANDS.has(command.cmd) ? "primary" : "secondary", getArgumentCompletions: (prefix) => getSfTopLevelCommandCompletions(command.cmd, prefix), handler: async (args, ctx) => { diff --git a/src/resources/extensions/sf/tests/direct-command-surface.test.mjs b/src/resources/extensions/sf/tests/direct-command-surface.test.mjs index 436622cef..2888904c9 100644 --- a/src/resources/extensions/sf/tests/direct-command-surface.test.mjs +++ b/src/resources/extensions/sf/tests/direct-command-surface.test.mjs @@ -7,7 +7,9 @@ import { DIRECT_SF_COMMAND_NAMES, getSfArgumentCompletions, getSfTopLevelCommandCompletions, + PRIMARY_COMMANDS, } from "../commands/catalog.js"; +import { showHelp } from "../commands/handlers/core.js"; import { registerSFCommands } from "../commands/index.js"; test("direct SF command surface registers workflow verbs without legacy sf namespace", () => { @@ -44,6 +46,34 @@ test("top_level_completions_keep_platform_owned_product_paths_visible", () => { assert.equal(labels.includes("permission-profile"), false); }); +test("primary_command_tier_is_the_five_command_memory_model", () => { + assert.deepEqual([...PRIMARY_COMMANDS].sort(), [ + "autonomous", + "discuss", + "help", + "next", + "quick", + ]); +}); + +test("help_keyword_routes_natural_language_to_public_commands", () => { + const messages = []; + showHelp( + { + ui: { + notify(message) { + messages.push(message); + }, + }, + }, + "I want to see my queue", + ); + + assert.equal(messages.length, 1); + assert.match(messages[0], /\/queue\b/); + assert.doesNotMatch(messages[0], /\/parallel\b/); +}); + test("direct command completions strip the already typed command name", () => { assert.deepEqual(getSfTopLevelCommandCompletions("autonomous", "--"), [ {