From 8cf3dce6fbfa41a58d076fda86a23cd2bcc9a708 Mon Sep 17 00:00:00 2001 From: can1357 Date: Sat, 8 Aug 2026 06:32:01 +0200 Subject: [PATCH] refactor(coding-agent): split builtin slash commands by domain - builtin-registry.ts held a 2341-line array of every command spec with its handler inlined, plus the autocomplete builders and the TUI dispatcher. - Specs moved verbatim into modes, collaboration, session, lifecycle, marketplace and control modules; completions moved to builtin-completions. - builtin-registry.ts is now a 174-line composition/dispatch module and keeps all 16 exports. Command order is unchanged, which matters because it drives autocomplete ordering and BUILTIN_SLASH_COMMAND_DEFS. --- .../slash-commands/builtin-collaboration.ts | 504 +++ .../src/slash-commands/builtin-completions.ts | 291 ++ .../src/slash-commands/builtin-control.ts | 78 + .../src/slash-commands/builtin-lifecycle.ts | 506 +++ .../src/slash-commands/builtin-marketplace.ts | 567 ++++ .../src/slash-commands/builtin-modes.ts | 518 +++ .../src/slash-commands/builtin-registry.ts | 3021 +---------------- .../src/slash-commands/builtin-session.ts | 592 ++++ 8 files changed, 3077 insertions(+), 3000 deletions(-) create mode 100644 packages/coding-agent/src/slash-commands/builtin-collaboration.ts create mode 100644 packages/coding-agent/src/slash-commands/builtin-completions.ts create mode 100644 packages/coding-agent/src/slash-commands/builtin-control.ts create mode 100644 packages/coding-agent/src/slash-commands/builtin-lifecycle.ts create mode 100644 packages/coding-agent/src/slash-commands/builtin-marketplace.ts create mode 100644 packages/coding-agent/src/slash-commands/builtin-modes.ts create mode 100644 packages/coding-agent/src/slash-commands/builtin-session.ts diff --git a/packages/coding-agent/src/slash-commands/builtin-collaboration.ts b/packages/coding-agent/src/slash-commands/builtin-collaboration.ts new file mode 100644 index 000000000..0bf8d71f3 --- /dev/null +++ b/packages/coding-agent/src/slash-commands/builtin-collaboration.ts @@ -0,0 +1,504 @@ +import { Spacer } from "@oh-my-pi/pi-tui"; +import { APP_NAME } from "@oh-my-pi/pi-utils"; +import { CollabGuestLink } from "../collab/guest"; +import { CollabHost } from "../collab/host"; +import type { SettingPath, SettingValue } from "../config/settings"; +import { settings } from "../config/settings"; +import { parseExportArgs } from "../export/html/args"; +import { shareSession } from "../export/share"; +import { theme } from "../modes/theme/theme"; +import type { InteractiveModeContext } from "../modes/types"; +import { extractLastCodeBlock, extractLastCommand } from "../modes/utils/copy-targets"; +import { urlHyperlinkAlways } from "../tui"; +import { copyToClipboard } from "../utils/clipboard"; +import { refreshStatusLine } from "./builtin-modes"; +import { CollabQrCodeComponent } from "./helpers/collab-qrcode"; +import { commandConsumed, errorMessage, parseSubcommand, usage } from "./helpers/parse"; +import type { SlashCommandSpec } from "./types"; + +/** Scheme-less display form of a browser deep link: accent + underline, OSC-8 linked to the full URL. */ +function collabWebLinkClickable(webLink: string): string { + const display = theme.fg("accent", `\x1b[4m${webLink.replace(/^https?:\/\//, "")}\x1b[24m`); + return urlHyperlinkAlways(webLink, display); +} + +/** Join hint printed by /collab: compact terminal link + clickable browser deep link. */ +function collabLinkHint(host: CollabHost, heading: string, view = false): string { + const bullet = theme.fg("accent", theme.format.bullet); + const link = view ? host.viewLink : host.link; + const webLink = view ? host.webViewLink : host.webLink; + return [ + theme.fg("success", heading), + ` ${bullet} ${theme.fg("muted", view ? "Watch from another terminal:" : "Join from another terminal:")} ${APP_NAME} join "${link}"`, + ` ${bullet} ${theme.fg("muted", "or any web browser:")} ${collabWebLinkClickable(webLink)}`, + theme.fg( + "dim", + view + ? "Anyone with this link can watch the session but cannot prompt the agent." + : "Anyone with the link can read the session and prompt the agent. Read-only link: /collab view", + ), + ].join("\n"); +} + +function showCollabQrCode(ctx: InteractiveModeContext, webLink: string): void { + try { + ctx.present([new Spacer(1), new CollabQrCodeComponent(webLink)]); + } catch (err) { + ctx.showError(`Failed to render collab QR code: ${errorMessage(err)}`); + } +} + +function showCollabLink(ctx: InteractiveModeContext, host: CollabHost, heading: string, view = false): void { + ctx.showStatus(collabLinkHint(host, heading, view), { dim: false }); + showCollabQrCode(ctx, view ? host.webViewLink : host.webLink); +} + +export const BUILTIN_COLLABORATION_SLASH_COMMANDS: ReadonlyArray = [ + { + name: "advisor", + description: "Toggle the advisor (a second model that reviews each turn and injects notes)", + acpDescription: "Toggle advisor", + acpInputHint: "[on|off|status|dump [raw]|configure]", + subcommands: [ + { name: "on", description: "Enable the advisor" }, + { name: "off", description: "Disable the advisor" }, + { name: "status", description: "Show advisor status" }, + { name: "dump", description: "Copy the advisor's transcript to clipboard", usage: "[raw]" }, + { name: "configure", description: "Open the advisor configuration editor (TUI)" }, + ], + allowArgs: true, + getTuiAutocompleteDescription: runtime => { + const stats = runtime.ctx.session.getAdvisorStats(); + if (stats.active && stats.advisors.length > 1) return `Advisor: on (${stats.advisors.length} advisors)`; + if (stats.active && stats.model) return `Advisor: on (${stats.model.provider}/${stats.model.id})`; + if (stats.configured) return "Advisor: configured, no model"; + return "Advisor: off"; + }, + handle: async (command, runtime) => { + const { verb, rest } = parseSubcommand(command.args); + if (!verb || verb === "toggle") { + const active = runtime.session.toggleAdvisorEnabled(); + const configured = runtime.session.isAdvisorEnabled(); + if (active) { + await runtime.output("Advisor enabled."); + } else if (configured) { + await runtime.output("Advisor setting enabled, but no model is assigned to the 'advisor' role."); + } else { + await runtime.output("Advisor disabled."); + } + return commandConsumed(); + } + if (verb === "on") { + const active = runtime.session.setAdvisorEnabled(true); + await runtime.output( + active ? "Advisor enabled." : "Advisor setting enabled, but no model is assigned to the 'advisor' role.", + ); + return commandConsumed(); + } + if (verb === "off") { + runtime.session.setAdvisorEnabled(false); + await runtime.output("Advisor disabled."); + return commandConsumed(); + } + if (verb === "status") { + await runtime.output(runtime.session.formatAdvisorStatus()); + return commandConsumed(); + } + if (verb === "dump") { + const isRaw = rest.toLowerCase() === "raw"; + const text = runtime.session.formatAdvisorHistoryAsText({ compact: !isRaw }); + await runtime.output(text ?? "Advisor is not active for this session."); + return commandConsumed(); + } + if (verb === "configure") { + await runtime.output( + "/advisor configure opens an interactive editor and is only available in the interactive TUI.", + ); + return commandConsumed(); + } + return usage("Usage: /advisor [on|off|status|dump [raw]|configure]", runtime); + }, + handleTui: async (command, runtime) => { + const { verb, rest } = parseSubcommand(command.args); + if (!verb || verb === "toggle") { + const active = runtime.ctx.session.toggleAdvisorEnabled(); + const configured = runtime.ctx.session.isAdvisorEnabled(); + if (active) { + runtime.ctx.showStatus("Advisor enabled."); + } else if (configured) { + runtime.ctx.showStatus("Advisor setting enabled, but no model is assigned to the 'advisor' role."); + } else { + runtime.ctx.showStatus("Advisor disabled."); + } + refreshStatusLine(runtime.ctx); + runtime.ctx.editor.setText(""); + return; + } + if (verb === "on") { + const active = runtime.ctx.session.setAdvisorEnabled(true); + runtime.ctx.showStatus( + active ? "Advisor enabled." : "Advisor setting enabled, but no model is assigned to the 'advisor' role.", + ); + refreshStatusLine(runtime.ctx); + runtime.ctx.editor.setText(""); + return; + } + if (verb === "off") { + runtime.ctx.session.setAdvisorEnabled(false); + runtime.ctx.showStatus("Advisor disabled."); + refreshStatusLine(runtime.ctx); + runtime.ctx.editor.setText(""); + return; + } + if (verb === "status") { + await runtime.ctx.handleAdvisorStatusCommand(); + runtime.ctx.editor.setText(""); + return; + } + if (verb === "dump") { + const isRaw = rest.toLowerCase() === "raw"; + runtime.ctx.handleAdvisorDumpCommand(isRaw); + runtime.ctx.editor.setText(""); + return; + } + if (verb === "configure") { + runtime.ctx.showAdvisorConfigure(); + runtime.ctx.editor.setText(""); + return; + } + runtime.ctx.showStatus("Usage: /advisor [on|off|status|dump [raw]|configure]"); + runtime.ctx.editor.setText(""); + }, + }, + { + name: "export", + description: "Export session to HTML file", + inlineHint: "[--themes] [path]", + allowArgs: true, + handle: async (command, runtime) => { + try { + const { outputPath, useUserThemes } = parseExportArgs(command.args); + if (outputPath === "--copy" || outputPath === "clipboard" || outputPath === "copy") { + return usage("Use /dump to copy the session to clipboard.", runtime); + } + const filePath = await runtime.session.exportToHtml(outputPath, useUserThemes); + await runtime.output(`Session exported to: ${filePath}`); + return commandConsumed(); + } catch (err) { + return usage(`Failed to export session: ${errorMessage(err)}`, runtime); + } + }, + handleTui: async (command, runtime) => { + await runtime.ctx.handleExportCommand(command.text); + runtime.ctx.editor.setText(""); + }, + }, + { + name: "dump", + description: "Copy session transcript to clipboard (and write LLM request JSON to tmp)", + acpDescription: "Return full transcript as plain text, with LLM request JSON path", + allowArgs: true, + handle: async (_command, runtime) => { + const text = runtime.session.formatSessionAsText(); + if (!text) { + await runtime.output("No messages to dump yet."); + return commandConsumed(); + } + let sidecarPath: string | undefined; + try { + sidecarPath = await runtime.session.dumpLlmRequestToTmpDir(); + } catch { + // Sidecar is best-effort; the transcript is still output below. + } + const lines = [text]; + if (sidecarPath) + lines.push( + "", + `LLM request JSON: ${sidecarPath}`, + "This file persists on disk and may contain raw context/secrets — treat accordingly.", + ); + await runtime.output(lines.join("\n")); + return commandConsumed(); + }, + handleTui: async (_command, runtime) => { + await runtime.ctx.handleDumpCommand(); + runtime.ctx.editor.setText(""); + }, + }, + { + name: "share", + description: "Share session via an encrypted link (share server or secret gist)", + handle: async (_command, runtime) => { + try { + const result = await shareSession(runtime.sessionManager, { + serverUrl: runtime.settings.get("share.serverUrl"), + store: runtime.settings.get("share.store"), + state: runtime.session.state, + obfuscator: runtime.settings.get("share.redactSecrets") ? runtime.session.obfuscator : undefined, + }); + const lines = [`Share URL: ${result.url}`]; + if (result.gistUrl) lines.push(`Gist: ${result.gistUrl}`); + if (result.truncated) lines.push("Note: large content was trimmed to fit the share size limit."); + await runtime.output(lines.join("\n")); + return commandConsumed(); + } catch (err) { + return usage(`Failed to share session: ${errorMessage(err)}`, runtime); + } + }, + handleTui: async (_command, runtime) => { + await runtime.ctx.handleShareCommand(); + runtime.ctx.editor.setText(""); + }, + }, + { + name: "collab", + description: "Share this session live via a relay", + inlineHint: "[start|view|stop|status] [relayUrl]", + subcommands: [ + { name: "view", description: "Share a read-only link (guests can watch, not prompt)" }, + { name: "status", description: "Show link + participants" }, + { name: "stop", description: "Stop sharing" }, + ], + allowArgs: true, + getTuiAutocompleteDescription: runtime => { + if (runtime.ctx.collabHost) { + return `Collab: hosting (${Math.max(0, runtime.ctx.collabHost.participants.length - 1)} guests)`; + } + if (runtime.ctx.collabGuest?.readOnly) return "Collab: read-only guest"; + if (runtime.ctx.collabGuest) return "Collab: guest"; + return "Collab: off"; + }, + handleTui: async (command, runtime) => { + const ctx = runtime.ctx; + ctx.editor.setText(""); + const args = command.args.trim(); + const { verb, rest } = parseSubcommand(args); + if (verb === "stop") { + if (!ctx.collabHost) { + ctx.showStatus("Not hosting a collab session"); + return; + } + await ctx.collabHost.stop("host stopped"); + ctx.showStatus("Collab stopped"); + return; + } + if (verb === "status") { + if (ctx.collabHost) { + const names = ctx.collabHost.participants.map(p => + p.role === "host" ? `${p.name} (host)` : p.readOnly ? `${p.name} (view-only)` : p.name, + ); + ctx.showStatus(`Collab: ${names.join(", ")} — ${collabWebLinkClickable(ctx.collabHost.webLink)}`); + } else if (ctx.collabGuest) { + ctx.showStatus( + ctx.collabGuest.readOnly + ? "In a collab session as a read-only guest (/leave to exit)" + : "In a collab session as a guest (/leave to exit)", + ); + } else { + ctx.showStatus("Not in a collab session"); + } + return; + } + if (ctx.collabGuest) { + ctx.showError("Already in a collab session as a guest (/leave first)"); + return; + } + const knownStartVerb = verb === "start" || verb === "view"; + const view = verb === "view"; + if (ctx.collabHost) { + showCollabLink( + ctx, + ctx.collabHost, + view ? "Read-only collab session active" : "Collab session active", + view, + ); + return; + } + const explicitUrl = knownStartVerb ? rest : args; + const relayInput = explicitUrl || ctx.settings.get("collab.relayUrl") || ""; + if (!relayInput) { + ctx.showError( + "No relay configured. Set collab.relayUrl in /settings or pass one: /collab relay.example.com", + ); + return; + } + // Scheme-less relay args default to wss (ws:// must be spelled out for localhost). + const relayUrl = relayInput.includes("://") ? relayInput : `wss://${relayInput}`; + const webUrl = ctx.settings.get("collab.webUrl") || ""; + const host = new CollabHost(ctx); + try { + await host.start(relayUrl, webUrl); + } catch (err) { + ctx.showError(`Failed to start collab session: ${errorMessage(err)}`); + return; + } + ctx.collabHost = host; + showCollabLink(ctx, host, "Collab session started!", view); + }, + }, + { + name: "join", + description: "Join a shared collab session", + inlineHint: "", + allowArgs: true, + handleTui: async (command, runtime) => { + const ctx = runtime.ctx; + ctx.editor.setText(""); + const link = command.args.trim(); + if (!link) { + ctx.showError("Usage: /join "); + return; + } + if (ctx.collabHost) { + ctx.showError("Stop hosting first (/collab stop)"); + return; + } + if (ctx.collabGuest) { + ctx.showError("Already in a collab session (/leave first)"); + return; + } + try { + await new CollabGuestLink(ctx).join(link); + } catch (err) { + ctx.showError(`Failed to join collab session: ${errorMessage(err)}`); + } + }, + }, + { + name: "leave", + description: "Leave the collab session", + getTuiAutocompleteDescription: runtime => { + if (runtime.ctx.collabHost) return "Leave collab: hosting"; + if (runtime.ctx.collabGuest) return "Leave collab: guest"; + return "Leave collab: not in collab"; + }, + handleTui: async (_command, runtime) => { + const ctx = runtime.ctx; + ctx.editor.setText(""); + if (ctx.collabGuest) { + await ctx.collabGuest.leave("left"); + return; + } + if (ctx.collabHost) { + await ctx.collabHost.stop("host stopped"); + ctx.showStatus("Collab stopped"); + return; + } + ctx.showStatus("Not in a collab session"); + }, + }, + { + name: "browser", + description: "Toggle browser headless vs visible mode", + acpInputHint: "[headless|visible]", + subcommands: [ + { name: "headless", description: "Switch to headless mode" }, + { name: "visible", description: "Switch to visible mode" }, + ], + allowArgs: true, + getTuiAutocompleteDescription: runtime => { + if (!runtime.ctx.settings.get("browser.enabled" as SettingPath)) return "Browser: disabled"; + return runtime.ctx.settings.get("browser.headless" as SettingPath) ? "Browser: headless" : "Browser: visible"; + }, + handle: async (command, runtime) => { + const arg = command.args.toLowerCase(); + const enabled = runtime.settings.get("browser.enabled" as SettingPath) as boolean; + if (!enabled) return usage("Browser tool is disabled (enable in settings).", runtime); + const current = runtime.settings.get("browser.headless" as SettingPath) as boolean; + let next = current; + if (!arg) next = !current; + else if (arg === "headless" || arg === "hidden") next = true; + else if (arg === "visible" || arg === "show" || arg === "headful") next = false; + else return usage("Usage: /browser [headless|visible]", runtime); + runtime.settings.set("browser.headless" as SettingPath, next as SettingValue); + const tool = runtime.session.getToolByName("browser"); + if (tool && "restartForModeChange" in tool) { + try { + await (tool as { restartForModeChange: () => Promise }).restartForModeChange(); + } catch (err) { + // Setting was already mutated; surface the restart failure so the + // user knows the browser is in an inconsistent state. + await runtime.output( + `Browser mode set to ${next ? "headless" : "visible"}, but restart failed: ${errorMessage(err)}`, + ); + return commandConsumed(); + } + } + await runtime.output(`Browser mode: ${next ? "headless" : "visible"}`); + return commandConsumed(); + }, + handleTui: async (command, runtime) => { + const arg = command.args.toLowerCase(); + const current = settings.get("browser.headless" as SettingPath) as boolean; + let next = current; + if (!(settings.get("browser.enabled" as SettingPath) as boolean)) { + runtime.ctx.showWarning("Browser tool is disabled (enable in settings)"); + runtime.ctx.editor.setText(""); + return; + } + if (!arg) { + next = !current; + } else if (arg === "headless" || arg === "hidden") { + next = true; + } else if (arg === "visible" || arg === "show" || arg === "headful") { + next = false; + } else { + runtime.ctx.showStatus("Usage: /browser [headless|visible]"); + runtime.ctx.editor.setText(""); + return; + } + settings.set("browser.headless" as SettingPath, next as SettingValue); + const tool = runtime.ctx.session.getToolByName("browser"); + if (tool && "restartForModeChange" in tool) { + try { + await (tool as { restartForModeChange: () => Promise }).restartForModeChange(); + } catch (error) { + runtime.ctx.showWarning(`Failed to restart browser: ${errorMessage(error)}`); + runtime.ctx.editor.setText(""); + return; + } + } + runtime.ctx.showStatus(`Browser mode: ${next ? "headless" : "visible"}`); + runtime.ctx.editor.setText(""); + }, + }, + { + name: "copy", + description: "Pick text or code from the conversation to copy", + allowArgs: true, + handleTui: async (command, runtime) => { + const arg = command.args.trim().toLowerCase(); + if (!arg) { + runtime.ctx.showCopySelector(); + runtime.ctx.editor.setText(""); + return; + } + if (arg === "code") { + const block = extractLastCodeBlock(runtime.ctx.session.messages); + if (!block) { + runtime.ctx.showStatus("No code block to copy."); + runtime.ctx.editor.setText(""); + return; + } + await copyToClipboard(block.code); + runtime.ctx.showStatus("Copied code block to clipboard"); + runtime.ctx.editor.setText(""); + return; + } + if (arg === "cmd" || arg === "command") { + const lastCommand = extractLastCommand(runtime.ctx.session.messages); + if (!lastCommand) { + runtime.ctx.showStatus("No command to copy."); + runtime.ctx.editor.setText(""); + return; + } + await copyToClipboard(lastCommand.code); + runtime.ctx.showStatus(`Copied ${lastCommand.kind === "bash" ? "bash command" : "eval code"} to clipboard`); + runtime.ctx.editor.setText(""); + return; + } + runtime.ctx.showStatus("Usage: /copy [code|cmd]"); + runtime.ctx.editor.setText(""); + }, + }, +]; diff --git a/packages/coding-agent/src/slash-commands/builtin-completions.ts b/packages/coding-agent/src/slash-commands/builtin-completions.ts new file mode 100644 index 000000000..92e92744c --- /dev/null +++ b/packages/coding-agent/src/slash-commands/builtin-completions.ts @@ -0,0 +1,291 @@ +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import type { AutocompleteItem } from "@oh-my-pi/pi-tui"; +import { getMCPConfigPath, getProjectDir, logger } from "@oh-my-pi/pi-utils"; +import { readMCPConfigFile } from "../mcp/config-writer"; +import { collectMcpServerNames } from "../modes/controllers/mcp-command-controller"; +import { expandTilde } from "../tools/path-utils"; +import type { SubcommandDef, TuiSlashCommandRuntime } from "./types"; + +/** + * Build getArgumentCompletions from declarative subcommand definitions. + * Returns subcommand names filtered by prefix in the dropdown. + */ +export function buildArgumentCompletions(subcommands: SubcommandDef[]): (prefix: string) => AutocompleteItem[] | null { + return (argumentPrefix: string) => { + if (argumentPrefix.includes(" ")) return null; // past the subcommand + const lower = argumentPrefix.toLowerCase(); + const matches = subcommands + .filter(s => s.name.startsWith(lower)) + .map(s => ({ + value: `${s.name} `, + label: s.name, + description: s.description, + hint: s.usage, + })); + return matches.length > 0 ? matches : null; + }; +} + +/** /mcp subcommands whose argument is a server name (per their `usage: "..."`). */ +const MCP_SERVER_NAME_SUBCOMMANDS: Readonly> = { + enable: true, + disable: true, + test: true, + remove: true, + reconnect: true, + reauth: true, + unauth: true, +}; + +/** Subcommands that accept names found only in `userConfig.disabledServers`. */ +const MCP_DISABLED_ONLY_ELIGIBLE_SUBCOMMANDS: Readonly> = { + enable: true, + disable: true, +}; + +/** + * Subcommands that accept configured servers whose `enabled` flag is false. + * `unauth` can clear persisted credentials without connecting; test, + * reconnect, and reauth explicitly require an enabled server. + */ +const MCP_DISABLED_CONFIG_ELIGIBLE_SUBCOMMANDS: Readonly> = { + enable: true, + disable: true, + unauth: true, +}; + +/** + * Build getArgumentCompletions for /mcp. Delegates to the generic + * declarative subcommand completer while the subcommand name itself is + * still being typed, then switches to MCP server-name completion (sourced + * from {@link collectMcpServerNames}) once a recognized server-name + * subcommand (enable/disable/test/remove/reconnect/reauth/unauth) is + * followed by a space. `remove` gets its own scope-aware completions (see + * {@link buildMcpRemoveCompletions}) since — unlike the others — + * it only ever succeeds against a config-file entry. Subcommands with a + * different argument shape (add, smithery-search, ...) get no argument + * completion. + */ +export function buildMcpArgumentCompletions( + subcommands: SubcommandDef[], + runtime: TuiSlashCommandRuntime, +): (argumentPrefix: string) => Promise { + const genericCompletions = buildArgumentCompletions(subcommands); + return async (argumentPrefix: string) => { + const spaceIndex = argumentPrefix.indexOf(" "); + if (spaceIndex === -1) return genericCompletions(argumentPrefix); + + const rawSubcommand = argumentPrefix.slice(0, spaceIndex); + const lowerSubcommand = rawSubcommand.toLowerCase(); + if (MCP_SERVER_NAME_SUBCOMMANDS[lowerSubcommand] !== true) return null; + const namePrefix = argumentPrefix.slice(spaceIndex + 1).toLowerCase(); + if (lowerSubcommand === "remove") { + return await buildMcpRemoveCompletions(rawSubcommand, namePrefix); + } + + let serverNames: string[]; + try { + serverNames = await collectMcpServerNames( + runtime.ctx, + undefined, + MCP_DISABLED_ONLY_ELIGIBLE_SUBCOMMANDS[lowerSubcommand] === true, + MCP_DISABLED_CONFIG_ELIGIBLE_SUBCOMMANDS[lowerSubcommand] === true, + ); + } catch (error) { + logger.warn("MCP server-name autocomplete failed to read config", { error }); + return null; + } + const matches: AutocompleteItem[] = serverNames + .filter(name => name.toLowerCase().startsWith(namePrefix)) + .map(name => ({ value: `${rawSubcommand} ${name} `, label: name })); + return matches.length > 0 ? matches : null; + }; +} + +/** + * Build `/mcp remove ` completions. Unlike the other server-name + * subcommands, `#handleRemove` only ever succeeds against a config-file + * `mcpServers` entry in the target scope (project by default, user with an + * explicit `--scope user`) — a purely runtime-discovered server has no + * config entry to remove and always fails with `Server "" not found + * in config.`. Completions are therefore restricted to config-file + * names, and a name that exists only in the user config is completed with + * `--scope user` appended so the inserted command is directly executable. + */ +async function buildMcpRemoveCompletions( + rawSubcommand: string, + namePrefix: string, +): Promise { + const cwd = getProjectDir(); + let projectNames: string[]; + let userNames: string[]; + try { + const [projectConfig, userConfig] = await Promise.all([ + readMCPConfigFile(getMCPConfigPath("project", cwd)), + readMCPConfigFile(getMCPConfigPath("user", cwd)), + ]); + projectNames = Object.keys(projectConfig.mcpServers ?? {}); + userNames = Object.keys(userConfig.mcpServers ?? {}); + } catch (error) { + logger.warn("MCP remove autocomplete failed to read config", { error }); + return null; + } + + const projectNameSet = new Set(projectNames); + const allNames = new Set([...projectNames, ...userNames]); + const matches: AutocompleteItem[] = [...allNames] + .filter(name => name.toLowerCase().startsWith(namePrefix)) + .map(name => + projectNameSet.has(name) + ? { value: `${rawSubcommand} ${name} `, label: name } + : { value: `${rawSubcommand} ${name} --scope user `, label: `${name} (user)` }, + ) + .sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: "base" })); + return matches.length > 0 ? matches : null; +} + +/** + * Build getInlineHint from declarative subcommand definitions. + * Shows remaining completion + usage as dim ghost text after cursor. + */ +export function buildSubcommandInlineHint(subcommands: SubcommandDef[]): (argumentText: string) => string | null { + return (argumentText: string) => { + const trimmed = argumentText.trimStart(); + const spaceIndex = trimmed.indexOf(" "); + + if (spaceIndex === -1) { + // Still typing subcommand name — show remaining chars + usage + const prefix = trimmed.toLowerCase(); + if (prefix.length === 0) return null; + const match = subcommands.find(s => s.name.startsWith(prefix)); + if (!match) return null; + const remaining = match.name.slice(prefix.length); + return remaining + (match.usage ? ` ${match.usage}` : ""); + } + + // Subcommand typed — show remaining usage params + const subName = trimmed.slice(0, spaceIndex).toLowerCase(); + const afterSub = trimmed.slice(spaceIndex + 1); + const sub = subcommands.find(s => s.name === subName); + if (!sub?.usage) return null; + + if (afterSub.length > 0) { + const usageParts = sub.usage.split(" "); + const inputParts = afterSub.trim().split(/\s+/); + const remaining = usageParts.slice(inputParts.length); + return remaining.length > 0 ? remaining.join(" ") : null; + } + + return sub.usage; + }; +} + +/** + * Build getInlineHint for commands with a simple static hint string. + * Shows the hint only when no arguments have been typed yet. + */ +export function buildStaticInlineHint(hint: string): (argumentText: string) => string | null { + return (argumentText: string) => (argumentText.trim().length === 0 ? hint : null); +} + +/** + * Build getArgumentCompletions that suggests directories relative to the + * current project directory. Used by /move so users can Tab-complete the + * destination directory. + */ +export function buildDirectoryArgumentCompletions(): (prefix: string) => Promise { + return async (argumentPrefix: string) => { + const prefix = argumentPrefix.trim(); + + const cwd = getProjectDir(); + const expandedPrefix = expandTilde(prefix); + const isAbsolute = path.isAbsolute(expandedPrefix); + + let searchDir: string; + let searchPrefix: string; + if ( + prefix === "" || + prefix === "." || + prefix === "./" || + prefix === ".." || + prefix === "../" || + prefix === "~" || + prefix === "~/" || + prefix === "/" + ) { + searchDir = isAbsolute ? expandedPrefix : path.join(cwd, expandedPrefix); + searchPrefix = ""; + } else if (expandedPrefix.endsWith("/")) { + searchDir = isAbsolute ? expandedPrefix : path.join(cwd, expandedPrefix); + searchPrefix = ""; + } else { + const dir = path.dirname(expandedPrefix); + searchDir = isAbsolute ? dir : path.join(cwd, dir); + searchPrefix = path.basename(expandedPrefix); + } + + try { + const entries = await fs.readdir(searchDir, { withFileTypes: true }); + const suggestions: AutocompleteItem[] = []; + for (const entry of entries) { + if (!entry.name.toLowerCase().startsWith(searchPrefix.toLowerCase())) continue; + if (entry.name === ".git") continue; + + let isDirectory = entry.isDirectory(); + if (!isDirectory && entry.isSymbolicLink()) { + try { + isDirectory = (await fs.stat(path.join(searchDir, entry.name))).isDirectory(); + } catch { + continue; + } + } + if (!isDirectory) continue; + + const absoluteValue = path.join(searchDir, entry.name); + const displayValue = buildDirectoryCompletionDisplayValue(prefix, absoluteValue, cwd); + suggestions.push({ value: displayValue, label: `${entry.name}/` }); + } + suggestions.sort((a, b) => a.label.localeCompare(b.label)); + return suggestions.length > 0 ? suggestions : null; + } catch { + return null; + } + }; +} +function buildDirectoryCompletionDisplayValue(prefix: string, absoluteValue: string, cwd: string): string { + // Preserve the user's prefix style where possible, but always return a + // value that /move can resolve (absolute or relative) without escaping. + const normalized = path.normalize(absoluteValue); + + if (prefix.startsWith("~/")) { + const home = os.homedir(); + const homeRelative = path.relative(home, normalized); + return `~/${homeRelative.replaceAll("\\", "/")}/`; + } + if (prefix === "~") { + const home = os.homedir(); + const homeRelative = path.relative(home, normalized); + return `~/${homeRelative.replaceAll("\\", "/")}/`; + } + if (prefix.startsWith("/")) { + return `${normalized.replaceAll("\\", "/")}/`; + } + if (prefix.startsWith("./")) { + const relative = path.relative(cwd, normalized); + return `./${relative.replaceAll("\\", "/")}/`; + } + if (prefix.startsWith("../")) { + const relative = path.relative(cwd, normalized); + return `${relative.replaceAll("\\", "/")}/`; + } + if (prefix === "..") { + const relative = path.relative(cwd, normalized); + return `${relative.replaceAll("\\", "/")}/`; + } + + // Default: relative to cwd. + const relative = path.relative(cwd, normalized); + return `${relative.replaceAll("\\", "/")}/`; +} diff --git a/packages/coding-agent/src/slash-commands/builtin-control.ts b/packages/coding-agent/src/slash-commands/builtin-control.ts new file mode 100644 index 000000000..d1de71150 --- /dev/null +++ b/packages/coding-agent/src/slash-commands/builtin-control.ts @@ -0,0 +1,78 @@ +import { runPauseScreen } from "../modes/components/pause-screen"; +import { shutdownHandlerTui } from "./builtin-lifecycle"; +import { commandConsumed, errorMessage, usage } from "./helpers/parse"; +import type { SlashCommandSpec } from "./types"; + +export const BUILTIN_CONTROL_SLASH_COMMANDS: ReadonlyArray = [ + { + name: "force", + description: "Force next turn to use a specific tool", + aliases: ["force:"], + inlineHint: " [prompt]", + allowArgs: true, + getTuiAutocompleteDescription: runtime => { + const count = runtime.ctx.session.getActiveToolNames().length; + return count === 0 ? "Force: no active tools" : `Force: ${count} active tools`; + }, + handle: async (command, runtime) => { + const spaceIdx = command.args.indexOf(" "); + const toolName = spaceIdx === -1 ? command.args : command.args.slice(0, spaceIdx); + const prompt = spaceIdx === -1 ? "" : command.args.slice(spaceIdx + 1).trim(); + if (!toolName) return usage("Usage: /force: [prompt]", runtime); + try { + runtime.session.setForcedToolChoice(toolName); + } catch (err) { + return usage(errorMessage(err), runtime); + } + await runtime.output(`Next turn forced to use ${toolName}.`); + return prompt ? { prompt } : commandConsumed(); + }, + handleTui: (command, runtime) => { + const spaceIdx = command.args.indexOf(" "); + const toolName = spaceIdx === -1 ? command.args : command.args.slice(0, spaceIdx); + const prompt = spaceIdx === -1 ? "" : command.args.slice(spaceIdx + 1).trim(); + + if (!toolName) { + runtime.ctx.showError("Usage: /force: [prompt]"); + runtime.ctx.editor.setText(""); + return; + } + + try { + runtime.ctx.session.setForcedToolChoice(toolName); + runtime.ctx.showStatus(`Next turn forced to use ${toolName}.`); + } catch (error) { + runtime.ctx.showError(errorMessage(error)); + runtime.ctx.editor.setText(""); + return; + } + + runtime.ctx.editor.setText(""); + + // If a prompt was provided, pass it through as input + if (prompt) return { prompt }; + }, + }, + { + name: "live", + description: "Start Codex-backed realtime voice mode", + handleTui: async (_command, runtime) => { + runtime.ctx.editor.setText(""); + await runtime.ctx.handleLiveCommand(); + }, + }, + { + name: "pause", + description: "Freeze all agents (main, subagents, advisor) until resumed", + handleTui: async (_command, runtime) => { + runtime.ctx.editor.setText(""); + await runPauseScreen(runtime.ctx); + }, + }, + { + name: "quit", + aliases: ["q"], + description: "Quit the application", + handleTui: shutdownHandlerTui, + }, +]; diff --git a/packages/coding-agent/src/slash-commands/builtin-lifecycle.ts b/packages/coding-agent/src/slash-commands/builtin-lifecycle.ts new file mode 100644 index 000000000..5c999ce5e --- /dev/null +++ b/packages/coding-agent/src/slash-commands/builtin-lifecycle.ts @@ -0,0 +1,506 @@ +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import { setProjectDir } from "@oh-my-pi/pi-utils"; +import { applyProviderGlobalsFromSettings } from "../config/provider-globals"; +import { memoryStatsUnavailableMessage, resolveMemoryBackend } from "../memory-backend"; +import type { FreshSessionResult } from "../session/agent-session"; +import { COMPACT_MODES, parseCompactArgs } from "../session/compact-modes"; +import { resolveResumableSession } from "../session/session-listing"; +import { formatShakeSummary, type ShakeMode } from "../session/shake-types"; +import { resolveToCwd } from "../tools/path-utils"; +import { commandConsumed, errorMessage, usage } from "./helpers/parse"; +import { handleSshAcp } from "./helpers/ssh"; +import type { + ParsedSlashCommand, + SlashCommandResult, + SlashCommandRuntime, + SlashCommandSpec, + TuiSlashCommandRuntime, +} from "./types"; + +function formatFreshSessionResult(result: FreshSessionResult): string { + const stateLabel = result.closedProviderSessions === 1 ? "provider state" : "provider states"; + return `Fresh provider session started (${result.closedProviderSessions} ${stateLabel} pruned).`; +} + +export const shutdownHandlerTui = ( + _command: ParsedSlashCommand, + runtime: TuiSlashCommandRuntime, +): SlashCommandResult => { + runtime.ctx.editor.setText(""); + void runtime.ctx.shutdown(); + return commandConsumed(); +}; + +/** Parse the `/shake` subcommand into a {@link ShakeMode}; empty defaults to elide. */ +function parseShakeMode(args: string): ShakeMode | { error: string } { + const verb = args.trim().toLowerCase(); + if (verb === "" || verb === "elide") return "elide"; + if (verb === "images") return "images"; + return { error: `Unknown /shake mode "${verb}". Use elide or images.` }; +} + +/** Format the session's workspace directories (cwd + additional) for display. */ +function formatWorkspaceDirectories(runtime: SlashCommandRuntime, note?: string): string { + const cwd = runtime.sessionManager.getCwd(); + const additional = runtime.sessionManager.getAdditionalDirectories(); + const lines = ["Workspace directories:", ` ${cwd} (working directory)`, ...additional.map(d => ` ${d}`)]; + return note ? `${note}\n${lines.join("\n")}` : lines.join("\n"); +} + +export const BUILTIN_LIFECYCLE_SLASH_COMMANDS: ReadonlyArray = [ + { + name: "ssh", + description: "Manage SSH hosts (add, list, remove)", + acpDescription: "Manage SSH connections", + inlineHint: "", + subcommands: [ + { + name: "add", + description: "Add an SSH host", + usage: " --host [--user ] [--port ] [--key ]", + }, + { name: "list", description: "List all configured SSH hosts" }, + { name: "remove", description: "Remove an SSH host", usage: " [--scope project|user]" }, + { name: "help", description: "Show help message" }, + ], + allowArgs: true, + handle: handleSshAcp, + handleTui: async (command, runtime) => { + runtime.ctx.editor.setText(""); + await runtime.ctx.handleSSHCommand(command.text); + }, + }, + { + name: "new", + description: "Start a new session", + handleTui: async (_command, runtime) => { + runtime.ctx.editor.setText(""); + await runtime.ctx.handleClearCommand(); + }, + }, + { + name: "fresh", + description: "Reset provider stream state without changing the local transcript", + getTuiAutocompleteDescription: runtime => + runtime.ctx.session.isStreaming ? "Fresh: unavailable while streaming" : "Fresh: ready", + handle: async (_command, runtime) => { + const result = runtime.session.freshSession(); + if (!result) { + await runtime.output( + "Wait for the current response to finish or abort it before refreshing provider state.", + ); + return commandConsumed(); + } + await runtime.output(formatFreshSessionResult(result)); + return commandConsumed(); + }, + handleTui: async (_command, runtime) => { + runtime.ctx.editor.setText(""); + await runtime.ctx.handleFreshCommand(); + }, + }, + { + name: "clear", + description: "Clear the conversation context in place, keeping the session", + getTuiAutocompleteDescription: runtime => + runtime.ctx.session.isStreaming ? "Clear: unavailable while streaming" : "Clear: drop context, keep session", + handleTui: async (_command, runtime) => { + runtime.ctx.editor.setText(""); + await runtime.ctx.handleResetContextCommand(); + }, + }, + { + name: "drop", + description: "Delete the current session and start a new one", + handleTui: async (_command, runtime) => { + runtime.ctx.editor.setText(""); + await runtime.ctx.handleDropCommand(); + }, + }, + { + name: "compact", + description: "Manually compact the session context", + acpDescription: "Compact the conversation", + subcommands: COMPACT_MODES.map(mode => ({ + name: mode.name, + description: mode.description, + usage: mode.rejectsFocus ? undefined : "[focus]", + })), + acpInputHint: `[${COMPACT_MODES.map(mode => mode.name).join("|")}] [focus]`, + allowArgs: true, + getTuiAutocompleteDescription: runtime => { + const usage = runtime.ctx.session.getContextUsage(); + return usage ? `Compact: context ${Math.round(usage.percent)}% used` : "Compact: context unavailable"; + }, + handle: async (command, runtime) => { + const parsed = parseCompactArgs(command.args); + if ("error" in parsed) return usage(parsed.error, runtime); + const before = runtime.session.getContextUsage?.(); + const beforeTokens = before?.tokens; + try { + await runtime.session.compact(parsed.instructions, parsed.mode ? { mode: parsed.mode } : undefined); + } catch (err) { + // Compaction precondition failures (no model, already compacted, too + // small) and provider errors propagate as plain Errors; surface them + // via runtime.output so they don't fail the ACP prompt turn. + return usage(`Compaction failed: ${errorMessage(err)}`, runtime); + } + const after = runtime.session.getContextUsage?.(); + const afterTokens = after?.tokens; + if (beforeTokens != null && afterTokens != null) { + const saved = beforeTokens - afterTokens; + await runtime.output(`Compaction complete. Tokens: ${beforeTokens} -> ${afterTokens} (saved ${saved}).`); + } else { + await runtime.output("Compaction complete."); + } + return commandConsumed(); + }, + handleTui: async (command, runtime) => { + const parsed = parseCompactArgs(command.args); + runtime.ctx.editor.setText(""); + if ("error" in parsed) { + runtime.ctx.showWarning(parsed.error); + return; + } + await runtime.ctx.handleCompactCommand(parsed.instructions, parsed.mode); + }, + }, + { + name: "shake", + description: "Drop heavy content from context (tool results, large blocks)", + acpDescription: "Shake heavy content out of the conversation context", + subcommands: [ + { name: "elide", description: "Strip tool results + large blocks (default)" }, + { name: "images", description: "Strip image blocks" }, + ], + acpInputHint: "[elide|images]", + allowArgs: true, + handle: async (command, runtime) => { + const mode = parseShakeMode(command.args); + if (typeof mode !== "string") return usage(mode.error, runtime); + const result = await runtime.session.shake(mode); + await runtime.output(formatShakeSummary(result)); + return commandConsumed(); + }, + handleTui: async (command, runtime) => { + runtime.ctx.editor.setText(""); + const mode = parseShakeMode(command.args); + if (typeof mode !== "string") { + runtime.ctx.showWarning(mode.error); + return; + } + await runtime.ctx.handleShakeCommand(mode); + }, + }, + { + name: "handoff", + description: "Hand off session context to a new session", + inlineHint: "[focus instructions]", + allowArgs: true, + handleTui: async (command, runtime) => { + const customInstructions = command.args || undefined; + runtime.ctx.editor.setText(""); + await runtime.ctx.handleHandoffCommand(customInstructions); + }, + }, + { + name: "resume", + description: "Resume a different session", + inlineHint: "[session id|@claude|@codex]", + allowArgs: true, + handleTui: async (command, runtime) => { + const sessionArg = command.args.trim(); + runtime.ctx.editor.setText(""); + const foreignSource = sessionArg === "@claude" ? "claude" : sessionArg === "@codex" ? "codex" : undefined; + if (foreignSource) { + runtime.ctx.showSessionSelector(foreignSource); + return; + } + if (!sessionArg) { + runtime.ctx.showSessionSelector(); + return; + } + const match = await resolveResumableSession( + sessionArg, + runtime.ctx.sessionManager.getCwd(), + runtime.ctx.sessionManager.getSessionDir(), + { allowGlobalFallback: true }, + ); + if (!match) { + runtime.ctx.showError(`Session "${sessionArg}" not found`); + return; + } + await runtime.ctx.handleResumeSession(match.session.path); + }, + }, + { + name: "btw", + description: "Ask an ephemeral side question using the current session context", + inlineHint: "", + allowArgs: true, + handleTui: async (command, runtime) => { + const question = command.text.slice(`/${command.name}`.length).trim(); + runtime.ctx.editor.setText(""); + await runtime.ctx.handleBtwCommand(question); + }, + }, + { + name: "tan", + description: "Run a full background agent on tangential work", + inlineHint: "", + allowArgs: true, + handleTui: async (command, runtime) => { + const work = command.text.slice(`/${command.name}`.length).trim(); + runtime.ctx.editor.setText(""); + await runtime.ctx.handleTanCommand(work); + }, + }, + { + name: "omfg", + description: "Forge a TTSR rule from a complaint to stop a recurring behavior", + inlineHint: "", + allowArgs: true, + handleTui: async (command, runtime) => { + const complaint = command.text.slice(`/${command.name}`.length).trim(); + runtime.ctx.editor.setText(""); + await runtime.ctx.handleOmfgCommand(complaint); + }, + }, + { + name: "retry", + description: "Retry the last failed agent turn", + handleTui: async (_command, runtime) => { + const didRetry = await runtime.ctx.session.retry(); + if (!didRetry) { + runtime.ctx.showStatus("Nothing to retry"); + } + runtime.ctx.editor.setText(""); + }, + }, + { + name: "debug", + description: "Open debug tools selector", + handleTui: async (_command, runtime) => { + await runtime.ctx.showDebugSelector(); + runtime.ctx.editor.setText(""); + }, + }, + { + name: "memory", + description: "Inspect and operate memory maintenance", + acpDescription: "Manage memory", + acpInputHint: "", + subcommands: [ + { name: "view", description: "Show current memory injection payload" }, + { name: "stats", description: "Show memory backend statistics" }, + { name: "diagnose", description: "Run memory backend diagnostics" }, + { name: "clear", description: "Clear persisted memory data and artifacts" }, + { name: "reset", description: "Alias for clear" }, + { name: "enqueue", description: "Enqueue memory consolidation maintenance" }, + { name: "rebuild", description: "Alias for enqueue" }, + { name: "mm list", description: "List mental models on the active bank" }, + { name: "mm show", description: "Show one mental model (id required)" }, + { + name: "mm refresh", + description: "Refresh auto-refresh models bank-wide, or one model by id", + }, + { name: "mm history", description: "Diff the change history of a mental model" }, + { name: "mm seed", description: "Create any built-in mental models that are missing" }, + { name: "mm delete", description: "Delete a mental model from the bank (id required)" }, + { name: "mm reload", description: "Re-pull the cached block" }, + ], + allowArgs: true, + handle: async (command, runtime) => { + const verb = (command.args.trim().split(/\s+/)[0] ?? "").toLowerCase() || "view"; + const backend = await resolveMemoryBackend(runtime.settings); + switch (verb) { + case "view": { + const payload = await backend.buildDeveloperInstructions( + runtime.settings.getAgentDir(), + runtime.settings, + runtime.session, + ); + await runtime.output(payload || "Memory payload is empty."); + return commandConsumed(); + } + case "clear": + case "reset": { + await backend.clear(runtime.settings.getAgentDir(), runtime.cwd, runtime.session); + await runtime.session.refreshBaseSystemPrompt(); + await runtime.output("Memory cleared."); + return commandConsumed(); + } + case "enqueue": + case "rebuild": { + await backend.enqueue(runtime.settings.getAgentDir(), runtime.cwd, runtime.session); + await runtime.output("Memory consolidation enqueued."); + return commandConsumed(); + } + case "stats": + case "diagnose": { + const hook = verb === "stats" ? backend.stats : backend.diagnose; + const payload = await hook?.(runtime.settings.getAgentDir(), runtime.cwd, runtime.session); + await runtime.output(payload ?? memoryStatsUnavailableMessage(backend.id, verb)); + return commandConsumed(); + } + case "mm": + return usage( + "Mental-model maintenance via /memory mm is unsupported in ACP mode; use the hindsight HTTP API directly.", + runtime, + ); + default: + return usage("Usage: /memory ", runtime); + } + }, + handleTui: async (command, runtime) => { + runtime.ctx.editor.setText(""); + await runtime.ctx.handleMemoryCommand(command.text); + }, + }, + { + name: "rename", + description: "Rename the current session", + inlineHint: "", + allowArgs: true, + handle: async (command, runtime) => { + if (!command.args) return usage("Usage: /rename <title>", runtime); + const ok = await runtime.sessionManager.setSessionName(command.args, "user"); + if (!ok) { + await runtime.output("Session name not changed (a user-set name takes precedence)."); + return commandConsumed(); + } + await runtime.notifyTitleChanged?.(); + await runtime.output(`Session renamed to ${command.args}.`); + return commandConsumed(); + }, + handleTui: async (command, runtime) => { + const title = command.args.trim(); + if (!title) { + runtime.ctx.showError("Usage: /rename <title>"); + runtime.ctx.editor.setText(""); + return; + } + runtime.ctx.editor.setText(""); + await runtime.ctx.handleRenameCommand(title); + }, + }, + { + name: "move", + description: "Move the current session to a different directory", + acpDescription: "Move the current session to a different directory", + inlineHint: "[<path>]", + allowArgs: true, + handle: async (command, runtime) => { + if (runtime.session.isStreaming) return usage("Cannot move while streaming.", runtime); + if (!command.args) return usage("Usage: /move <path>", runtime); + const resolvedPath = resolveToCwd(command.args, runtime.cwd); + try { + const stat = await fs.stat(resolvedPath); + if (!stat.isDirectory()) { + return usage(`Not a directory: ${resolvedPath}`, runtime); + } + } catch { + return usage(`Directory does not exist: ${resolvedPath}`, runtime); + } + try { + await runtime.settings.flush(); + } catch (err) { + return usage(`Failed to save pending settings: ${errorMessage(err)}`, runtime); + } + try { + await runtime.session.moveSession(resolvedPath); + } catch (err) { + return usage(`Move failed: ${errorMessage(err)}`, runtime); + } + setProjectDir(resolvedPath); + await runtime.settings.reloadForCwd(resolvedPath); + applyProviderGlobalsFromSettings(runtime.settings); + // Reload plugin/capability caches so the next prompt sees commands and + // capabilities scoped to the new cwd. + await runtime.reloadPlugins(); + await runtime.notifyConfigChanged?.(); + await runtime.notifyTitleChanged?.(); + await runtime.output(`Moved to ${runtime.sessionManager.getCwd()}.`); + return commandConsumed(); + }, + handleTui: async (command, runtime) => { + runtime.ctx.editor.addToHistory(command.text); + runtime.ctx.editor.setText(""); + await runtime.ctx.handleMoveCommand(command.args || undefined); + }, + }, + { + name: "add-dir", + description: "Add a workspace directory to this session (multi-root)", + acpDescription: "Add a workspace directory to this session", + inlineHint: "<path>", + allowArgs: true, + handle: async (command, runtime) => { + if (runtime.session.isStreaming) return usage("Cannot add a directory while streaming.", runtime); + if (!command.args) return usage(formatWorkspaceDirectories(runtime, "Usage: /add-dir <path>"), runtime); + const resolved = resolveToCwd(command.args, runtime.cwd); + try { + const stat = await fs.stat(resolved); + if (!stat.isDirectory()) return usage(`Not a directory: ${resolved}`, runtime); + } catch { + return usage(`Directory does not exist: ${resolved}`, runtime); + } + let added: string | null; + try { + added = await runtime.sessionManager.addWorkspaceDirectory(resolved); + } catch (err) { + return usage(errorMessage(err), runtime); + } + if (added === null) { + await runtime.output(`Already in the workspace: ${resolved}`); + return commandConsumed(); + } + await runtime.session.refreshBaseSystemPrompt(); + await runtime.output(formatWorkspaceDirectories(runtime, `Added ${added}.`)); + return commandConsumed(); + }, + }, + { + name: "remove-dir", + description: "Remove a workspace directory from this session", + acpDescription: "Remove a workspace directory from this session", + inlineHint: "<path>", + allowArgs: true, + handle: async (command, runtime) => { + if (runtime.session.isStreaming) return usage("Cannot remove a directory while streaming.", runtime); + if (!command.args) return usage("Usage: /remove-dir <path>", runtime); + const resolved = resolveToCwd(command.args, runtime.cwd); + if (resolved === path.resolve(runtime.cwd)) { + return usage("Cannot remove the working directory; use /move to change it.", runtime); + } + let removed: string | null; + try { + removed = await runtime.sessionManager.removeWorkspaceDirectory(resolved); + } catch (err) { + return usage(errorMessage(err), runtime); + } + if (removed === null) { + await runtime.output(`Not a workspace directory: ${resolved}`); + return commandConsumed(); + } + await runtime.session.refreshBaseSystemPrompt(); + await runtime.output(formatWorkspaceDirectories(runtime, `Removed ${removed}.`)); + return commandConsumed(); + }, + }, + { + name: "dirs", + description: "List this session's workspace directories", + acpDescription: "List this session's workspace directories", + handle: async (_command, runtime) => { + await runtime.output(formatWorkspaceDirectories(runtime)); + return commandConsumed(); + }, + }, + { + name: "exit", + description: "Exit the application", + handleTui: shutdownHandlerTui, + }, +]; diff --git a/packages/coding-agent/src/slash-commands/builtin-marketplace.ts b/packages/coding-agent/src/slash-commands/builtin-marketplace.ts new file mode 100644 index 000000000..260697819 --- /dev/null +++ b/packages/coding-agent/src/slash-commands/builtin-marketplace.ts @@ -0,0 +1,567 @@ +import { reset as resetCapabilities } from "../capability"; +import { + clearPluginRootsAndCaches, + resolveActiveProjectRegistryPath, + resolveOrDefaultProjectRegistryPath, +} from "../discovery/helpers.js"; +import { PluginManager } from "../extensibility/plugins"; +import { + getInstalledPluginsRegistryPath, + getMarketplacesCacheDir, + getMarketplacesRegistryPath, + getPluginsCacheDir, + MarketplaceManager, +} from "../extensibility/plugins/marketplace"; +import { MCPCommandController } from "../modes/controllers/mcp-command-controller"; +import type { InteractiveModeContext } from "../modes/types"; +import { refreshAgentDiscovery } from "../task"; +import { createMarketplaceManager } from "./helpers/marketplace-manager"; +import { commandConsumed, errorMessage, parseSubcommand, usage } from "./helpers/parse"; +import { parseMarketplaceInstallArgs, parsePluginScopeArgs } from "./marketplace-install-parser"; +import type { SlashCommandSpec } from "./types"; + +/** + * Reload the interactive session's plugin runtime: invalidate fs/plugin-root + * caches, rediscover skills, file slash commands, and task agents, reset the + * capability cache, and reconnect MCP servers (rebinding the session's MCP + * tools). Shared by `/reload-plugins`'s TUI handler and the `handle`-adapter's + * `reloadPlugins` hook so both honor the command's documented reload scope. + */ +export async function reloadTuiPluginState(ctx: InteractiveModeContext): Promise<void> { + const projectPath = await resolveActiveProjectRegistryPath(ctx.sessionManager.getCwd()); + clearPluginRootsAndCaches(projectPath ? [projectPath] : undefined); + await refreshAgentDiscovery(ctx.sessionManager.getCwd()); + await ctx.refreshSkillState(); + await ctx.refreshSlashCommandState(); + resetCapabilities(); + if (ctx.mcpManager) { + await new MCPCommandController(ctx).reloadServers(); + } +} + +export const BUILTIN_MARKETPLACE_SLASH_COMMANDS: ReadonlyArray<SlashCommandSpec> = [ + { + name: "marketplace", + description: "Manage marketplace plugin sources and installed plugins", + acpDescription: "Manage plugins from marketplaces", + acpInputHint: "<subcommand>", + subcommands: [ + { name: "add", description: "Add a marketplace source", usage: "<source>" }, + { name: "remove", description: "Remove a marketplace source", usage: "<name>" }, + { name: "update", description: "Update marketplace catalog(s)", usage: "[name]" }, + { name: "list", description: "List configured marketplaces" }, + { name: "discover", description: "Browse available plugins", usage: "[marketplace]" }, + { + name: "install", + description: "Install a plugin (interactive browser if no args)", + usage: "[--force] [name@marketplace]", + }, + { name: "uninstall", description: "Uninstall a plugin (selector if no args)", usage: "[name@marketplace]" }, + { name: "installed", description: "List installed marketplace plugins" }, + { name: "upgrade", description: "Upgrade outdated plugins", usage: "[name@marketplace]" }, + { name: "help", description: "Show usage guide" }, + ], + allowArgs: true, + handle: async (command, runtime) => { + const { verb, rest } = parseSubcommand(command.args); + if (!verb) { + try { + const manager = await createMarketplaceManager(runtime); + const marketplaces = await manager.listMarketplaces(); + if (marketplaces.length === 0) { + await runtime.output( + "No marketplaces configured.\n\nGet started:\n /marketplace add anthropics/claude-plugins-official\n\nThen browse with /marketplace discover", + ); + } else { + const lines = marketplaces.map(m => ` ${m.name} ${m.sourceUri}`); + await runtime.output( + `Marketplaces:\n${lines.join("\n")}\n\nUse /marketplace discover to browse plugins, or /marketplace help for all commands`, + ); + } + return commandConsumed(); + } catch (err) { + return usage(`Marketplace error: ${errorMessage(err)}`, runtime); + } + } + if (verb === "help") { + await runtime.output( + [ + "Marketplace commands:", + " /marketplace List configured marketplaces", + " /marketplace add <source> Add a marketplace (e.g. owner/repo)", + " /marketplace remove <name> Remove a marketplace", + " /marketplace update [name] Re-fetch catalog(s)", + " /marketplace list List configured marketplaces", + " /marketplace discover [marketplace] Browse available plugins", + " /marketplace install <name@marketplace> Install a plugin", + " /marketplace uninstall <name@marketplace> Uninstall a plugin", + " /marketplace installed List installed plugins", + " /marketplace upgrade [name@marketplace] Upgrade plugin(s)", + "", + "Quick start:", + " /marketplace add anthropics/claude-plugins-official", + ].join("\n"), + ); + return commandConsumed(); + } + if ((verb === "install" || verb === "uninstall") && !rest) { + return usage( + "Interactive plugin pickers are TUI-only. Pass an explicit name@marketplace argument.", + runtime, + ); + } + try { + const manager = await createMarketplaceManager(runtime); + switch (verb) { + case "add": { + if (!rest) return usage("Usage: /marketplace add <source>", runtime); + const entry = await manager.addMarketplace(rest); + await runtime.output(`Added marketplace: ${entry.name}`); + return commandConsumed(); + } + case "remove": + case "rm": { + if (!rest) return usage("Usage: /marketplace remove <name>", runtime); + await manager.removeMarketplace(rest); + await runtime.output(`Removed marketplace: ${rest}`); + return commandConsumed(); + } + case "update": { + if (rest) { + await manager.updateMarketplace(rest); + await runtime.output(`Updated marketplace: ${rest}`); + } else { + const results = await manager.updateAllMarketplaces(); + await runtime.output(`Updated ${results.length} marketplace(s)`); + } + return commandConsumed(); + } + case "list": { + const marketplaces = await manager.listMarketplaces(); + if (marketplaces.length === 0) { + await runtime.output("No marketplaces configured."); + } else { + const lines = marketplaces.map(m => ` ${m.name} ${m.sourceUri}`); + await runtime.output(`Marketplaces:\n${lines.join("\n")}`); + } + return commandConsumed(); + } + case "discover": { + const plugins = await manager.listAvailablePlugins(rest || undefined); + if (plugins.length === 0) { + const marketplaces = await manager.listMarketplaces(); + await runtime.output( + marketplaces.length === 0 + ? "No marketplaces configured. Try:\n /marketplace add anthropics/claude-plugins-official" + : "No plugins available in configured marketplaces", + ); + return commandConsumed(); + } + const lines = ["Available plugins:"]; + for (const plugin of plugins) { + lines.push(` - ${plugin.name}${plugin.version ? `@${plugin.version}` : ""}`); + if (plugin.description) lines.push(` ${plugin.description}`); + } + await runtime.output(lines.join("\n")); + return commandConsumed(); + } + case "install": { + const parsed = parseMarketplaceInstallArgs(rest); + if ("error" in parsed) return usage(parsed.error, runtime); + const atIndex = parsed.installSpec.lastIndexOf("@"); + const pluginName = parsed.installSpec.slice(0, atIndex); + const marketplace = parsed.installSpec.slice(atIndex + 1); + await manager.installPlugin(pluginName, marketplace, { force: parsed.force, scope: parsed.scope }); + await runtime.reloadPlugins(); + await runtime.output(`Installed ${pluginName} from ${marketplace}`); + return commandConsumed(); + } + case "uninstall": { + const parsed = parsePluginScopeArgs( + rest, + "Usage: /marketplace uninstall [--scope user|project] <name@marketplace>", + ); + if ("error" in parsed) return usage(parsed.error, runtime); + await manager.uninstallPlugin(parsed.pluginId, parsed.scope); + await runtime.reloadPlugins(); + await runtime.output(`Uninstalled ${parsed.pluginId}`); + return commandConsumed(); + } + case "installed": { + const installed = await manager.listInstalledPlugins(); + if (installed.length === 0) { + await runtime.output("No marketplace plugins installed"); + } else { + const lines = installed.map( + p => ` ${p.id} [${p.scope}]${p.shadowedBy ? " [shadowed]" : ""} (${p.entries.length} entry)`, + ); + await runtime.output(`Installed plugins:\n${lines.join("\n")}`); + } + return commandConsumed(); + } + case "upgrade": { + if (rest) { + const parsed = parsePluginScopeArgs( + rest, + "Usage: /marketplace upgrade [--scope user|project] <name@marketplace>", + ); + if ("error" in parsed) return usage(parsed.error, runtime); + const result = await manager.upgradePlugin(parsed.pluginId, parsed.scope); + await runtime.reloadPlugins(); + await runtime.output(`Upgraded ${parsed.pluginId} to ${result.version}`); + return commandConsumed(); + } + const results = await manager.upgradeAllPlugins(); + if (results.length === 0) { + await runtime.output("All marketplace plugins are up to date"); + } else { + await runtime.reloadPlugins(); + const lines = results.map(r => ` ${r.pluginId}: ${r.from} -> ${r.to}`); + await runtime.output(`Upgraded ${results.length} plugin(s):\n${lines.join("\n")}`); + } + return commandConsumed(); + } + default: + return usage( + `Unknown /marketplace subcommand: ${verb}. Use /marketplace help for available commands.`, + runtime, + ); + } + } catch (err) { + return usage(`Marketplace error: ${errorMessage(err)}`, runtime); + } + }, + handleTui: async (command, runtime) => { + runtime.ctx.editor.setText(""); + const args = command.args.trim().split(/\s+/); + const sub = args[0] || "install"; + const rest = args.slice(1).join(" ").trim(); + + // /marketplace (no args) or /marketplace install (no args) → interactive browser + if ((sub === "install" && !rest) || (!args[0] && !command.args.trim())) { + try { + runtime.ctx.showPluginSelector("install"); + } catch (err) { + runtime.ctx.showStatus(`Marketplace error: ${err}`); + } + return; + } + + const mgr = new MarketplaceManager({ + marketplacesRegistryPath: getMarketplacesRegistryPath(), + installedRegistryPath: getInstalledPluginsRegistryPath(), + projectInstalledRegistryPath: await resolveOrDefaultProjectRegistryPath( + runtime.ctx.sessionManager.getCwd(), + ), + marketplacesCacheDir: getMarketplacesCacheDir(), + pluginsCacheDir: getPluginsCacheDir(), + clearPluginRootsCache: clearPluginRootsAndCaches, + }); + + try { + switch (sub) { + case "add": { + if (!rest) { + runtime.ctx.showStatus("Usage: /marketplace add <source>"); + return; + } + const entry = await mgr.addMarketplace(rest); + runtime.ctx.showStatus(`Added marketplace: ${entry.name}`); + break; + } + case "remove": + case "rm": { + if (!rest) { + runtime.ctx.showStatus("Usage: /marketplace remove <name>"); + return; + } + await mgr.removeMarketplace(rest); + runtime.ctx.showStatus(`Removed marketplace: ${rest}`); + break; + } + case "update": { + if (rest) { + await mgr.updateMarketplace(rest); + runtime.ctx.showStatus(`Updated marketplace: ${rest}`); + } else { + const results = await mgr.updateAllMarketplaces(); + runtime.ctx.showStatus(`Updated ${results.length} marketplace(s)`); + } + break; + } + case "discover": { + const plugins = await mgr.listAvailablePlugins(rest || undefined); + if (plugins.length === 0) { + const marketplaces = await mgr.listMarketplaces(); + if (marketplaces.length === 0) { + runtime.ctx.showStatus( + "No marketplaces configured. Try:\n /marketplace add anthropics/claude-plugins-official", + ); + } else { + runtime.ctx.showStatus("No plugins available in configured marketplaces"); + } + } else { + const lines = plugins.map( + p => + ` ${p.name}${p.version ? `@${p.version}` : ""}${p.description ? ` - ${p.description}` : ""}`, + ); + runtime.ctx.showStatus(`Available plugins:\n${lines.join("\n")}`); + } + break; + } + case "install": { + // Parse: /marketplace install [--force] [--scope user|project] name@marketplace + const parsed = parseMarketplaceInstallArgs(rest); + if ("error" in parsed) { + runtime.ctx.showStatus(parsed.error); + return; + } + const atIdx = parsed.installSpec.lastIndexOf("@"); + const name = parsed.installSpec.slice(0, atIdx); + const marketplace = parsed.installSpec.slice(atIdx + 1); + await mgr.installPlugin(name, marketplace, { force: parsed.force, scope: parsed.scope }); + runtime.ctx.showStatus(`Installed ${name} from ${marketplace}`); + break; + } + case "uninstall": { + if (!rest) { + // No args → open interactive uninstall selector + runtime.ctx.showPluginSelector("uninstall"); + return; + } + const uninstArgs = parsePluginScopeArgs( + rest, + "Usage: /marketplace uninstall [--scope user|project] <name@marketplace>", + ); + if ("error" in uninstArgs) { + runtime.ctx.showStatus(uninstArgs.error); + return; + } + await mgr.uninstallPlugin(uninstArgs.pluginId, uninstArgs.scope); + runtime.ctx.showStatus(`Uninstalled ${uninstArgs.pluginId}`); + break; + } + case "installed": { + const installed = await mgr.listInstalledPlugins(); + if (installed.length === 0) { + runtime.ctx.showStatus("No marketplace plugins installed"); + } else { + const lines = installed.map( + p => ` ${p.id} [${p.scope}]${p.shadowedBy ? " [shadowed]" : ""} (${p.entries.length} entry)`, + ); + runtime.ctx.showStatus(`Installed plugins:\n${lines.join("\n")}`); + } + break; + } + case "upgrade": { + if (rest) { + const upArgs = parsePluginScopeArgs( + rest, + "Usage: /marketplace upgrade [--scope user|project] <name@marketplace>", + ); + if ("error" in upArgs) { + runtime.ctx.showStatus(upArgs.error); + return; + } + const result = await mgr.upgradePlugin(upArgs.pluginId, upArgs.scope); + runtime.ctx.showStatus(`Upgraded ${upArgs.pluginId} to ${result.version}`); + } else { + const results = await mgr.upgradeAllPlugins(); + if (results.length === 0) { + runtime.ctx.showStatus("All marketplace plugins are up to date"); + } else { + const lines = results.map(r => ` ${r.pluginId}: ${r.from} -> ${r.to}`); + runtime.ctx.showStatus(`Upgraded ${results.length} plugin(s):\n${lines.join("\n")}`); + } + } + break; + } + case "help": { + runtime.ctx.showStatus( + [ + "Marketplace commands:", + " /marketplace Browse and install plugins", + " /marketplace add <source> Add a marketplace (e.g. owner/repo)", + " /marketplace remove <name> Remove a marketplace", + " /marketplace update [name] Re-fetch catalog(s)", + " /marketplace list List configured marketplaces", + " /marketplace discover [marketplace] Browse available plugins", + " /marketplace install <name@marketplace> Install a plugin", + " /marketplace uninstall <name@marketplace> Uninstall a plugin", + " /marketplace installed List installed plugins", + " /marketplace upgrade [name@marketplace] Upgrade plugin(s)", + "", + "Quick start:", + " /marketplace add anthropics/claude-plugins-official", + " /marketplace (opens interactive browser)", + ].join("\n"), + ); + break; + } + default: { + const marketplaces = await mgr.listMarketplaces(); + if (marketplaces.length === 0) { + runtime.ctx.showStatus( + "No marketplaces configured.\n\nGet started:\n /marketplace add anthropics/claude-plugins-official\n\nThen browse plugins with /marketplace or /marketplace discover", + ); + } else { + const lines = marketplaces.map(m => ` ${m.name} ${m.sourceUri}`); + runtime.ctx.showStatus( + `Marketplaces:\n${lines.join("\n")}\n\nUse /marketplace discover to browse plugins, or /marketplace help for all commands`, + ); + } + break; + } + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + runtime.ctx.showStatus(`Marketplace error: ${msg}`); + } + }, + }, + { + name: "plugins", + description: "View and manage installed plugins", + acpDescription: "Manage plugins", + acpInputHint: "[list|enable|disable]", + subcommands: [ + { name: "list", description: "List all installed plugins (npm + marketplace)" }, + { name: "enable", description: "Enable a marketplace plugin", usage: "<name@marketplace>" }, + { name: "disable", description: "Disable a marketplace plugin", usage: "<name@marketplace>" }, + ], + allowArgs: true, + handle: async (command, runtime) => { + const { verb, rest } = parseSubcommand(command.args); + try { + if (verb === "enable" || verb === "disable") { + const parsed = parsePluginScopeArgs( + rest, + `Usage: /plugins ${verb} [--scope user|project] <name@marketplace>`, + ); + if ("error" in parsed) return usage(parsed.error, runtime); + const manager = await createMarketplaceManager(runtime); + const isEnable = verb === "enable"; + await manager.setPluginEnabled(parsed.pluginId, isEnable, parsed.scope); + await runtime.reloadPlugins(); + await runtime.output(`${isEnable ? "Enabled" : "Disabled"} ${parsed.pluginId}`); + return commandConsumed(); + } + // Default: list + const lines: string[] = []; + const npmManager = new PluginManager(); + const npmPlugins = await npmManager.list(); + if (npmPlugins.length > 0) { + lines.push("npm plugins:"); + for (const plugin of npmPlugins) { + const status = plugin.enabled === false ? " (disabled)" : ""; + lines.push(` ${plugin.name}@${plugin.version}${status}`); + } + } + + const marketplaceManager = await createMarketplaceManager(runtime); + const marketplacePlugins = await marketplaceManager.listInstalledPlugins(); + if (marketplacePlugins.length > 0) { + if (lines.length > 0) lines.push(""); + lines.push("marketplace plugins:"); + for (const plugin of marketplacePlugins) { + const entry = plugin.entries[0]; + const status = entry?.enabled === false ? " (disabled)" : ""; + const shadowed = plugin.shadowedBy ? " [shadowed]" : ""; + lines.push(` ${plugin.id} v${entry?.version ?? "?"}${status} [${plugin.scope}]${shadowed}`); + } + } + + await runtime.output(lines.length === 0 ? "No plugins installed" : lines.join("\n")); + return commandConsumed(); + } catch (err) { + return usage(`Plugin error: ${errorMessage(err)}`, runtime); + } + }, + handleTui: async (command, runtime) => { + runtime.ctx.editor.setText(""); + const args = command.args.trim().split(/\s+/); + const sub = args[0] || "list"; + const rest = args.slice(1).join(" ").trim(); + + try { + const mgr = new MarketplaceManager({ + marketplacesRegistryPath: getMarketplacesRegistryPath(), + installedRegistryPath: getInstalledPluginsRegistryPath(), + projectInstalledRegistryPath: await resolveOrDefaultProjectRegistryPath( + runtime.ctx.sessionManager.getCwd(), + ), + marketplacesCacheDir: getMarketplacesCacheDir(), + pluginsCacheDir: getPluginsCacheDir(), + clearPluginRootsCache: clearPluginRootsAndCaches, + }); + + switch (sub) { + case "enable": + case "disable": { + const parsed = parsePluginScopeArgs( + rest ?? "", + `Usage: /plugins ${sub} [--scope user|project] <name@marketplace>`, + ); + if ("error" in parsed) { + runtime.ctx.showStatus(parsed.error); + return; + } + const isEnable = sub === "enable"; + await mgr.setPluginEnabled(parsed.pluginId, isEnable, parsed.scope); + runtime.ctx.showStatus(`${isEnable ? "Enabled" : "Disabled"} ${parsed.pluginId}`); + break; + } + default: { + const lines: string[] = []; + + const npm = new PluginManager(); + const npmPlugins = await npm.list(); + if (npmPlugins.length > 0) { + lines.push("npm plugins:"); + for (const p of npmPlugins) { + const status = p.enabled === false ? " (disabled)" : ""; + lines.push(` ${p.name}@${p.version}${status}`); + } + } + + const mktPlugins = await mgr.listInstalledPlugins(); + if (mktPlugins.length > 0) { + if (lines.length > 0) lines.push(""); + lines.push("marketplace plugins:"); + for (const p of mktPlugins) { + const entry = p.entries[0]; + const status = entry?.enabled === false ? " (disabled)" : ""; + const shadowed = p.shadowedBy ? " [shadowed]" : ""; + lines.push(` ${p.id} v${entry?.version ?? "?"}${status} [${p.scope}]${shadowed}`); + } + } + + if (lines.length === 0) { + runtime.ctx.showStatus("No plugins installed"); + } else { + runtime.ctx.showStatus(lines.join("\n")); + } + break; + } + } + } catch (err) { + runtime.ctx.showStatus(`Plugin error: ${err}`); + } + }, + }, + { + name: "reload-plugins", + description: "Reload all plugins (skills, commands, hooks, tools, agents, MCP)", + acpDescription: "Reload all plugins", + handle: async (_command, runtime) => { + await runtime.reloadPlugins(); + await runtime.output("Plugins reloaded."); + return commandConsumed(); + }, + handleTui: async (_command, runtime) => { + await reloadTuiPluginState(runtime.ctx); + runtime.ctx.showStatus("Plugins reloaded."); + runtime.ctx.editor.setText(""); + }, + }, +]; diff --git a/packages/coding-agent/src/slash-commands/builtin-modes.ts b/packages/coding-agent/src/slash-commands/builtin-modes.ts new file mode 100644 index 000000000..5fa9b6290 --- /dev/null +++ b/packages/coding-agent/src/slash-commands/builtin-modes.ts @@ -0,0 +1,518 @@ +import * as path from "node:path"; +import { + expandRoleAlias, + formatModelString, + getModelMatchPreferences, + resolveCliModel, +} from "../config/model-resolver"; +import type { SettingPath } from "../config/settings"; +import { describeLoopLimitRuntime } from "../modes/loop-limit"; +import type { InteractiveModeContext } from "../modes/types"; +import type { AgentSession } from "../session/agent-session"; +import type { ComputerTool } from "../tools/computer"; +import { computerExposureMode } from "../tools/computer/exposure"; +import type { InspectImageMode } from "../utils/inspect-image-mode"; +import { commandConsumed, errorMessage, usage } from "./helpers/parse"; +import { handleSecurityCommand } from "./helpers/security"; +import type { SlashCommandSpec } from "./types"; + +export function refreshStatusLine(ctx: InteractiveModeContext): void { + ctx.statusLine.invalidate(); + ctx.ui.requestRender(); +} + +/** `/fast status` label for the active model: "on" when its family is priority, else "off". */ +function formatFastModeStatus(session: AgentSession): string { + return session.isFastModeEnabled() ? "on" : "off"; +} + +/** Detailed, session-effective `/computer status` diagnostics. */ +async function formatComputerUseStatus(session: AgentSession): Promise<string> { + const enabled = session.settings.get("computer.enabled"); + const active = session.getEnabledToolNames().includes("computer"); + const model = session.model; + const modelName = model ? formatModelString(model) : "none"; + const exposure = !enabled + ? "not exposed (disabled)" + : !active + ? "not exposed (tool inactive)" + : computerExposureMode(model); + const configured = { + display: session.settings.get("computer.display"), + maxWidth: session.settings.get("computer.maxWidth"), + maxHeight: session.settings.get("computer.maxHeight"), + }; + const computerTool = active + ? (session.getToolByName("computer") as Pick<ComputerTool, "capabilities"> | undefined) + : undefined; + const capabilities = await computerTool?.capabilities(); + const capabilityStatus = capabilities + ? [ + `backend=${capabilities.backend}${capabilities.displayServer ? `/${capabilities.displayServer}` : ""}`, + `capture=${capabilities.capture} (${capabilities.capturePermission})`, + `input=${capabilities.input} (${capabilities.inputPermission})`, + `ax=${capabilities.ax} (${capabilities.axPermission})`, + `backgroundWindowInput=${capabilities.backgroundWindowInput}`, + `deliveryModes=${capabilities.deliveryModes.join(",") || "none"}`, + ].join(", ") + : "session not started"; + return [ + `Computer use: ${enabled ? "enabled" : "disabled"}`, + `tool: ${active ? "active" : "inactive"}`, + `exposure: ${exposure}`, + `model: ${modelName}`, + `configured: display=${configured.display}, maxWidth=${configured.maxWidth}, maxHeight=${configured.maxHeight}`, + `capabilities: ${capabilityStatus}`, + ].join(" · "); +} + +/** + * Apply a session-scoped computer-use toggle: flip the active tool slate first + * (so a failed enable never leaves a stale settings override), then record the + * runtime override — never `settings.set`, which would persist to settings.json. + * Returns the operator feedback line. + */ +async function applyComputerUseToggle(session: AgentSession, enable: boolean): Promise<string> { + const applied = await session.setComputerToolEnabled(enable); + if (enable && !applied) { + return "Computer use is unavailable in this session."; + } + session.settings.override("computer.enabled", enable); + return enable + ? `Computer use enabled for this session. ${await formatComputerUseStatus(session)}` + : "Computer use disabled for this session."; +} + +/** Session-effective `/vision status` line. */ +function formatVisionStatus(session: AgentSession): string { + const { mode, active, model } = session.inspectImageState(); + const override = session.getInspectImageModeOverride(); + const modelObj = session.model; + const capability = modelObj + ? modelObj.input.includes("image") + ? "native image input" + : "no native image input" + : "no active model"; + return [ + `inspect_image: ${active ? "active" : "inactive"}`, + `mode: ${mode}${override ? " (session override)" : ""}`, + ...(override ? [`configured: ${session.settings.get("inspect_image.mode")}`] : []), + `model: ${model ?? "none"} (${capability})`, + ].join(" · "); +} + +/** Applies a `/vision` mode for this session and returns the operator feedback line. */ +async function applyVisionMode(session: AgentSession, mode: InspectImageMode): Promise<string> { + const applied = await session.setInspectImageMode(mode); + if (!applied) { + return "inspect_image is unavailable in this session."; + } + return `Vision mode: ${mode}. ${formatVisionStatus(session)}`; +} + +const AUTOCOMPLETE_DETAIL_LIMIT = 48; + +function shortDetail(value: string, limit = AUTOCOMPLETE_DETAIL_LIMIT): string { + const singleLine = value.replace(/\s+/g, " ").trim(); + return singleLine.length <= limit ? singleLine : `${singleLine.slice(0, limit - 1)}…`; +} + +export function formatTokenCount(value: number): string { + return value.toLocaleString(); +} + +export const BUILTIN_MODE_SLASH_COMMANDS: ReadonlyArray<SlashCommandSpec> = [ + { + name: "security", + description: "Plan, run, inspect, import, and compare OMP-native security scans", + allowArgs: true, + acpInputHint: "<plan|scan|status|cancel|scans|show|import|export|validate|compare|disposition>", + subcommands: [ + { name: "plan", description: "Create an immutable security scan plan" }, + { name: "scan", description: "Start a planned or newly planned native scan" }, + { name: "status", description: "Show native scan operation status" }, + { name: "cancel", description: "Cancel a running native scan" }, + { name: "scans", description: "List stored project security scans" }, + { name: "show", description: "Render a scan or security:// resource" }, + { name: "import", description: "Import SARIF or a Codex Security bundle" }, + { name: "export", description: "Export a canonical bundle, SARIF, or report" }, + { name: "validate", description: "Validate one finding with OMP-native tools" }, + { name: "compare", description: "Compare finding lineage across two scans" }, + { name: "disposition", description: "Set a finding disposition with rationale" }, + ], + handle: handleSecurityCommand, + }, + { + name: "settings", + description: "Open settings menu", + handleTui: (_command, runtime) => { + runtime.ctx.showSettingsSelector(); + runtime.ctx.editor.setText(""); + }, + }, + { + name: "setup", + aliases: ["providers"], + description: "Open provider setup", + allowArgs: true, + subcommands: [{ name: "providers", description: "Configure sign-in and web search providers" }], + handleTui: async (command, runtime) => { + const args = command.args.trim().toLowerCase(); + const opensProviders = args === "" || args === "providers"; + if (opensProviders) { + await runtime.ctx.showProviderSetup(); + } else { + runtime.ctx.showWarning(`Usage: /${command.name} [providers]`); + } + runtime.ctx.editor.setText(""); + }, + }, + { + name: "plan", + description: "Toggle plan mode (agent plans before executing)", + inlineHint: "[prompt]", + allowArgs: true, + getTuiAutocompleteDescription: runtime => { + if (!runtime.ctx.settings.get("plan.enabled" as SettingPath)) return "Plan: disabled in settings"; + if (runtime.ctx.planModeEnabled) { + const planFile = runtime.ctx.planModePlanFilePath; + return `Plan: on${planFile ? ` (${path.basename(planFile)})` : ""}`; + } + if (runtime.ctx.goalModeEnabled) return "Plan: blocked by goal mode"; + return "Plan: off"; + }, + handleTui: async (command, runtime) => { + await runtime.ctx.handlePlanModeCommand(command.args || undefined); + runtime.ctx.editor.setText(""); + }, + }, + { + name: "plan-review", + description: "Re-open the plan review for the latest plan (plan mode only)", + getTuiAutocompleteDescription: runtime => + runtime.ctx.planModeEnabled ? "Plan review: available" : "Plan review: plan mode inactive", + handleTui: async (_command, runtime) => { + await runtime.ctx.openPlanReview(); + runtime.ctx.editor.setText(""); + }, + }, + { + name: "vibe", + description: "Toggle vibe mode (direct persistent fast/good worker sessions; read-only toolset)", + inlineHint: "[prompt]", + allowArgs: true, + getTuiAutocompleteDescription: runtime => { + if (runtime.ctx.vibeModeEnabled) return "Vibe: on"; + if (runtime.ctx.planModeEnabled) return "Vibe: blocked by plan mode"; + if (runtime.ctx.goalModeEnabled) return "Vibe: blocked by goal mode"; + return "Vibe: off"; + }, + handleTui: async (command, runtime) => { + await runtime.ctx.handleVibeModeCommand(command.args || undefined); + runtime.ctx.editor.setText(""); + }, + }, + { + name: "goal", + description: "Toggle goal mode (persistent autonomous objective for this session)", + subcommands: [ + { name: "set", description: "Set or replace the goal", usage: "<objective>" }, + { name: "show", description: "Show current goal details" }, + { name: "pause", description: "Pause the current goal" }, + { name: "resume", description: "Resume a paused goal" }, + { name: "drop", description: "Drop the current goal" }, + { name: "budget", description: "Adjust the token budget", usage: "<N|off>" }, + ], + inlineHint: "[objective]", + allowArgs: true, + getTuiAutocompleteDescription: runtime => { + if (!runtime.ctx.settings.get("goal.enabled" as SettingPath)) return "Goal: disabled in settings"; + if (runtime.ctx.planModeEnabled) return "Goal: blocked by plan mode"; + const state = runtime.ctx.session.getGoalModeState(); + return state ? `Goal: ${state.goal.status} (${shortDetail(state.goal.objective)})` : "Goal: off"; + }, + handleTui: async (command, runtime) => { + await runtime.ctx.handleGoalModeCommand(command.args || undefined); + runtime.ctx.editor.setText(""); + }, + }, + { + name: "guided-goal", + description: "Have the agent interview you in chat, then set up goal mode", + inlineHint: "[rough objective]", + allowArgs: true, + handleTui: async (command, runtime) => { + // Clear the slash draft BEFORE the await: the handler blocks for the + // whole kickoff turn, and a post-await clear would wipe an answer the + // user starts typing while the first interview question streams. + runtime.ctx.editor.setText(""); + await runtime.ctx.handleGuidedGoalCommand(command.args || undefined); + }, + }, + { + name: "loop", + description: + "Toggle loop mode. While enabled, the next prompt you send re-submits after every yield. Esc cancels the current iteration; /loop again to disable.", + inlineHint: "[count|duration] [prompt]", + allowArgs: true, + getTuiAutocompleteDescription: runtime => { + if (!runtime.ctx.loopModeEnabled) return "Loop: off"; + if (runtime.ctx.loopModePaused) return "Loop: paused"; + if (runtime.ctx.loopLimit) return `Loop: on (${describeLoopLimitRuntime(runtime.ctx.loopLimit)})`; + if (runtime.ctx.loopPrompt) return "Loop: on (repeating prompt)"; + return "Loop: on (waiting for next prompt)"; + }, + handleTui: async (command, runtime) => { + const prompt = await runtime.ctx.handleLoopCommand(command.args); + runtime.ctx.editor.setText(""); + // Surface any inline prompt so the dispatcher returns it and the normal + // submit flow runs the first loop iteration (recording it as the loop prompt). + if (prompt) return { prompt }; + }, + }, + { + name: "queue", + description: "Queue a message for after the agent yields", + inlineHint: "<message>", + allowArgs: true, + handleTui: async (command, runtime) => { + await runtime.ctx.handleQueueCommand(command.args); + }, + }, + { + name: "model", + aliases: ["models"], + description: "Switch model for this session", + acpDescription: "Show current model selection", + getTuiAutocompleteDescription: runtime => { + const model = runtime.ctx.session.model; + return model ? `Model: ${model.provider}/${model.id}` : "Model: none selected"; + }, + handle: async (command, runtime) => { + if (command.args) { + const modelId = command.args.trim(); + const availableModels = runtime.session.getAvailableModels?.() ?? []; + const match = availableModels.find( + model => model.id === modelId || `${model.provider}/${model.id}` === modelId, + ); + if (!match) { + return usage( + `Unknown model: ${modelId}. Use ACP \`session/setModel\` for picker-driven selection or list available models with /model.`, + runtime, + ); + } + try { + await runtime.session.setModel(match); + await runtime.output(`Model set to ${match.provider}/${match.id}.`); + await runtime.notifyTitleChanged?.(); + await runtime.notifyConfigChanged?.(); + return commandConsumed(); + } catch (err) { + return usage(`Failed to set model: ${errorMessage(err)}`, runtime); + } + } + + const model = runtime.session.model; + await runtime.output( + model ? `Current model: ${model.provider}/${model.id}` : "No model is currently selected.", + ); + return commandConsumed(); + }, + handleTui: (_command, runtime) => { + runtime.ctx.showModelSelector(); + runtime.ctx.editor.setText(""); + }, + }, + { + name: "switch", + description: "Switch model for this session (same as alt+p)", + getTuiAutocompleteDescription: runtime => { + const model = runtime.ctx.session.model; + return model ? `Model: ${model.provider}/${model.id}` : "Model: none selected"; + }, + handleTui: (_command, runtime) => { + runtime.ctx.showModelSelector({ temporaryOnly: true }); + runtime.ctx.editor.setText(""); + }, + }, + { + name: "fast", + description: "Toggle priority service tier (OpenAI service_tier=priority, Anthropic speed=fast)", + acpDescription: "Toggle fast mode", + acpInputHint: "[on|off|status]", + subcommands: [ + { name: "on", description: "Enable fast mode" }, + { name: "off", description: "Disable fast mode" }, + { name: "status", description: "Show fast mode status" }, + ], + allowArgs: true, + getTuiAutocompleteDescription: runtime => `Fast: ${formatFastModeStatus(runtime.ctx.session)}`, + handle: async (command, runtime) => { + const arg = command.args.toLowerCase(); + if (!arg || arg === "toggle") { + const enabled = runtime.session.toggleFastMode(); + await runtime.output(`Fast mode ${enabled ? "enabled" : "disabled"}.`); + return commandConsumed(); + } + if (arg === "on") { + const supported = runtime.session.setFastMode(true); + await runtime.output(supported ? "Fast mode enabled." : "Fast mode is unavailable for the current model."); + return commandConsumed(); + } + if (arg === "off") { + runtime.session.setFastMode(false); + await runtime.output("Fast mode disabled."); + return commandConsumed(); + } + if (arg === "status") { + await runtime.output(`Fast mode is ${formatFastModeStatus(runtime.session)}.`); + return commandConsumed(); + } + return usage("Usage: /fast [on|off|status]", runtime); + }, + handleTui: (command, runtime) => { + const arg = command.args.trim().toLowerCase(); + if (!arg || arg === "toggle") { + const enabled = runtime.ctx.session.toggleFastMode(); + refreshStatusLine(runtime.ctx); + runtime.ctx.showStatus(`Fast mode ${enabled ? "enabled" : "disabled"}.`); + runtime.ctx.editor.setText(""); + return; + } + if (arg === "on") { + const supported = runtime.ctx.session.setFastMode(true); + refreshStatusLine(runtime.ctx); + runtime.ctx.showStatus( + supported ? "Fast mode enabled." : "Fast mode is unavailable for the current model.", + ); + runtime.ctx.editor.setText(""); + return; + } + if (arg === "off") { + runtime.ctx.session.setFastMode(false); + refreshStatusLine(runtime.ctx); + runtime.ctx.showStatus("Fast mode disabled."); + runtime.ctx.editor.setText(""); + return; + } + if (arg === "status") { + runtime.ctx.showStatus(`Fast mode is ${formatFastModeStatus(runtime.ctx.session)}.`); + runtime.ctx.editor.setText(""); + return; + } + runtime.ctx.showStatus("Usage: /fast [on|off|status]"); + runtime.ctx.editor.setText(""); + }, + }, + { + name: "computer", + description: "Toggle the native computer-use tool for this session", + acpDescription: "Toggle computer use", + acpInputHint: "[on|off|status]", + subcommands: [ + { name: "on", description: "Enable computer use for this session" }, + { name: "off", description: "Disable computer use for this session" }, + { name: "status", description: "Show computer use status" }, + ], + allowArgs: true, + getTuiAutocompleteDescription: runtime => + `Computer: ${runtime.ctx.session.settings.get("computer.enabled") ? "on" : "off"}`, + handle: async (command, runtime) => { + const arg = command.args.trim().toLowerCase(); + if (arg === "status") { + await runtime.output(await formatComputerUseStatus(runtime.session)); + return commandConsumed(); + } + if (!arg || arg === "toggle" || arg === "on" || arg === "off") { + const enable = arg === "off" ? false : arg === "on" || !runtime.session.settings.get("computer.enabled"); + await runtime.output(await applyComputerUseToggle(runtime.session, enable)); + return commandConsumed(); + } + return usage("Usage: /computer [on|off|status]", runtime); + }, + handleTui: async (command, runtime) => { + const arg = command.args.trim().toLowerCase(); + if (arg === "status") { + runtime.ctx.showStatus(await formatComputerUseStatus(runtime.ctx.session)); + runtime.ctx.editor.setText(""); + return; + } + if (!arg || arg === "toggle" || arg === "on" || arg === "off") { + const enable = + arg === "off" ? false : arg === "on" || !runtime.ctx.session.settings.get("computer.enabled"); + runtime.ctx.showStatus(await applyComputerUseToggle(runtime.ctx.session, enable)); + runtime.ctx.editor.setText(""); + return; + } + runtime.ctx.showStatus("Usage: /computer [on|off|status]"); + runtime.ctx.editor.setText(""); + }, + }, + { + name: "vision", + description: "Control the inspect_image vision-delegation tool for this session", + acpDescription: "Toggle vision delegation", + acpInputHint: "[on|off|auto|status]", + subcommands: [ + { name: "on", description: "Always expose inspect_image this session" }, + { name: "off", description: "Never expose inspect_image this session" }, + { name: "auto", description: "Follow inspect_image.mode (auto hides it for vision-capable models)" }, + { name: "status", description: "Show inspect_image status" }, + ], + allowArgs: true, + getTuiAutocompleteDescription: runtime => `Vision: ${runtime.ctx.session.inspectImageState().mode}`, + handle: async (command, runtime) => { + const arg = command.args.trim().toLowerCase(); + if (arg === "status") { + await runtime.output(formatVisionStatus(runtime.session)); + return commandConsumed(); + } + if (arg === "on" || arg === "off" || arg === "auto") { + await runtime.output(await applyVisionMode(runtime.session, arg)); + return commandConsumed(); + } + return usage("Usage: /vision [on|off|auto|status]", runtime); + }, + handleTui: async (command, runtime) => { + const arg = command.args.trim().toLowerCase(); + if (arg === "status") { + runtime.ctx.showStatus(formatVisionStatus(runtime.ctx.session)); + runtime.ctx.editor.setText(""); + return; + } + if (arg === "on" || arg === "off" || arg === "auto") { + runtime.ctx.showStatus(await applyVisionMode(runtime.ctx.session, arg)); + runtime.ctx.editor.setText(""); + return; + } + runtime.ctx.showStatus("Usage: /vision [on|off|auto|status]"); + runtime.ctx.editor.setText(""); + }, + }, + { + name: "prewalk", + description: "Switch to a fast/cheap model at the next action (works even without --prewalk)", + acpDescription: "Prewalk at the next action", + handle: async (_command, runtime) => { + const rolePattern = expandRoleAlias("@smol", runtime.settings); + const resolved = resolveCliModel({ + cliModel: rolePattern, + modelRegistry: runtime.session.modelRegistry, + preferences: getModelMatchPreferences(runtime.settings), + }); + if (resolved.error || !resolved.model) { + return usage(resolved.error ?? `Model "${rolePattern}" not found`, runtime); + } + if (!runtime.session.modelRegistry.hasConfiguredAuth(resolved.model)) { + return usage(`No API key for ${resolved.model.provider}/${resolved.model.id}`, runtime); + } + const armed = runtime.session.armPrewalk(resolved.model, resolved.thinkingLevel); + if (armed) { + await runtime.output( + `Prewalk on: switching to ${resolved.model.provider}/${resolved.model.id} at the next edit/write (todo-gated).`, + ); + } + return commandConsumed(); + }, + }, +]; diff --git a/packages/coding-agent/src/slash-commands/builtin-registry.ts b/packages/coding-agent/src/slash-commands/builtin-registry.ts index 7a096e3e2..beea0277a 100644 --- a/packages/coding-agent/src/slash-commands/builtin-registry.ts +++ b/packages/coding-agent/src/slash-commands/builtin-registry.ts @@ -1,83 +1,25 @@ -import * as fs from "node:fs/promises"; -import * as os from "node:os"; -import * as path from "node:path"; -import { getOAuthProviders } from "@oh-my-pi/pi-ai/oauth"; -import { type AutocompleteItem, Spacer } from "@oh-my-pi/pi-tui"; -import { APP_NAME, getMCPConfigPath, getProjectDir, logger, setProjectDir } from "@oh-my-pi/pi-utils"; -import { reset as resetCapabilities } from "../capability"; -import { COLLAB_GUEST_ALLOWED_COMMANDS, CollabGuestLink } from "../collab/guest"; -import { CollabHost } from "../collab/host"; +import type { AutocompleteItem } from "@oh-my-pi/pi-tui"; +import { COLLAB_GUEST_ALLOWED_COMMANDS } from "../collab/guest"; +import { BUILTIN_COLLABORATION_SLASH_COMMANDS } from "./builtin-collaboration"; import { - expandRoleAlias, - formatModelString, - getModelMatchPreferences, - resolveCliModel, -} from "../config/model-resolver"; -import { applyProviderGlobalsFromSettings } from "../config/provider-globals"; -import type { SettingPath, SettingValue } from "../config/settings"; -import { settings } from "../config/settings"; -import { - clearPluginRootsAndCaches, - resolveActiveProjectRegistryPath, - resolveOrDefaultProjectRegistryPath, -} from "../discovery/helpers.js"; -import { parseExportArgs } from "../export/html/args"; -import { shareSession } from "../export/share"; -import { PluginManager } from "../extensibility/plugins"; -import { - getInstalledPluginsRegistryPath, - getMarketplacesCacheDir, - getMarketplacesRegistryPath, - getPluginsCacheDir, - MarketplaceManager, -} from "../extensibility/plugins/marketplace"; -import { readMCPConfigFile } from "../mcp/config-writer"; -import { memoryStatsUnavailableMessage, resolveMemoryBackend } from "../memory-backend"; -import { runPauseScreen } from "../modes/components/pause-screen"; -import { collectMcpServerNames, MCPCommandController } from "../modes/controllers/mcp-command-controller"; -import { describeLoopLimitRuntime } from "../modes/loop-limit"; -import { theme } from "../modes/theme/theme"; -import type { InteractiveModeContext } from "../modes/types"; -import { extractLastCodeBlock, extractLastCommand } from "../modes/utils/copy-targets"; -import type { AgentSession, FreshSessionResult } from "../session/agent-session"; -import type { SessionOAuthAccountList } from "../session/agent-session-types"; -import { COMPACT_MODES, parseCompactArgs } from "../session/compact-modes"; -import { resolveResumableSession } from "../session/session-listing"; -import { formatShakeSummary, type ShakeMode } from "../session/shake-types"; -import { refreshAgentDiscovery } from "../task"; -import type { ComputerTool } from "../tools/computer"; -import { computerExposureMode } from "../tools/computer/exposure"; -import { expandTilde, resolveToCwd } from "../tools/path-utils"; -import { urlHyperlinkAlways } from "../tui"; -import { - getChangelogPath, - parseChangelog, - RECENT_CHANGELOG_ENTRY_LIMIT, - renderChangelogEntries, -} from "../utils/changelog"; -import { copyToClipboard } from "../utils/clipboard"; -import type { InspectImageMode } from "../utils/inspect-image-mode"; -import { CollabQrCodeComponent } from "./helpers/collab-qrcode"; -import { buildContextReportText } from "./helpers/context-report"; -import { formatDuration } from "./helpers/format"; -import { createMarketplaceManager } from "./helpers/marketplace-manager"; -import { handleMcpAcp } from "./helpers/mcp"; -import { commandConsumed, errorMessage, parseSlashCommand, parseSubcommand, usage } from "./helpers/parse"; -import { describeRedeemOutcome, type ResetUsageAccount, toResetUsageAccounts } from "./helpers/reset-usage"; -import { handleSecurityCommand } from "./helpers/security"; -import { matchSessionPinAccounts, toSessionPinAccounts } from "./helpers/session-pin"; -import { handleSshAcp } from "./helpers/ssh"; -import { launchStatsDashboard, parseStatsDashboardArgs } from "./helpers/stats-dashboard"; -import { handleTodoAcp } from "./helpers/todo"; -import { buildUsageReportText } from "./helpers/usage-report"; -import { parseMarketplaceInstallArgs, parsePluginScopeArgs } from "./marketplace-install-parser"; + buildArgumentCompletions, + buildDirectoryArgumentCompletions, + buildMcpArgumentCompletions, + buildStaticInlineHint, + buildSubcommandInlineHint, +} from "./builtin-completions"; +import { BUILTIN_CONTROL_SLASH_COMMANDS } from "./builtin-control"; +import { BUILTIN_LIFECYCLE_SLASH_COMMANDS } from "./builtin-lifecycle"; +import { BUILTIN_MARKETPLACE_SLASH_COMMANDS, reloadTuiPluginState } from "./builtin-marketplace"; +import { BUILTIN_MODE_SLASH_COMMANDS } from "./builtin-modes"; +import { BUILTIN_SESSION_SLASH_COMMANDS } from "./builtin-session"; +import { parseSlashCommand } from "./helpers/parse"; import type { BuiltinSlashCommand, ParsedSlashCommand, SlashCommandResult, SlashCommandRuntime, SlashCommandSpec, - SubcommandDef, TuiSlashCommandRuntime, } from "./types"; @@ -92,2633 +34,13 @@ export interface TuiBuiltinSlashCommand extends BuiltinSlashCommand { getAutocompleteDescription?: () => string | undefined; } -function refreshStatusLine(ctx: InteractiveModeContext): void { - ctx.statusLine.invalidate(); - ctx.ui.requestRender(); -} - -/** `/fast status` label for the active model: "on" when its family is priority, else "off". */ -function formatFastModeStatus(session: AgentSession): string { - return session.isFastModeEnabled() ? "on" : "off"; -} - -/** Detailed, session-effective `/computer status` diagnostics. */ -async function formatComputerUseStatus(session: AgentSession): Promise<string> { - const enabled = session.settings.get("computer.enabled"); - const active = session.getEnabledToolNames().includes("computer"); - const model = session.model; - const modelName = model ? formatModelString(model) : "none"; - const exposure = !enabled - ? "not exposed (disabled)" - : !active - ? "not exposed (tool inactive)" - : computerExposureMode(model); - const configured = { - display: session.settings.get("computer.display"), - maxWidth: session.settings.get("computer.maxWidth"), - maxHeight: session.settings.get("computer.maxHeight"), - }; - const computerTool = active - ? (session.getToolByName("computer") as Pick<ComputerTool, "capabilities"> | undefined) - : undefined; - const capabilities = await computerTool?.capabilities(); - const capabilityStatus = capabilities - ? [ - `backend=${capabilities.backend}${capabilities.displayServer ? `/${capabilities.displayServer}` : ""}`, - `capture=${capabilities.capture} (${capabilities.capturePermission})`, - `input=${capabilities.input} (${capabilities.inputPermission})`, - `ax=${capabilities.ax} (${capabilities.axPermission})`, - `backgroundWindowInput=${capabilities.backgroundWindowInput}`, - `deliveryModes=${capabilities.deliveryModes.join(",") || "none"}`, - ].join(", ") - : "session not started"; - return [ - `Computer use: ${enabled ? "enabled" : "disabled"}`, - `tool: ${active ? "active" : "inactive"}`, - `exposure: ${exposure}`, - `model: ${modelName}`, - `configured: display=${configured.display}, maxWidth=${configured.maxWidth}, maxHeight=${configured.maxHeight}`, - `capabilities: ${capabilityStatus}`, - ].join(" · "); -} - -/** - * Apply a session-scoped computer-use toggle: flip the active tool slate first - * (so a failed enable never leaves a stale settings override), then record the - * runtime override — never `settings.set`, which would persist to settings.json. - * Returns the operator feedback line. - */ -async function applyComputerUseToggle(session: AgentSession, enable: boolean): Promise<string> { - const applied = await session.setComputerToolEnabled(enable); - if (enable && !applied) { - return "Computer use is unavailable in this session."; - } - session.settings.override("computer.enabled", enable); - return enable - ? `Computer use enabled for this session. ${await formatComputerUseStatus(session)}` - : "Computer use disabled for this session."; -} - -/** Session-effective `/vision status` line. */ -function formatVisionStatus(session: AgentSession): string { - const { mode, active, model } = session.inspectImageState(); - const override = session.getInspectImageModeOverride(); - const modelObj = session.model; - const capability = modelObj - ? modelObj.input.includes("image") - ? "native image input" - : "no native image input" - : "no active model"; - return [ - `inspect_image: ${active ? "active" : "inactive"}`, - `mode: ${mode}${override ? " (session override)" : ""}`, - ...(override ? [`configured: ${session.settings.get("inspect_image.mode")}`] : []), - `model: ${model ?? "none"} (${capability})`, - ].join(" · "); -} - -/** Applies a `/vision` mode for this session and returns the operator feedback line. */ -async function applyVisionMode(session: AgentSession, mode: InspectImageMode): Promise<string> { - const applied = await session.setInspectImageMode(mode); - if (!applied) { - return "inspect_image is unavailable in this session."; - } - return `Vision mode: ${mode}. ${formatVisionStatus(session)}`; -} - -const AUTOCOMPLETE_DETAIL_LIMIT = 48; - -function shortDetail(value: string, limit = AUTOCOMPLETE_DETAIL_LIMIT): string { - const singleLine = value.replace(/\s+/g, " ").trim(); - return singleLine.length <= limit ? singleLine : `${singleLine.slice(0, limit - 1)}…`; -} - -function formatTokenCount(value: number): string { - return value.toLocaleString(); -} - -/** Scheme-less display form of a browser deep link: accent + underline, OSC-8 linked to the full URL. */ -function collabWebLinkClickable(webLink: string): string { - const display = theme.fg("accent", `\x1b[4m${webLink.replace(/^https?:\/\//, "")}\x1b[24m`); - return urlHyperlinkAlways(webLink, display); -} - -/** Join hint printed by /collab: compact terminal link + clickable browser deep link. */ -function collabLinkHint(host: CollabHost, heading: string, view = false): string { - const bullet = theme.fg("accent", theme.format.bullet); - const link = view ? host.viewLink : host.link; - const webLink = view ? host.webViewLink : host.webLink; - return [ - theme.fg("success", heading), - ` ${bullet} ${theme.fg("muted", view ? "Watch from another terminal:" : "Join from another terminal:")} ${APP_NAME} join "${link}"`, - ` ${bullet} ${theme.fg("muted", "or any web browser:")} ${collabWebLinkClickable(webLink)}`, - theme.fg( - "dim", - view - ? "Anyone with this link can watch the session but cannot prompt the agent." - : "Anyone with the link can read the session and prompt the agent. Read-only link: /collab view", - ), - ].join("\n"); -} - -function showCollabQrCode(ctx: InteractiveModeContext, webLink: string): void { - try { - ctx.present([new Spacer(1), new CollabQrCodeComponent(webLink)]); - } catch (err) { - ctx.showError(`Failed to render collab QR code: ${errorMessage(err)}`); - } -} - -function showCollabLink(ctx: InteractiveModeContext, host: CollabHost, heading: string, view = false): void { - ctx.showStatus(collabLinkHint(host, heading, view), { dim: false }); - showCollabQrCode(ctx, view ? host.webViewLink : host.webLink); -} - -function formatFreshSessionResult(result: FreshSessionResult): string { - const stateLabel = result.closedProviderSessions === 1 ? "provider state" : "provider states"; - return `Fresh provider session started (${result.closedProviderSessions} ${stateLabel} pruned).`; -} - -const shutdownHandlerTui = (_command: ParsedSlashCommand, runtime: TuiSlashCommandRuntime): SlashCommandResult => { - runtime.ctx.editor.setText(""); - void runtime.ctx.shutdown(); - return commandConsumed(); -}; - -async function handleUsageResetCommand( - arg: string, - session: AgentSession, - output: SlashCommandRuntime["output"], -): Promise<void> { - let accounts: ResetUsageAccount[]; - try { - accounts = toResetUsageAccounts(await session.listResetCredits()); - } catch (error) { - await output(`Could not load saved resets: ${errorMessage(error)}`); - return; - } - if (accounts.length === 0) { - await output("No Codex accounts found. Use /login to add one."); - return; - } - const targetArg = arg.trim(); - if (!targetArg) { - const lines = ["Saved Codex rate-limit resets:"]; - for (const account of accounts) { - const detail = account.error ? `unavailable (${account.error})` : `${account.availableCount} available`; - lines.push(`- ${account.label}: ${detail}${account.active ? " (active)" : ""}`); - } - lines.push("", "Spend one with `/usage reset <account email>` or `/usage reset active`."); - await output(lines.join("\n")); - return; - } - const wanted = targetArg.toLowerCase(); - const target = - wanted === "active" - ? accounts.find(account => account.active) - : accounts.find( - account => - account.label.toLowerCase() === wanted || - account.target.email?.toLowerCase() === wanted || - account.target.accountId?.toLowerCase() === wanted, - ); - if (!target) { - await output(`No Codex account matches "${targetArg}".`); - return; - } - if (target.availableCount <= 0) { - await output(`${target.label}: no saved resets to spend.`); - return; - } - const outcome = await session.redeemResetCredit(target.target); - await output(describeRedeemOutcome(outcome, target.label)); -} - -async function handleSessionPinCommand( - arg: string, - session: AgentSession, - output: SlashCommandRuntime["output"], -): Promise<void> { - if (session.isStreaming) { - await output("Cannot pin an account while the session is streaming."); - return; - } - let accountList: SessionOAuthAccountList | undefined; - try { - accountList = await session.listCurrentProviderOAuthAccounts(); - } catch (error) { - await output(`Could not load provider accounts: ${errorMessage(error)}`); - return; - } - if (!accountList) { - await output("Select a model before pinning a provider account."); - return; - } - const provider = getOAuthProviders().find(candidate => candidate.id === accountList.provider); - const providerName = provider?.name ?? accountList.provider; - const accounts = toSessionPinAccounts(accountList.accounts); - if (accounts.length === 0) { - const source = session.modelRegistry.authStorage.describeCredentialSource( - accountList.provider, - session.sessionId, - ); - await output( - source - ? `No stored OAuth accounts for ${providerName}. Current auth comes from ${source}.` - : `No stored OAuth accounts for ${providerName}. Use /login to add one.`, - ); - return; - } - - const selector = arg.trim(); - if (!selector) { - const lines = [`OAuth accounts for ${providerName}:`]; - for (const account of accounts) { - lines.push(`${account.position + 1}. ${account.label}${account.active ? " (active)" : ""}`); - } - lines.push("", "Pin one with `/session pin <number|email|account id>`."); - await output(lines.join("\n")); - return; - } - - const matches = matchSessionPinAccounts(accounts, selector); - if (matches.length === 0) { - await output(`No ${providerName} account matches "${selector}".`); - return; - } - if (matches.length > 1) { - await output( - `"${selector}" matches multiple ${providerName} accounts: ${matches - .map(account => `${account.position + 1}. ${account.label}`) - .join(", ")}. Use the account number.`, - ); - return; - } - const account = matches[0]; - if (!account || !session.pinCurrentProviderOAuthAccount(account.credentialId)) { - await output(`${account?.label ?? selector} is no longer available to pin.`); - return; - } - await output(`Pinned ${account.label} to this session for ${providerName}.`); -} - -/** Parse the `/shake` subcommand into a {@link ShakeMode}; empty defaults to elide. */ -function parseShakeMode(args: string): ShakeMode | { error: string } { - const verb = args.trim().toLowerCase(); - if (verb === "" || verb === "elide") return "elide"; - if (verb === "images") return "images"; - return { error: `Unknown /shake mode "${verb}". Use elide or images.` }; -} - -/** Format the session's workspace directories (cwd + additional) for display. */ -function formatWorkspaceDirectories(runtime: SlashCommandRuntime, note?: string): string { - const cwd = runtime.sessionManager.getCwd(); - const additional = runtime.sessionManager.getAdditionalDirectories(); - const lines = ["Workspace directories:", ` ${cwd} (working directory)`, ...additional.map(d => ` ${d}`)]; - return note ? `${note}\n${lines.join("\n")}` : lines.join("\n"); -} - const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [ - { - name: "security", - description: "Plan, run, inspect, import, and compare OMP-native security scans", - allowArgs: true, - acpInputHint: "<plan|scan|status|cancel|scans|show|import|export|validate|compare|disposition>", - subcommands: [ - { name: "plan", description: "Create an immutable security scan plan" }, - { name: "scan", description: "Start a planned or newly planned native scan" }, - { name: "status", description: "Show native scan operation status" }, - { name: "cancel", description: "Cancel a running native scan" }, - { name: "scans", description: "List stored project security scans" }, - { name: "show", description: "Render a scan or security:// resource" }, - { name: "import", description: "Import SARIF or a Codex Security bundle" }, - { name: "export", description: "Export a canonical bundle, SARIF, or report" }, - { name: "validate", description: "Validate one finding with OMP-native tools" }, - { name: "compare", description: "Compare finding lineage across two scans" }, - { name: "disposition", description: "Set a finding disposition with rationale" }, - ], - handle: handleSecurityCommand, - }, - { - name: "settings", - description: "Open settings menu", - handleTui: (_command, runtime) => { - runtime.ctx.showSettingsSelector(); - runtime.ctx.editor.setText(""); - }, - }, - { - name: "setup", - aliases: ["providers"], - description: "Open provider setup", - allowArgs: true, - subcommands: [{ name: "providers", description: "Configure sign-in and web search providers" }], - handleTui: async (command, runtime) => { - const args = command.args.trim().toLowerCase(); - const opensProviders = args === "" || args === "providers"; - if (opensProviders) { - await runtime.ctx.showProviderSetup(); - } else { - runtime.ctx.showWarning(`Usage: /${command.name} [providers]`); - } - runtime.ctx.editor.setText(""); - }, - }, - { - name: "plan", - description: "Toggle plan mode (agent plans before executing)", - inlineHint: "[prompt]", - allowArgs: true, - getTuiAutocompleteDescription: runtime => { - if (!runtime.ctx.settings.get("plan.enabled" as SettingPath)) return "Plan: disabled in settings"; - if (runtime.ctx.planModeEnabled) { - const planFile = runtime.ctx.planModePlanFilePath; - return `Plan: on${planFile ? ` (${path.basename(planFile)})` : ""}`; - } - if (runtime.ctx.goalModeEnabled) return "Plan: blocked by goal mode"; - return "Plan: off"; - }, - handleTui: async (command, runtime) => { - await runtime.ctx.handlePlanModeCommand(command.args || undefined); - runtime.ctx.editor.setText(""); - }, - }, - { - name: "plan-review", - description: "Re-open the plan review for the latest plan (plan mode only)", - getTuiAutocompleteDescription: runtime => - runtime.ctx.planModeEnabled ? "Plan review: available" : "Plan review: plan mode inactive", - handleTui: async (_command, runtime) => { - await runtime.ctx.openPlanReview(); - runtime.ctx.editor.setText(""); - }, - }, - { - name: "vibe", - description: "Toggle vibe mode (direct persistent fast/good worker sessions; read-only toolset)", - inlineHint: "[prompt]", - allowArgs: true, - getTuiAutocompleteDescription: runtime => { - if (runtime.ctx.vibeModeEnabled) return "Vibe: on"; - if (runtime.ctx.planModeEnabled) return "Vibe: blocked by plan mode"; - if (runtime.ctx.goalModeEnabled) return "Vibe: blocked by goal mode"; - return "Vibe: off"; - }, - handleTui: async (command, runtime) => { - await runtime.ctx.handleVibeModeCommand(command.args || undefined); - runtime.ctx.editor.setText(""); - }, - }, - { - name: "goal", - description: "Toggle goal mode (persistent autonomous objective for this session)", - subcommands: [ - { name: "set", description: "Set or replace the goal", usage: "<objective>" }, - { name: "show", description: "Show current goal details" }, - { name: "pause", description: "Pause the current goal" }, - { name: "resume", description: "Resume a paused goal" }, - { name: "drop", description: "Drop the current goal" }, - { name: "budget", description: "Adjust the token budget", usage: "<N|off>" }, - ], - inlineHint: "[objective]", - allowArgs: true, - getTuiAutocompleteDescription: runtime => { - if (!runtime.ctx.settings.get("goal.enabled" as SettingPath)) return "Goal: disabled in settings"; - if (runtime.ctx.planModeEnabled) return "Goal: blocked by plan mode"; - const state = runtime.ctx.session.getGoalModeState(); - return state ? `Goal: ${state.goal.status} (${shortDetail(state.goal.objective)})` : "Goal: off"; - }, - handleTui: async (command, runtime) => { - await runtime.ctx.handleGoalModeCommand(command.args || undefined); - runtime.ctx.editor.setText(""); - }, - }, - { - name: "guided-goal", - description: "Have the agent interview you in chat, then set up goal mode", - inlineHint: "[rough objective]", - allowArgs: true, - handleTui: async (command, runtime) => { - // Clear the slash draft BEFORE the await: the handler blocks for the - // whole kickoff turn, and a post-await clear would wipe an answer the - // user starts typing while the first interview question streams. - runtime.ctx.editor.setText(""); - await runtime.ctx.handleGuidedGoalCommand(command.args || undefined); - }, - }, - { - name: "loop", - description: - "Toggle loop mode. While enabled, the next prompt you send re-submits after every yield. Esc cancels the current iteration; /loop again to disable.", - inlineHint: "[count|duration] [prompt]", - allowArgs: true, - getTuiAutocompleteDescription: runtime => { - if (!runtime.ctx.loopModeEnabled) return "Loop: off"; - if (runtime.ctx.loopModePaused) return "Loop: paused"; - if (runtime.ctx.loopLimit) return `Loop: on (${describeLoopLimitRuntime(runtime.ctx.loopLimit)})`; - if (runtime.ctx.loopPrompt) return "Loop: on (repeating prompt)"; - return "Loop: on (waiting for next prompt)"; - }, - handleTui: async (command, runtime) => { - const prompt = await runtime.ctx.handleLoopCommand(command.args); - runtime.ctx.editor.setText(""); - // Surface any inline prompt so the dispatcher returns it and the normal - // submit flow runs the first loop iteration (recording it as the loop prompt). - if (prompt) return { prompt }; - }, - }, - { - name: "queue", - description: "Queue a message for after the agent yields", - inlineHint: "<message>", - allowArgs: true, - handleTui: async (command, runtime) => { - await runtime.ctx.handleQueueCommand(command.args); - }, - }, - { - name: "model", - aliases: ["models"], - description: "Switch model for this session", - acpDescription: "Show current model selection", - getTuiAutocompleteDescription: runtime => { - const model = runtime.ctx.session.model; - return model ? `Model: ${model.provider}/${model.id}` : "Model: none selected"; - }, - handle: async (command, runtime) => { - if (command.args) { - const modelId = command.args.trim(); - const availableModels = runtime.session.getAvailableModels?.() ?? []; - const match = availableModels.find( - model => model.id === modelId || `${model.provider}/${model.id}` === modelId, - ); - if (!match) { - return usage( - `Unknown model: ${modelId}. Use ACP \`session/setModel\` for picker-driven selection or list available models with /model.`, - runtime, - ); - } - try { - await runtime.session.setModel(match); - await runtime.output(`Model set to ${match.provider}/${match.id}.`); - await runtime.notifyTitleChanged?.(); - await runtime.notifyConfigChanged?.(); - return commandConsumed(); - } catch (err) { - return usage(`Failed to set model: ${errorMessage(err)}`, runtime); - } - } - - const model = runtime.session.model; - await runtime.output( - model ? `Current model: ${model.provider}/${model.id}` : "No model is currently selected.", - ); - return commandConsumed(); - }, - handleTui: (_command, runtime) => { - runtime.ctx.showModelSelector(); - runtime.ctx.editor.setText(""); - }, - }, - { - name: "switch", - description: "Switch model for this session (same as alt+p)", - getTuiAutocompleteDescription: runtime => { - const model = runtime.ctx.session.model; - return model ? `Model: ${model.provider}/${model.id}` : "Model: none selected"; - }, - handleTui: (_command, runtime) => { - runtime.ctx.showModelSelector({ temporaryOnly: true }); - runtime.ctx.editor.setText(""); - }, - }, - { - name: "fast", - description: "Toggle priority service tier (OpenAI service_tier=priority, Anthropic speed=fast)", - acpDescription: "Toggle fast mode", - acpInputHint: "[on|off|status]", - subcommands: [ - { name: "on", description: "Enable fast mode" }, - { name: "off", description: "Disable fast mode" }, - { name: "status", description: "Show fast mode status" }, - ], - allowArgs: true, - getTuiAutocompleteDescription: runtime => `Fast: ${formatFastModeStatus(runtime.ctx.session)}`, - handle: async (command, runtime) => { - const arg = command.args.toLowerCase(); - if (!arg || arg === "toggle") { - const enabled = runtime.session.toggleFastMode(); - await runtime.output(`Fast mode ${enabled ? "enabled" : "disabled"}.`); - return commandConsumed(); - } - if (arg === "on") { - const supported = runtime.session.setFastMode(true); - await runtime.output(supported ? "Fast mode enabled." : "Fast mode is unavailable for the current model."); - return commandConsumed(); - } - if (arg === "off") { - runtime.session.setFastMode(false); - await runtime.output("Fast mode disabled."); - return commandConsumed(); - } - if (arg === "status") { - await runtime.output(`Fast mode is ${formatFastModeStatus(runtime.session)}.`); - return commandConsumed(); - } - return usage("Usage: /fast [on|off|status]", runtime); - }, - handleTui: (command, runtime) => { - const arg = command.args.trim().toLowerCase(); - if (!arg || arg === "toggle") { - const enabled = runtime.ctx.session.toggleFastMode(); - refreshStatusLine(runtime.ctx); - runtime.ctx.showStatus(`Fast mode ${enabled ? "enabled" : "disabled"}.`); - runtime.ctx.editor.setText(""); - return; - } - if (arg === "on") { - const supported = runtime.ctx.session.setFastMode(true); - refreshStatusLine(runtime.ctx); - runtime.ctx.showStatus( - supported ? "Fast mode enabled." : "Fast mode is unavailable for the current model.", - ); - runtime.ctx.editor.setText(""); - return; - } - if (arg === "off") { - runtime.ctx.session.setFastMode(false); - refreshStatusLine(runtime.ctx); - runtime.ctx.showStatus("Fast mode disabled."); - runtime.ctx.editor.setText(""); - return; - } - if (arg === "status") { - runtime.ctx.showStatus(`Fast mode is ${formatFastModeStatus(runtime.ctx.session)}.`); - runtime.ctx.editor.setText(""); - return; - } - runtime.ctx.showStatus("Usage: /fast [on|off|status]"); - runtime.ctx.editor.setText(""); - }, - }, - { - name: "computer", - description: "Toggle the native computer-use tool for this session", - acpDescription: "Toggle computer use", - acpInputHint: "[on|off|status]", - subcommands: [ - { name: "on", description: "Enable computer use for this session" }, - { name: "off", description: "Disable computer use for this session" }, - { name: "status", description: "Show computer use status" }, - ], - allowArgs: true, - getTuiAutocompleteDescription: runtime => - `Computer: ${runtime.ctx.session.settings.get("computer.enabled") ? "on" : "off"}`, - handle: async (command, runtime) => { - const arg = command.args.trim().toLowerCase(); - if (arg === "status") { - await runtime.output(await formatComputerUseStatus(runtime.session)); - return commandConsumed(); - } - if (!arg || arg === "toggle" || arg === "on" || arg === "off") { - const enable = arg === "off" ? false : arg === "on" || !runtime.session.settings.get("computer.enabled"); - await runtime.output(await applyComputerUseToggle(runtime.session, enable)); - return commandConsumed(); - } - return usage("Usage: /computer [on|off|status]", runtime); - }, - handleTui: async (command, runtime) => { - const arg = command.args.trim().toLowerCase(); - if (arg === "status") { - runtime.ctx.showStatus(await formatComputerUseStatus(runtime.ctx.session)); - runtime.ctx.editor.setText(""); - return; - } - if (!arg || arg === "toggle" || arg === "on" || arg === "off") { - const enable = - arg === "off" ? false : arg === "on" || !runtime.ctx.session.settings.get("computer.enabled"); - runtime.ctx.showStatus(await applyComputerUseToggle(runtime.ctx.session, enable)); - runtime.ctx.editor.setText(""); - return; - } - runtime.ctx.showStatus("Usage: /computer [on|off|status]"); - runtime.ctx.editor.setText(""); - }, - }, - { - name: "vision", - description: "Control the inspect_image vision-delegation tool for this session", - acpDescription: "Toggle vision delegation", - acpInputHint: "[on|off|auto|status]", - subcommands: [ - { name: "on", description: "Always expose inspect_image this session" }, - { name: "off", description: "Never expose inspect_image this session" }, - { name: "auto", description: "Follow inspect_image.mode (auto hides it for vision-capable models)" }, - { name: "status", description: "Show inspect_image status" }, - ], - allowArgs: true, - getTuiAutocompleteDescription: runtime => `Vision: ${runtime.ctx.session.inspectImageState().mode}`, - handle: async (command, runtime) => { - const arg = command.args.trim().toLowerCase(); - if (arg === "status") { - await runtime.output(formatVisionStatus(runtime.session)); - return commandConsumed(); - } - if (arg === "on" || arg === "off" || arg === "auto") { - await runtime.output(await applyVisionMode(runtime.session, arg)); - return commandConsumed(); - } - return usage("Usage: /vision [on|off|auto|status]", runtime); - }, - handleTui: async (command, runtime) => { - const arg = command.args.trim().toLowerCase(); - if (arg === "status") { - runtime.ctx.showStatus(formatVisionStatus(runtime.ctx.session)); - runtime.ctx.editor.setText(""); - return; - } - if (arg === "on" || arg === "off" || arg === "auto") { - runtime.ctx.showStatus(await applyVisionMode(runtime.ctx.session, arg)); - runtime.ctx.editor.setText(""); - return; - } - runtime.ctx.showStatus("Usage: /vision [on|off|auto|status]"); - runtime.ctx.editor.setText(""); - }, - }, - { - name: "prewalk", - description: "Switch to a fast/cheap model at the next action (works even without --prewalk)", - acpDescription: "Prewalk at the next action", - handle: async (_command, runtime) => { - const rolePattern = expandRoleAlias("@smol", runtime.settings); - const resolved = resolveCliModel({ - cliModel: rolePattern, - modelRegistry: runtime.session.modelRegistry, - preferences: getModelMatchPreferences(runtime.settings), - }); - if (resolved.error || !resolved.model) { - return usage(resolved.error ?? `Model "${rolePattern}" not found`, runtime); - } - if (!runtime.session.modelRegistry.hasConfiguredAuth(resolved.model)) { - return usage(`No API key for ${resolved.model.provider}/${resolved.model.id}`, runtime); - } - const armed = runtime.session.armPrewalk(resolved.model, resolved.thinkingLevel); - if (armed) { - await runtime.output( - `Prewalk on: switching to ${resolved.model.provider}/${resolved.model.id} at the next edit/write (todo-gated).`, - ); - } - return commandConsumed(); - }, - }, - { - name: "advisor", - description: "Toggle the advisor (a second model that reviews each turn and injects notes)", - acpDescription: "Toggle advisor", - acpInputHint: "[on|off|status|dump [raw]|configure]", - subcommands: [ - { name: "on", description: "Enable the advisor" }, - { name: "off", description: "Disable the advisor" }, - { name: "status", description: "Show advisor status" }, - { name: "dump", description: "Copy the advisor's transcript to clipboard", usage: "[raw]" }, - { name: "configure", description: "Open the advisor configuration editor (TUI)" }, - ], - allowArgs: true, - getTuiAutocompleteDescription: runtime => { - const stats = runtime.ctx.session.getAdvisorStats(); - if (stats.active && stats.advisors.length > 1) return `Advisor: on (${stats.advisors.length} advisors)`; - if (stats.active && stats.model) return `Advisor: on (${stats.model.provider}/${stats.model.id})`; - if (stats.configured) return "Advisor: configured, no model"; - return "Advisor: off"; - }, - handle: async (command, runtime) => { - const { verb, rest } = parseSubcommand(command.args); - if (!verb || verb === "toggle") { - const active = runtime.session.toggleAdvisorEnabled(); - const configured = runtime.session.isAdvisorEnabled(); - if (active) { - await runtime.output("Advisor enabled."); - } else if (configured) { - await runtime.output("Advisor setting enabled, but no model is assigned to the 'advisor' role."); - } else { - await runtime.output("Advisor disabled."); - } - return commandConsumed(); - } - if (verb === "on") { - const active = runtime.session.setAdvisorEnabled(true); - await runtime.output( - active ? "Advisor enabled." : "Advisor setting enabled, but no model is assigned to the 'advisor' role.", - ); - return commandConsumed(); - } - if (verb === "off") { - runtime.session.setAdvisorEnabled(false); - await runtime.output("Advisor disabled."); - return commandConsumed(); - } - if (verb === "status") { - await runtime.output(runtime.session.formatAdvisorStatus()); - return commandConsumed(); - } - if (verb === "dump") { - const isRaw = rest.toLowerCase() === "raw"; - const text = runtime.session.formatAdvisorHistoryAsText({ compact: !isRaw }); - await runtime.output(text ?? "Advisor is not active for this session."); - return commandConsumed(); - } - if (verb === "configure") { - await runtime.output( - "/advisor configure opens an interactive editor and is only available in the interactive TUI.", - ); - return commandConsumed(); - } - return usage("Usage: /advisor [on|off|status|dump [raw]|configure]", runtime); - }, - handleTui: async (command, runtime) => { - const { verb, rest } = parseSubcommand(command.args); - if (!verb || verb === "toggle") { - const active = runtime.ctx.session.toggleAdvisorEnabled(); - const configured = runtime.ctx.session.isAdvisorEnabled(); - if (active) { - runtime.ctx.showStatus("Advisor enabled."); - } else if (configured) { - runtime.ctx.showStatus("Advisor setting enabled, but no model is assigned to the 'advisor' role."); - } else { - runtime.ctx.showStatus("Advisor disabled."); - } - refreshStatusLine(runtime.ctx); - runtime.ctx.editor.setText(""); - return; - } - if (verb === "on") { - const active = runtime.ctx.session.setAdvisorEnabled(true); - runtime.ctx.showStatus( - active ? "Advisor enabled." : "Advisor setting enabled, but no model is assigned to the 'advisor' role.", - ); - refreshStatusLine(runtime.ctx); - runtime.ctx.editor.setText(""); - return; - } - if (verb === "off") { - runtime.ctx.session.setAdvisorEnabled(false); - runtime.ctx.showStatus("Advisor disabled."); - refreshStatusLine(runtime.ctx); - runtime.ctx.editor.setText(""); - return; - } - if (verb === "status") { - await runtime.ctx.handleAdvisorStatusCommand(); - runtime.ctx.editor.setText(""); - return; - } - if (verb === "dump") { - const isRaw = rest.toLowerCase() === "raw"; - runtime.ctx.handleAdvisorDumpCommand(isRaw); - runtime.ctx.editor.setText(""); - return; - } - if (verb === "configure") { - runtime.ctx.showAdvisorConfigure(); - runtime.ctx.editor.setText(""); - return; - } - runtime.ctx.showStatus("Usage: /advisor [on|off|status|dump [raw]|configure]"); - runtime.ctx.editor.setText(""); - }, - }, - { - name: "export", - description: "Export session to HTML file", - inlineHint: "[--themes] [path]", - allowArgs: true, - handle: async (command, runtime) => { - try { - const { outputPath, useUserThemes } = parseExportArgs(command.args); - if (outputPath === "--copy" || outputPath === "clipboard" || outputPath === "copy") { - return usage("Use /dump to copy the session to clipboard.", runtime); - } - const filePath = await runtime.session.exportToHtml(outputPath, useUserThemes); - await runtime.output(`Session exported to: ${filePath}`); - return commandConsumed(); - } catch (err) { - return usage(`Failed to export session: ${errorMessage(err)}`, runtime); - } - }, - handleTui: async (command, runtime) => { - await runtime.ctx.handleExportCommand(command.text); - runtime.ctx.editor.setText(""); - }, - }, - { - name: "dump", - description: "Copy session transcript to clipboard (and write LLM request JSON to tmp)", - acpDescription: "Return full transcript as plain text, with LLM request JSON path", - allowArgs: true, - handle: async (_command, runtime) => { - const text = runtime.session.formatSessionAsText(); - if (!text) { - await runtime.output("No messages to dump yet."); - return commandConsumed(); - } - let sidecarPath: string | undefined; - try { - sidecarPath = await runtime.session.dumpLlmRequestToTmpDir(); - } catch { - // Sidecar is best-effort; the transcript is still output below. - } - const lines = [text]; - if (sidecarPath) - lines.push( - "", - `LLM request JSON: ${sidecarPath}`, - "This file persists on disk and may contain raw context/secrets — treat accordingly.", - ); - await runtime.output(lines.join("\n")); - return commandConsumed(); - }, - handleTui: async (_command, runtime) => { - await runtime.ctx.handleDumpCommand(); - runtime.ctx.editor.setText(""); - }, - }, - { - name: "share", - description: "Share session via an encrypted link (share server or secret gist)", - handle: async (_command, runtime) => { - try { - const result = await shareSession(runtime.sessionManager, { - serverUrl: runtime.settings.get("share.serverUrl"), - store: runtime.settings.get("share.store"), - state: runtime.session.state, - obfuscator: runtime.settings.get("share.redactSecrets") ? runtime.session.obfuscator : undefined, - }); - const lines = [`Share URL: ${result.url}`]; - if (result.gistUrl) lines.push(`Gist: ${result.gistUrl}`); - if (result.truncated) lines.push("Note: large content was trimmed to fit the share size limit."); - await runtime.output(lines.join("\n")); - return commandConsumed(); - } catch (err) { - return usage(`Failed to share session: ${errorMessage(err)}`, runtime); - } - }, - handleTui: async (_command, runtime) => { - await runtime.ctx.handleShareCommand(); - runtime.ctx.editor.setText(""); - }, - }, - { - name: "collab", - description: "Share this session live via a relay", - inlineHint: "[start|view|stop|status] [relayUrl]", - subcommands: [ - { name: "view", description: "Share a read-only link (guests can watch, not prompt)" }, - { name: "status", description: "Show link + participants" }, - { name: "stop", description: "Stop sharing" }, - ], - allowArgs: true, - getTuiAutocompleteDescription: runtime => { - if (runtime.ctx.collabHost) { - return `Collab: hosting (${Math.max(0, runtime.ctx.collabHost.participants.length - 1)} guests)`; - } - if (runtime.ctx.collabGuest?.readOnly) return "Collab: read-only guest"; - if (runtime.ctx.collabGuest) return "Collab: guest"; - return "Collab: off"; - }, - handleTui: async (command, runtime) => { - const ctx = runtime.ctx; - ctx.editor.setText(""); - const args = command.args.trim(); - const { verb, rest } = parseSubcommand(args); - if (verb === "stop") { - if (!ctx.collabHost) { - ctx.showStatus("Not hosting a collab session"); - return; - } - await ctx.collabHost.stop("host stopped"); - ctx.showStatus("Collab stopped"); - return; - } - if (verb === "status") { - if (ctx.collabHost) { - const names = ctx.collabHost.participants.map(p => - p.role === "host" ? `${p.name} (host)` : p.readOnly ? `${p.name} (view-only)` : p.name, - ); - ctx.showStatus(`Collab: ${names.join(", ")} — ${collabWebLinkClickable(ctx.collabHost.webLink)}`); - } else if (ctx.collabGuest) { - ctx.showStatus( - ctx.collabGuest.readOnly - ? "In a collab session as a read-only guest (/leave to exit)" - : "In a collab session as a guest (/leave to exit)", - ); - } else { - ctx.showStatus("Not in a collab session"); - } - return; - } - if (ctx.collabGuest) { - ctx.showError("Already in a collab session as a guest (/leave first)"); - return; - } - const knownStartVerb = verb === "start" || verb === "view"; - const view = verb === "view"; - if (ctx.collabHost) { - showCollabLink( - ctx, - ctx.collabHost, - view ? "Read-only collab session active" : "Collab session active", - view, - ); - return; - } - const explicitUrl = knownStartVerb ? rest : args; - const relayInput = explicitUrl || ctx.settings.get("collab.relayUrl") || ""; - if (!relayInput) { - ctx.showError( - "No relay configured. Set collab.relayUrl in /settings or pass one: /collab relay.example.com", - ); - return; - } - // Scheme-less relay args default to wss (ws:// must be spelled out for localhost). - const relayUrl = relayInput.includes("://") ? relayInput : `wss://${relayInput}`; - const webUrl = ctx.settings.get("collab.webUrl") || ""; - const host = new CollabHost(ctx); - try { - await host.start(relayUrl, webUrl); - } catch (err) { - ctx.showError(`Failed to start collab session: ${errorMessage(err)}`); - return; - } - ctx.collabHost = host; - showCollabLink(ctx, host, "Collab session started!", view); - }, - }, - { - name: "join", - description: "Join a shared collab session", - inlineHint: "<link>", - allowArgs: true, - handleTui: async (command, runtime) => { - const ctx = runtime.ctx; - ctx.editor.setText(""); - const link = command.args.trim(); - if (!link) { - ctx.showError("Usage: /join <link>"); - return; - } - if (ctx.collabHost) { - ctx.showError("Stop hosting first (/collab stop)"); - return; - } - if (ctx.collabGuest) { - ctx.showError("Already in a collab session (/leave first)"); - return; - } - try { - await new CollabGuestLink(ctx).join(link); - } catch (err) { - ctx.showError(`Failed to join collab session: ${errorMessage(err)}`); - } - }, - }, - { - name: "leave", - description: "Leave the collab session", - getTuiAutocompleteDescription: runtime => { - if (runtime.ctx.collabHost) return "Leave collab: hosting"; - if (runtime.ctx.collabGuest) return "Leave collab: guest"; - return "Leave collab: not in collab"; - }, - handleTui: async (_command, runtime) => { - const ctx = runtime.ctx; - ctx.editor.setText(""); - if (ctx.collabGuest) { - await ctx.collabGuest.leave("left"); - return; - } - if (ctx.collabHost) { - await ctx.collabHost.stop("host stopped"); - ctx.showStatus("Collab stopped"); - return; - } - ctx.showStatus("Not in a collab session"); - }, - }, - { - name: "browser", - description: "Toggle browser headless vs visible mode", - acpInputHint: "[headless|visible]", - subcommands: [ - { name: "headless", description: "Switch to headless mode" }, - { name: "visible", description: "Switch to visible mode" }, - ], - allowArgs: true, - getTuiAutocompleteDescription: runtime => { - if (!runtime.ctx.settings.get("browser.enabled" as SettingPath)) return "Browser: disabled"; - return runtime.ctx.settings.get("browser.headless" as SettingPath) ? "Browser: headless" : "Browser: visible"; - }, - handle: async (command, runtime) => { - const arg = command.args.toLowerCase(); - const enabled = runtime.settings.get("browser.enabled" as SettingPath) as boolean; - if (!enabled) return usage("Browser tool is disabled (enable in settings).", runtime); - const current = runtime.settings.get("browser.headless" as SettingPath) as boolean; - let next = current; - if (!arg) next = !current; - else if (arg === "headless" || arg === "hidden") next = true; - else if (arg === "visible" || arg === "show" || arg === "headful") next = false; - else return usage("Usage: /browser [headless|visible]", runtime); - runtime.settings.set("browser.headless" as SettingPath, next as SettingValue<SettingPath>); - const tool = runtime.session.getToolByName("browser"); - if (tool && "restartForModeChange" in tool) { - try { - await (tool as { restartForModeChange: () => Promise<void> }).restartForModeChange(); - } catch (err) { - // Setting was already mutated; surface the restart failure so the - // user knows the browser is in an inconsistent state. - await runtime.output( - `Browser mode set to ${next ? "headless" : "visible"}, but restart failed: ${errorMessage(err)}`, - ); - return commandConsumed(); - } - } - await runtime.output(`Browser mode: ${next ? "headless" : "visible"}`); - return commandConsumed(); - }, - handleTui: async (command, runtime) => { - const arg = command.args.toLowerCase(); - const current = settings.get("browser.headless" as SettingPath) as boolean; - let next = current; - if (!(settings.get("browser.enabled" as SettingPath) as boolean)) { - runtime.ctx.showWarning("Browser tool is disabled (enable in settings)"); - runtime.ctx.editor.setText(""); - return; - } - if (!arg) { - next = !current; - } else if (arg === "headless" || arg === "hidden") { - next = true; - } else if (arg === "visible" || arg === "show" || arg === "headful") { - next = false; - } else { - runtime.ctx.showStatus("Usage: /browser [headless|visible]"); - runtime.ctx.editor.setText(""); - return; - } - settings.set("browser.headless" as SettingPath, next as SettingValue<SettingPath>); - const tool = runtime.ctx.session.getToolByName("browser"); - if (tool && "restartForModeChange" in tool) { - try { - await (tool as { restartForModeChange: () => Promise<void> }).restartForModeChange(); - } catch (error) { - runtime.ctx.showWarning(`Failed to restart browser: ${errorMessage(error)}`); - runtime.ctx.editor.setText(""); - return; - } - } - runtime.ctx.showStatus(`Browser mode: ${next ? "headless" : "visible"}`); - runtime.ctx.editor.setText(""); - }, - }, - { - name: "copy", - description: "Pick text or code from the conversation to copy", - allowArgs: true, - handleTui: async (command, runtime) => { - const arg = command.args.trim().toLowerCase(); - if (!arg) { - runtime.ctx.showCopySelector(); - runtime.ctx.editor.setText(""); - return; - } - if (arg === "code") { - const block = extractLastCodeBlock(runtime.ctx.session.messages); - if (!block) { - runtime.ctx.showStatus("No code block to copy."); - runtime.ctx.editor.setText(""); - return; - } - await copyToClipboard(block.code); - runtime.ctx.showStatus("Copied code block to clipboard"); - runtime.ctx.editor.setText(""); - return; - } - if (arg === "cmd" || arg === "command") { - const lastCommand = extractLastCommand(runtime.ctx.session.messages); - if (!lastCommand) { - runtime.ctx.showStatus("No command to copy."); - runtime.ctx.editor.setText(""); - return; - } - await copyToClipboard(lastCommand.code); - runtime.ctx.showStatus(`Copied ${lastCommand.kind === "bash" ? "bash command" : "eval code"} to clipboard`); - runtime.ctx.editor.setText(""); - return; - } - runtime.ctx.showStatus("Usage: /copy [code|cmd]"); - runtime.ctx.editor.setText(""); - }, - }, - { - name: "todo", - description: "View or modify the agent's todo list", - acpDescription: "Manage todos", - acpInputHint: "<subcommand>", - subcommands: [ - { name: "edit", description: "Open todos in $EDITOR (Markdown round-trip)" }, - { name: "copy", description: "Copy todos as Markdown to clipboard" }, - { name: "export", description: "Write todos as Markdown to a file (default: TODO.md)", usage: "[<path>]" }, - { name: "import", description: "Replace todos from a Markdown file (default: TODO.md)", usage: "[<path>]" }, - { - name: "append", - description: "Append a task; phase fuzzy-matched or auto-created", - usage: "[<phase>] <task...>", - }, - { name: "start", description: "Mark task in_progress (fuzzy-matched)", usage: "<task>" }, - { name: "done", description: "Mark task/phase/all completed (fuzzy-matched)", usage: "[<task|phase>]" }, - { name: "drop", description: "Mark task/phase/all abandoned (fuzzy-matched)", usage: "[<task|phase>]" }, - { name: "rm", description: "Remove task/phase/all (fuzzy-matched)", usage: "[<task|phase>]" }, - ], - allowArgs: true, - getTuiAutocompleteDescription: runtime => { - const tasks = runtime.ctx.todoPhases.flatMap(phase => phase.tasks); - if (tasks.length === 0) return "Todos: none"; - const pending = tasks.filter(task => task.status === "pending").length; - const inProgress = tasks.filter(task => task.status === "in_progress").length; - const completed = tasks.filter(task => task.status === "completed").length; - return `Todos: ${pending + inProgress} open (${inProgress} in progress, ${completed} done)`; - }, - handle: handleTodoAcp, - handleTui: async (command, runtime) => { - await runtime.ctx.handleTodoCommand(command.args); - runtime.ctx.editor.setText(""); - }, - }, - { - name: "session", - description: "Session management commands", - acpDescription: "Show or configure the current session", - acpInputHint: "[info|delete|pin [account]]", - subcommands: [ - { name: "info", description: "Show session info and stats" }, - { name: "delete", description: "Delete current session and return to selector" }, - { - name: "pin", - description: "Pin the current provider to a stored OAuth account", - usage: "[account]", - }, - ], - allowArgs: true, - handle: async (command, runtime) => { - const { verb, rest } = parseSubcommand(command.args); - if (!verb || (verb === "info" && !rest)) { - await runtime.output( - [ - `Session: ${runtime.session.sessionId}`, - `Title: ${runtime.session.sessionName}`, - `CWD: ${runtime.cwd}`, - ].join("\n"), - ); - return commandConsumed(); - } - if (verb === "delete" && !rest) { - if (runtime.session.isStreaming) return usage("Cannot delete the session while streaming.", runtime); - const sessionFile = runtime.sessionManager.getSessionFile(); - if (!sessionFile) return usage("No session file to delete (in-memory session).", runtime); - // Route through the active SessionManager so the persist writer is - // closed before the file is deleted. Constructing a fresh - // FileSessionStorage and calling deleteSessionWithArtifacts leaves - // the active writer attached to the now-deleted path, so the next - // prompt would silently resurrect or corrupt the "deleted" file. - try { - await runtime.sessionManager.dropSession(sessionFile); - } catch (err) { - return usage(`Failed to delete session: ${errorMessage(err)}`, runtime); - } - await runtime.output( - `Session deleted: ${sessionFile}. Use ACP \`session/load\` to switch to another session.`, - ); - return commandConsumed(); - } - if (verb === "pin") { - await handleSessionPinCommand(rest, runtime.session, runtime.output); - return commandConsumed(); - } - return usage("Usage: /session [info|delete|pin [account]]", runtime); - }, - handleTui: async (command, runtime) => { - const { verb, rest } = parseSubcommand(command.args); - if (verb === "delete" && !rest) { - runtime.ctx.editor.setText(""); - await runtime.ctx.handleSessionDeleteCommand(); - return; - } - if (verb === "pin") { - if (rest) { - await handleSessionPinCommand(rest, runtime.ctx.session, text => runtime.ctx.showStatus(text)); - refreshStatusLine(runtime.ctx); - } else { - await runtime.ctx.showSessionPinSelector(); - } - runtime.ctx.editor.setText(""); - return; - } - if (!verb || (verb === "info" && !rest)) { - await runtime.ctx.handleSessionCommand(); - } else { - runtime.ctx.showStatus("Usage: /session [info|delete|pin [account]]"); - } - runtime.ctx.editor.setText(""); - }, - }, - { - name: "jobs", - description: "Show async background jobs status", - acpDescription: "Show background jobs", - getTuiAutocompleteDescription: runtime => { - const snapshot = runtime.ctx.session.getAsyncJobSnapshot({ recentLimit: 5 }); - if (!snapshot || (snapshot.running.length === 0 && snapshot.recent.length === 0)) return "Jobs: none"; - return `Jobs: ${snapshot.running.length} running, ${snapshot.recent.length} recent`; - }, - handle: async (_command, runtime) => { - const snapshot = runtime.session.getAsyncJobSnapshot({ recentLimit: 5 }); - if (!snapshot || (snapshot.running.length === 0 && snapshot.recent.length === 0)) { - await runtime.output( - "No background jobs running. (Background jobs run async tools — e.g. long-running bash, debug, or task subagents that would otherwise tie up a turn. They appear here while alive and for ~5 minutes after.)", - ); - return commandConsumed(); - } - const now = Date.now(); - const lines: string[] = ["Background Jobs", `Running: ${snapshot.running.length}`]; - if (snapshot.running.length > 0) { - lines.push("", "Running Jobs"); - for (const job of snapshot.running) { - lines.push(` [${job.id}] ${job.type} (${job.status}) — ${formatDuration(now - job.startTime)}`); - lines.push(` ${job.label}`); - } - } - if (snapshot.recent.length > 0) { - lines.push("", "Recent Jobs"); - for (const job of snapshot.recent) { - lines.push(` [${job.id}] ${job.type} (${job.status}) — ${formatDuration(now - job.startTime)}`); - lines.push(` ${job.label}`); - } - } - await runtime.output(lines.join("\n")); - return commandConsumed(); - }, - handleTui: async (_command, runtime) => { - await runtime.ctx.handleJobsCommand(); - runtime.ctx.editor.setText(""); - }, - }, - { - name: "usage", - description: "Show provider usage and limits", - acpDescription: "Show token usage", - acpInputHint: "[show|reset [account|active]]", - subcommands: [ - { name: "show", description: "Show provider usage and limits" }, - { name: "reset", description: "Spend a saved Codex rate-limit reset", usage: "[account|active]" }, - ], - allowArgs: true, - handle: async (command, runtime) => { - const { verb, rest } = parseSubcommand(command.args); - if (!verb || (verb === "show" && !rest)) { - await runtime.output(await buildUsageReportText(runtime)); - return commandConsumed(); - } - if (verb === "reset") { - await handleUsageResetCommand(rest, runtime.session, runtime.output); - return commandConsumed(); - } - return usage("Usage: /usage [show|reset [account|active]]", runtime); - }, - handleTui: async (command, runtime) => { - const { verb, rest } = parseSubcommand(command.args); - if (!verb || (verb === "show" && !rest)) { - await runtime.ctx.handleUsageCommand(); - runtime.ctx.editor.setText(""); - return; - } - if (verb === "reset") { - if (rest) { - await handleUsageResetCommand(rest, runtime.ctx.session, text => runtime.ctx.showStatus(text)); - } else { - await runtime.ctx.showResetUsageSelector(); - } - runtime.ctx.editor.setText(""); - return; - } - runtime.ctx.showStatus("Usage: /usage [show|reset [account|active]]"); - runtime.ctx.editor.setText(""); - }, - }, - { - name: "stats", - description: "Launch the local stats dashboard", - inlineHint: "[--port <port>]", - allowArgs: true, - handle: async (command, runtime) => { - const parsed = parseStatsDashboardArgs(command.args); - if ("error" in parsed) return usage(parsed.error, runtime); - - await runtime.output("Syncing session files..."); - try { - const result = await launchStatsDashboard(parsed); - await runtime.output(result.message); - } catch (error) { - await runtime.output(`Stats dashboard failed: ${errorMessage(error)}`); - } - return commandConsumed(); - }, - }, - { - name: "changelog", - description: "Show changelog entries", - acpDescription: "Show changelog", - acpInputHint: "[full]", - subcommands: [{ name: "full", description: "Show complete changelog" }], - allowArgs: true, - handle: async (command, runtime) => { - const changelogPath = getChangelogPath(); - const allEntries = await parseChangelog(changelogPath); - const showFull = command.args.trim().toLowerCase() === "full"; - const entriesToShow = showFull ? allEntries : allEntries.slice(0, RECENT_CHANGELOG_ENTRY_LIMIT); - if (entriesToShow.length === 0) { - await runtime.output("No changelog entries found."); - return commandConsumed(); - } - await runtime.output(renderChangelogEntries(entriesToShow).markdown); - return commandConsumed(); - }, - handleTui: async (command, runtime) => { - const showFull = command.args.split(/\s+/).filter(Boolean).includes("full"); - await runtime.ctx.handleChangelogCommand(showFull); - runtime.ctx.editor.setText(""); - }, - }, - { - name: "hotkeys", - description: "Show all keyboard shortcuts", - handleTui: (_command, runtime) => { - runtime.ctx.handleHotkeysCommand(); - runtime.ctx.editor.setText(""); - }, - }, - { - name: "tools", - description: "Show tools currently visible to the agent", - acpDescription: "Show available tools", - getTuiAutocompleteDescription: runtime => { - const active = runtime.ctx.session.getActiveToolNames().length; - const all = runtime.ctx.session.getAllToolNames().length; - return all === 0 ? "Tools: none available" : `Tools: ${active} active / ${all} available`; - }, - handle: async (_command, runtime) => { - const active = runtime.session.getActiveToolNames(); - const all = runtime.session.getAllToolNames(); - if (all.length === 0) { - await runtime.output("No tools are available."); - return commandConsumed(); - } - const lines = all.map(name => `${active.includes(name) ? "*" : "-"} ${name}`); - for (const mounted of runtime.session.getXdevToolEntries()) { - lines.push(`~ xd://${mounted.name}`); - } - await runtime.output(lines.join("\n")); - return commandConsumed(); - }, - handleTui: (_command, runtime) => { - runtime.ctx.handleToolsCommand(); - runtime.ctx.editor.setText(""); - }, - }, - { - name: "context", - description: "Show estimated context usage breakdown", - acpDescription: "Show context usage", - getTuiAutocompleteDescription: runtime => { - const usage = runtime.ctx.session.getContextUsage(); - if (!usage) return "Context: unavailable"; - return `Context: ${Math.round(usage.percent)}% (${formatTokenCount(usage.tokens)}/${formatTokenCount(usage.contextWindow)})`; - }, - handle: async (_command, runtime) => { - await runtime.output(buildContextReportText(runtime)); - return commandConsumed(); - }, - handleTui: (_command, runtime) => { - runtime.ctx.handleContextCommand(); - runtime.ctx.editor.setText(""); - }, - }, - { - name: "extensions", - aliases: ["status"], - description: "Open Extension Control Center dashboard", - handleTui: (_command, runtime) => { - runtime.ctx.showExtensionsDashboard(); - runtime.ctx.editor.setText(""); - }, - }, - { - name: "agents", - description: "Open Agent Control Center dashboard", - handleTui: (_command, runtime) => { - runtime.ctx.showAgentsDashboard(); - runtime.ctx.editor.setText(""); - }, - }, - { - name: "branch", - description: "Create a new branch from a previous message", - handleTui: (_command, runtime) => { - if (settings.get("doubleEscapeAction") === "tree") { - runtime.ctx.showTreeSelector(); - } else { - runtime.ctx.showUserMessageSelector(); - } - runtime.ctx.editor.setText(""); - }, - }, - { - name: "fork", - description: "Create a new fork from a previous message", - handleTui: async (_command, runtime) => { - runtime.ctx.editor.setText(""); - await runtime.ctx.handleForkCommand(); - }, - }, - { - name: "tree", - description: "Navigate session tree (switch branches)", - handleTui: (_command, runtime) => { - runtime.ctx.showTreeSelector(); - runtime.ctx.editor.setText(""); - }, - }, - { - name: "login", - description: "Login with OAuth provider", - inlineHint: "[provider|redirect URL]", - allowArgs: true, - getTuiAutocompleteDescription: runtime => - runtime.ctx.oauthManualInput.hasPending() - ? `Login: waiting for ${runtime.ctx.oauthManualInput.pendingProviderId ?? "OAuth"} callback` - : "Login: choose provider", - handleTui: (command, runtime) => { - const manualInput = runtime.ctx.oauthManualInput; - const args = command.args.trim(); - if (args.length > 0) { - const matchedProvider = getOAuthProviders().find(provider => provider.id === args); - if (matchedProvider) { - if (manualInput.hasPending()) { - const pendingProvider = manualInput.pendingProviderId; - const message = pendingProvider - ? `OAuth login already in progress for ${pendingProvider}. Paste the redirect URL with /login <url>.` - : "OAuth login already in progress. Paste the redirect URL with /login <url>."; - runtime.ctx.showWarning(message); - runtime.ctx.editor.setText(""); - return; - } - void runtime.ctx.showOAuthSelector("login", matchedProvider.id); - runtime.ctx.editor.setText(""); - return; - } - const submitted = manualInput.submit(args); - if (submitted) { - runtime.ctx.showStatus("OAuth callback received; completing login…"); - } else { - runtime.ctx.showWarning("No OAuth login is waiting for a manual callback."); - } - runtime.ctx.editor.setText(""); - return; - } - - if (manualInput.hasPending()) { - const provider = manualInput.pendingProviderId; - const message = provider - ? `OAuth login already in progress for ${provider}. Paste the redirect URL with /login <url>.` - : "OAuth login already in progress. Paste the redirect URL with /login <url>."; - runtime.ctx.showWarning(message); - runtime.ctx.editor.setText(""); - return; - } - - void runtime.ctx.showOAuthSelector("login"); - runtime.ctx.editor.setText(""); - }, - }, - { - name: "logout", - description: "Logout from OAuth provider", - inlineHint: "[provider]", - allowArgs: true, - handleTui: (command, runtime) => { - const providerId = command.args.trim(); - if (providerId) { - const matchedProvider = getOAuthProviders().find(provider => provider.id === providerId); - if (!matchedProvider) { - runtime.ctx.showWarning(`Unknown OAuth provider: ${providerId}`); - runtime.ctx.editor.setText(""); - return; - } - void runtime.ctx.showOAuthSelector("logout", matchedProvider.id); - runtime.ctx.editor.setText(""); - return; - } - void runtime.ctx.showOAuthSelector("logout"); - runtime.ctx.editor.setText(""); - }, - }, - { - name: "mcp", - description: "Manage MCP servers (add, list, remove, test)", - acpDescription: "Manage MCP servers", - inlineHint: "<subcommand>", - subcommands: [ - { - name: "add", - description: "Add a new MCP server", - usage: "<name> [--scope project|user] [--url <url>] [-- <command...>]", - }, - { name: "list", description: "List all configured MCP servers" }, - { name: "remove", description: "Remove an MCP server", usage: "<name> [--scope project|user]" }, - { name: "test", description: "Test connection to a server", usage: "<name>" }, - { name: "reauth", description: "Reauthorize OAuth for a server", usage: "<name>" }, - { name: "unauth", description: "Remove OAuth auth from a server", usage: "<name>" }, - { name: "enable", description: "Enable an MCP server", usage: "<name>" }, - { name: "disable", description: "Disable an MCP server", usage: "<name>" }, - { - name: "smithery-search", - description: "Search Smithery registry and deploy an MCP server", - usage: "<keyword> [--scope project|user] [--limit <1-100>] [--semantic]", - }, - { name: "smithery-login", description: "Login to Smithery and cache API key" }, - { name: "smithery-logout", description: "Remove cached Smithery API key" }, - { name: "reconnect", description: "Reconnect to a specific MCP server", usage: "<name>" }, - { name: "reload", description: "Force reload MCP runtime tools" }, - { name: "resources", description: "List available resources from connected servers" }, - { name: "prompts", description: "List available prompts from connected servers" }, - { name: "notifications", description: "Show notification capabilities and subscriptions" }, - { name: "help", description: "Show help message" }, - ], - allowArgs: true, - handle: handleMcpAcp, - handleTui: async (command, runtime) => { - runtime.ctx.editor.setText(""); - await runtime.ctx.handleMCPCommand(command.text); - }, - }, - { - name: "ssh", - description: "Manage SSH hosts (add, list, remove)", - acpDescription: "Manage SSH connections", - inlineHint: "<subcommand>", - subcommands: [ - { - name: "add", - description: "Add an SSH host", - usage: "<name> --host <host> [--user <user>] [--port <port>] [--key <keyPath>]", - }, - { name: "list", description: "List all configured SSH hosts" }, - { name: "remove", description: "Remove an SSH host", usage: "<name> [--scope project|user]" }, - { name: "help", description: "Show help message" }, - ], - allowArgs: true, - handle: handleSshAcp, - handleTui: async (command, runtime) => { - runtime.ctx.editor.setText(""); - await runtime.ctx.handleSSHCommand(command.text); - }, - }, - { - name: "new", - description: "Start a new session", - handleTui: async (_command, runtime) => { - runtime.ctx.editor.setText(""); - await runtime.ctx.handleClearCommand(); - }, - }, - { - name: "fresh", - description: "Reset provider stream state without changing the local transcript", - getTuiAutocompleteDescription: runtime => - runtime.ctx.session.isStreaming ? "Fresh: unavailable while streaming" : "Fresh: ready", - handle: async (_command, runtime) => { - const result = runtime.session.freshSession(); - if (!result) { - await runtime.output( - "Wait for the current response to finish or abort it before refreshing provider state.", - ); - return commandConsumed(); - } - await runtime.output(formatFreshSessionResult(result)); - return commandConsumed(); - }, - handleTui: async (_command, runtime) => { - runtime.ctx.editor.setText(""); - await runtime.ctx.handleFreshCommand(); - }, - }, - { - name: "clear", - description: "Clear the conversation context in place, keeping the session", - getTuiAutocompleteDescription: runtime => - runtime.ctx.session.isStreaming ? "Clear: unavailable while streaming" : "Clear: drop context, keep session", - handleTui: async (_command, runtime) => { - runtime.ctx.editor.setText(""); - await runtime.ctx.handleResetContextCommand(); - }, - }, - { - name: "drop", - description: "Delete the current session and start a new one", - handleTui: async (_command, runtime) => { - runtime.ctx.editor.setText(""); - await runtime.ctx.handleDropCommand(); - }, - }, - { - name: "compact", - description: "Manually compact the session context", - acpDescription: "Compact the conversation", - subcommands: COMPACT_MODES.map(mode => ({ - name: mode.name, - description: mode.description, - usage: mode.rejectsFocus ? undefined : "[focus]", - })), - acpInputHint: `[${COMPACT_MODES.map(mode => mode.name).join("|")}] [focus]`, - allowArgs: true, - getTuiAutocompleteDescription: runtime => { - const usage = runtime.ctx.session.getContextUsage(); - return usage ? `Compact: context ${Math.round(usage.percent)}% used` : "Compact: context unavailable"; - }, - handle: async (command, runtime) => { - const parsed = parseCompactArgs(command.args); - if ("error" in parsed) return usage(parsed.error, runtime); - const before = runtime.session.getContextUsage?.(); - const beforeTokens = before?.tokens; - try { - await runtime.session.compact(parsed.instructions, parsed.mode ? { mode: parsed.mode } : undefined); - } catch (err) { - // Compaction precondition failures (no model, already compacted, too - // small) and provider errors propagate as plain Errors; surface them - // via runtime.output so they don't fail the ACP prompt turn. - return usage(`Compaction failed: ${errorMessage(err)}`, runtime); - } - const after = runtime.session.getContextUsage?.(); - const afterTokens = after?.tokens; - if (beforeTokens != null && afterTokens != null) { - const saved = beforeTokens - afterTokens; - await runtime.output(`Compaction complete. Tokens: ${beforeTokens} -> ${afterTokens} (saved ${saved}).`); - } else { - await runtime.output("Compaction complete."); - } - return commandConsumed(); - }, - handleTui: async (command, runtime) => { - const parsed = parseCompactArgs(command.args); - runtime.ctx.editor.setText(""); - if ("error" in parsed) { - runtime.ctx.showWarning(parsed.error); - return; - } - await runtime.ctx.handleCompactCommand(parsed.instructions, parsed.mode); - }, - }, - { - name: "shake", - description: "Drop heavy content from context (tool results, large blocks)", - acpDescription: "Shake heavy content out of the conversation context", - subcommands: [ - { name: "elide", description: "Strip tool results + large blocks (default)" }, - { name: "images", description: "Strip image blocks" }, - ], - acpInputHint: "[elide|images]", - allowArgs: true, - handle: async (command, runtime) => { - const mode = parseShakeMode(command.args); - if (typeof mode !== "string") return usage(mode.error, runtime); - const result = await runtime.session.shake(mode); - await runtime.output(formatShakeSummary(result)); - return commandConsumed(); - }, - handleTui: async (command, runtime) => { - runtime.ctx.editor.setText(""); - const mode = parseShakeMode(command.args); - if (typeof mode !== "string") { - runtime.ctx.showWarning(mode.error); - return; - } - await runtime.ctx.handleShakeCommand(mode); - }, - }, - { - name: "handoff", - description: "Hand off session context to a new session", - inlineHint: "[focus instructions]", - allowArgs: true, - handleTui: async (command, runtime) => { - const customInstructions = command.args || undefined; - runtime.ctx.editor.setText(""); - await runtime.ctx.handleHandoffCommand(customInstructions); - }, - }, - { - name: "resume", - description: "Resume a different session", - inlineHint: "[session id|@claude|@codex]", - allowArgs: true, - handleTui: async (command, runtime) => { - const sessionArg = command.args.trim(); - runtime.ctx.editor.setText(""); - const foreignSource = sessionArg === "@claude" ? "claude" : sessionArg === "@codex" ? "codex" : undefined; - if (foreignSource) { - runtime.ctx.showSessionSelector(foreignSource); - return; - } - if (!sessionArg) { - runtime.ctx.showSessionSelector(); - return; - } - const match = await resolveResumableSession( - sessionArg, - runtime.ctx.sessionManager.getCwd(), - runtime.ctx.sessionManager.getSessionDir(), - { allowGlobalFallback: true }, - ); - if (!match) { - runtime.ctx.showError(`Session "${sessionArg}" not found`); - return; - } - await runtime.ctx.handleResumeSession(match.session.path); - }, - }, - { - name: "btw", - description: "Ask an ephemeral side question using the current session context", - inlineHint: "<question>", - allowArgs: true, - handleTui: async (command, runtime) => { - const question = command.text.slice(`/${command.name}`.length).trim(); - runtime.ctx.editor.setText(""); - await runtime.ctx.handleBtwCommand(question); - }, - }, - { - name: "tan", - description: "Run a full background agent on tangential work", - inlineHint: "<work>", - allowArgs: true, - handleTui: async (command, runtime) => { - const work = command.text.slice(`/${command.name}`.length).trim(); - runtime.ctx.editor.setText(""); - await runtime.ctx.handleTanCommand(work); - }, - }, - { - name: "omfg", - description: "Forge a TTSR rule from a complaint to stop a recurring behavior", - inlineHint: "<complaint>", - allowArgs: true, - handleTui: async (command, runtime) => { - const complaint = command.text.slice(`/${command.name}`.length).trim(); - runtime.ctx.editor.setText(""); - await runtime.ctx.handleOmfgCommand(complaint); - }, - }, - { - name: "retry", - description: "Retry the last failed agent turn", - handleTui: async (_command, runtime) => { - const didRetry = await runtime.ctx.session.retry(); - if (!didRetry) { - runtime.ctx.showStatus("Nothing to retry"); - } - runtime.ctx.editor.setText(""); - }, - }, - { - name: "debug", - description: "Open debug tools selector", - handleTui: async (_command, runtime) => { - await runtime.ctx.showDebugSelector(); - runtime.ctx.editor.setText(""); - }, - }, - { - name: "memory", - description: "Inspect and operate memory maintenance", - acpDescription: "Manage memory", - acpInputHint: "<subcommand>", - subcommands: [ - { name: "view", description: "Show current memory injection payload" }, - { name: "stats", description: "Show memory backend statistics" }, - { name: "diagnose", description: "Run memory backend diagnostics" }, - { name: "clear", description: "Clear persisted memory data and artifacts" }, - { name: "reset", description: "Alias for clear" }, - { name: "enqueue", description: "Enqueue memory consolidation maintenance" }, - { name: "rebuild", description: "Alias for enqueue" }, - { name: "mm list", description: "List mental models on the active bank" }, - { name: "mm show", description: "Show one mental model (id required)" }, - { - name: "mm refresh", - description: "Refresh auto-refresh models bank-wide, or one model by id", - }, - { name: "mm history", description: "Diff the change history of a mental model" }, - { name: "mm seed", description: "Create any built-in mental models that are missing" }, - { name: "mm delete", description: "Delete a mental model from the bank (id required)" }, - { name: "mm reload", description: "Re-pull the cached <mental_models> block" }, - ], - allowArgs: true, - handle: async (command, runtime) => { - const verb = (command.args.trim().split(/\s+/)[0] ?? "").toLowerCase() || "view"; - const backend = await resolveMemoryBackend(runtime.settings); - switch (verb) { - case "view": { - const payload = await backend.buildDeveloperInstructions( - runtime.settings.getAgentDir(), - runtime.settings, - runtime.session, - ); - await runtime.output(payload || "Memory payload is empty."); - return commandConsumed(); - } - case "clear": - case "reset": { - await backend.clear(runtime.settings.getAgentDir(), runtime.cwd, runtime.session); - await runtime.session.refreshBaseSystemPrompt(); - await runtime.output("Memory cleared."); - return commandConsumed(); - } - case "enqueue": - case "rebuild": { - await backend.enqueue(runtime.settings.getAgentDir(), runtime.cwd, runtime.session); - await runtime.output("Memory consolidation enqueued."); - return commandConsumed(); - } - case "stats": - case "diagnose": { - const hook = verb === "stats" ? backend.stats : backend.diagnose; - const payload = await hook?.(runtime.settings.getAgentDir(), runtime.cwd, runtime.session); - await runtime.output(payload ?? memoryStatsUnavailableMessage(backend.id, verb)); - return commandConsumed(); - } - case "mm": - return usage( - "Mental-model maintenance via /memory mm is unsupported in ACP mode; use the hindsight HTTP API directly.", - runtime, - ); - default: - return usage("Usage: /memory <view|stats|diagnose|clear|reset|enqueue|rebuild>", runtime); - } - }, - handleTui: async (command, runtime) => { - runtime.ctx.editor.setText(""); - await runtime.ctx.handleMemoryCommand(command.text); - }, - }, - { - name: "rename", - description: "Rename the current session", - inlineHint: "<title>", - allowArgs: true, - handle: async (command, runtime) => { - if (!command.args) return usage("Usage: /rename <title>", runtime); - const ok = await runtime.sessionManager.setSessionName(command.args, "user"); - if (!ok) { - await runtime.output("Session name not changed (a user-set name takes precedence)."); - return commandConsumed(); - } - await runtime.notifyTitleChanged?.(); - await runtime.output(`Session renamed to ${command.args}.`); - return commandConsumed(); - }, - handleTui: async (command, runtime) => { - const title = command.args.trim(); - if (!title) { - runtime.ctx.showError("Usage: /rename <title>"); - runtime.ctx.editor.setText(""); - return; - } - runtime.ctx.editor.setText(""); - await runtime.ctx.handleRenameCommand(title); - }, - }, - { - name: "move", - description: "Move the current session to a different directory", - acpDescription: "Move the current session to a different directory", - inlineHint: "[<path>]", - allowArgs: true, - handle: async (command, runtime) => { - if (runtime.session.isStreaming) return usage("Cannot move while streaming.", runtime); - if (!command.args) return usage("Usage: /move <path>", runtime); - const resolvedPath = resolveToCwd(command.args, runtime.cwd); - try { - const stat = await fs.stat(resolvedPath); - if (!stat.isDirectory()) { - return usage(`Not a directory: ${resolvedPath}`, runtime); - } - } catch { - return usage(`Directory does not exist: ${resolvedPath}`, runtime); - } - try { - await runtime.settings.flush(); - } catch (err) { - return usage(`Failed to save pending settings: ${errorMessage(err)}`, runtime); - } - try { - await runtime.session.moveSession(resolvedPath); - } catch (err) { - return usage(`Move failed: ${errorMessage(err)}`, runtime); - } - setProjectDir(resolvedPath); - await runtime.settings.reloadForCwd(resolvedPath); - applyProviderGlobalsFromSettings(runtime.settings); - // Reload plugin/capability caches so the next prompt sees commands and - // capabilities scoped to the new cwd. - await runtime.reloadPlugins(); - await runtime.notifyConfigChanged?.(); - await runtime.notifyTitleChanged?.(); - await runtime.output(`Moved to ${runtime.sessionManager.getCwd()}.`); - return commandConsumed(); - }, - handleTui: async (command, runtime) => { - runtime.ctx.editor.addToHistory(command.text); - runtime.ctx.editor.setText(""); - await runtime.ctx.handleMoveCommand(command.args || undefined); - }, - }, - { - name: "add-dir", - description: "Add a workspace directory to this session (multi-root)", - acpDescription: "Add a workspace directory to this session", - inlineHint: "<path>", - allowArgs: true, - handle: async (command, runtime) => { - if (runtime.session.isStreaming) return usage("Cannot add a directory while streaming.", runtime); - if (!command.args) return usage(formatWorkspaceDirectories(runtime, "Usage: /add-dir <path>"), runtime); - const resolved = resolveToCwd(command.args, runtime.cwd); - try { - const stat = await fs.stat(resolved); - if (!stat.isDirectory()) return usage(`Not a directory: ${resolved}`, runtime); - } catch { - return usage(`Directory does not exist: ${resolved}`, runtime); - } - let added: string | null; - try { - added = await runtime.sessionManager.addWorkspaceDirectory(resolved); - } catch (err) { - return usage(errorMessage(err), runtime); - } - if (added === null) { - await runtime.output(`Already in the workspace: ${resolved}`); - return commandConsumed(); - } - await runtime.session.refreshBaseSystemPrompt(); - await runtime.output(formatWorkspaceDirectories(runtime, `Added ${added}.`)); - return commandConsumed(); - }, - }, - { - name: "remove-dir", - description: "Remove a workspace directory from this session", - acpDescription: "Remove a workspace directory from this session", - inlineHint: "<path>", - allowArgs: true, - handle: async (command, runtime) => { - if (runtime.session.isStreaming) return usage("Cannot remove a directory while streaming.", runtime); - if (!command.args) return usage("Usage: /remove-dir <path>", runtime); - const resolved = resolveToCwd(command.args, runtime.cwd); - if (resolved === path.resolve(runtime.cwd)) { - return usage("Cannot remove the working directory; use /move to change it.", runtime); - } - let removed: string | null; - try { - removed = await runtime.sessionManager.removeWorkspaceDirectory(resolved); - } catch (err) { - return usage(errorMessage(err), runtime); - } - if (removed === null) { - await runtime.output(`Not a workspace directory: ${resolved}`); - return commandConsumed(); - } - await runtime.session.refreshBaseSystemPrompt(); - await runtime.output(formatWorkspaceDirectories(runtime, `Removed ${removed}.`)); - return commandConsumed(); - }, - }, - { - name: "dirs", - description: "List this session's workspace directories", - acpDescription: "List this session's workspace directories", - handle: async (_command, runtime) => { - await runtime.output(formatWorkspaceDirectories(runtime)); - return commandConsumed(); - }, - }, - { - name: "exit", - description: "Exit the application", - handleTui: shutdownHandlerTui, - }, - { - name: "marketplace", - description: "Manage marketplace plugin sources and installed plugins", - acpDescription: "Manage plugins from marketplaces", - acpInputHint: "<subcommand>", - subcommands: [ - { name: "add", description: "Add a marketplace source", usage: "<source>" }, - { name: "remove", description: "Remove a marketplace source", usage: "<name>" }, - { name: "update", description: "Update marketplace catalog(s)", usage: "[name]" }, - { name: "list", description: "List configured marketplaces" }, - { name: "discover", description: "Browse available plugins", usage: "[marketplace]" }, - { - name: "install", - description: "Install a plugin (interactive browser if no args)", - usage: "[--force] [name@marketplace]", - }, - { name: "uninstall", description: "Uninstall a plugin (selector if no args)", usage: "[name@marketplace]" }, - { name: "installed", description: "List installed marketplace plugins" }, - { name: "upgrade", description: "Upgrade outdated plugins", usage: "[name@marketplace]" }, - { name: "help", description: "Show usage guide" }, - ], - allowArgs: true, - handle: async (command, runtime) => { - const { verb, rest } = parseSubcommand(command.args); - if (!verb) { - try { - const manager = await createMarketplaceManager(runtime); - const marketplaces = await manager.listMarketplaces(); - if (marketplaces.length === 0) { - await runtime.output( - "No marketplaces configured.\n\nGet started:\n /marketplace add anthropics/claude-plugins-official\n\nThen browse with /marketplace discover", - ); - } else { - const lines = marketplaces.map(m => ` ${m.name} ${m.sourceUri}`); - await runtime.output( - `Marketplaces:\n${lines.join("\n")}\n\nUse /marketplace discover to browse plugins, or /marketplace help for all commands`, - ); - } - return commandConsumed(); - } catch (err) { - return usage(`Marketplace error: ${errorMessage(err)}`, runtime); - } - } - if (verb === "help") { - await runtime.output( - [ - "Marketplace commands:", - " /marketplace List configured marketplaces", - " /marketplace add <source> Add a marketplace (e.g. owner/repo)", - " /marketplace remove <name> Remove a marketplace", - " /marketplace update [name] Re-fetch catalog(s)", - " /marketplace list List configured marketplaces", - " /marketplace discover [marketplace] Browse available plugins", - " /marketplace install <name@marketplace> Install a plugin", - " /marketplace uninstall <name@marketplace> Uninstall a plugin", - " /marketplace installed List installed plugins", - " /marketplace upgrade [name@marketplace] Upgrade plugin(s)", - "", - "Quick start:", - " /marketplace add anthropics/claude-plugins-official", - ].join("\n"), - ); - return commandConsumed(); - } - if ((verb === "install" || verb === "uninstall") && !rest) { - return usage( - "Interactive plugin pickers are TUI-only. Pass an explicit name@marketplace argument.", - runtime, - ); - } - try { - const manager = await createMarketplaceManager(runtime); - switch (verb) { - case "add": { - if (!rest) return usage("Usage: /marketplace add <source>", runtime); - const entry = await manager.addMarketplace(rest); - await runtime.output(`Added marketplace: ${entry.name}`); - return commandConsumed(); - } - case "remove": - case "rm": { - if (!rest) return usage("Usage: /marketplace remove <name>", runtime); - await manager.removeMarketplace(rest); - await runtime.output(`Removed marketplace: ${rest}`); - return commandConsumed(); - } - case "update": { - if (rest) { - await manager.updateMarketplace(rest); - await runtime.output(`Updated marketplace: ${rest}`); - } else { - const results = await manager.updateAllMarketplaces(); - await runtime.output(`Updated ${results.length} marketplace(s)`); - } - return commandConsumed(); - } - case "list": { - const marketplaces = await manager.listMarketplaces(); - if (marketplaces.length === 0) { - await runtime.output("No marketplaces configured."); - } else { - const lines = marketplaces.map(m => ` ${m.name} ${m.sourceUri}`); - await runtime.output(`Marketplaces:\n${lines.join("\n")}`); - } - return commandConsumed(); - } - case "discover": { - const plugins = await manager.listAvailablePlugins(rest || undefined); - if (plugins.length === 0) { - const marketplaces = await manager.listMarketplaces(); - await runtime.output( - marketplaces.length === 0 - ? "No marketplaces configured. Try:\n /marketplace add anthropics/claude-plugins-official" - : "No plugins available in configured marketplaces", - ); - return commandConsumed(); - } - const lines = ["Available plugins:"]; - for (const plugin of plugins) { - lines.push(` - ${plugin.name}${plugin.version ? `@${plugin.version}` : ""}`); - if (plugin.description) lines.push(` ${plugin.description}`); - } - await runtime.output(lines.join("\n")); - return commandConsumed(); - } - case "install": { - const parsed = parseMarketplaceInstallArgs(rest); - if ("error" in parsed) return usage(parsed.error, runtime); - const atIndex = parsed.installSpec.lastIndexOf("@"); - const pluginName = parsed.installSpec.slice(0, atIndex); - const marketplace = parsed.installSpec.slice(atIndex + 1); - await manager.installPlugin(pluginName, marketplace, { force: parsed.force, scope: parsed.scope }); - await runtime.reloadPlugins(); - await runtime.output(`Installed ${pluginName} from ${marketplace}`); - return commandConsumed(); - } - case "uninstall": { - const parsed = parsePluginScopeArgs( - rest, - "Usage: /marketplace uninstall [--scope user|project] <name@marketplace>", - ); - if ("error" in parsed) return usage(parsed.error, runtime); - await manager.uninstallPlugin(parsed.pluginId, parsed.scope); - await runtime.reloadPlugins(); - await runtime.output(`Uninstalled ${parsed.pluginId}`); - return commandConsumed(); - } - case "installed": { - const installed = await manager.listInstalledPlugins(); - if (installed.length === 0) { - await runtime.output("No marketplace plugins installed"); - } else { - const lines = installed.map( - p => ` ${p.id} [${p.scope}]${p.shadowedBy ? " [shadowed]" : ""} (${p.entries.length} entry)`, - ); - await runtime.output(`Installed plugins:\n${lines.join("\n")}`); - } - return commandConsumed(); - } - case "upgrade": { - if (rest) { - const parsed = parsePluginScopeArgs( - rest, - "Usage: /marketplace upgrade [--scope user|project] <name@marketplace>", - ); - if ("error" in parsed) return usage(parsed.error, runtime); - const result = await manager.upgradePlugin(parsed.pluginId, parsed.scope); - await runtime.reloadPlugins(); - await runtime.output(`Upgraded ${parsed.pluginId} to ${result.version}`); - return commandConsumed(); - } - const results = await manager.upgradeAllPlugins(); - if (results.length === 0) { - await runtime.output("All marketplace plugins are up to date"); - } else { - await runtime.reloadPlugins(); - const lines = results.map(r => ` ${r.pluginId}: ${r.from} -> ${r.to}`); - await runtime.output(`Upgraded ${results.length} plugin(s):\n${lines.join("\n")}`); - } - return commandConsumed(); - } - default: - return usage( - `Unknown /marketplace subcommand: ${verb}. Use /marketplace help for available commands.`, - runtime, - ); - } - } catch (err) { - return usage(`Marketplace error: ${errorMessage(err)}`, runtime); - } - }, - handleTui: async (command, runtime) => { - runtime.ctx.editor.setText(""); - const args = command.args.trim().split(/\s+/); - const sub = args[0] || "install"; - const rest = args.slice(1).join(" ").trim(); - - // /marketplace (no args) or /marketplace install (no args) → interactive browser - if ((sub === "install" && !rest) || (!args[0] && !command.args.trim())) { - try { - runtime.ctx.showPluginSelector("install"); - } catch (err) { - runtime.ctx.showStatus(`Marketplace error: ${err}`); - } - return; - } - - const mgr = new MarketplaceManager({ - marketplacesRegistryPath: getMarketplacesRegistryPath(), - installedRegistryPath: getInstalledPluginsRegistryPath(), - projectInstalledRegistryPath: await resolveOrDefaultProjectRegistryPath( - runtime.ctx.sessionManager.getCwd(), - ), - marketplacesCacheDir: getMarketplacesCacheDir(), - pluginsCacheDir: getPluginsCacheDir(), - clearPluginRootsCache: clearPluginRootsAndCaches, - }); - - try { - switch (sub) { - case "add": { - if (!rest) { - runtime.ctx.showStatus("Usage: /marketplace add <source>"); - return; - } - const entry = await mgr.addMarketplace(rest); - runtime.ctx.showStatus(`Added marketplace: ${entry.name}`); - break; - } - case "remove": - case "rm": { - if (!rest) { - runtime.ctx.showStatus("Usage: /marketplace remove <name>"); - return; - } - await mgr.removeMarketplace(rest); - runtime.ctx.showStatus(`Removed marketplace: ${rest}`); - break; - } - case "update": { - if (rest) { - await mgr.updateMarketplace(rest); - runtime.ctx.showStatus(`Updated marketplace: ${rest}`); - } else { - const results = await mgr.updateAllMarketplaces(); - runtime.ctx.showStatus(`Updated ${results.length} marketplace(s)`); - } - break; - } - case "discover": { - const plugins = await mgr.listAvailablePlugins(rest || undefined); - if (plugins.length === 0) { - const marketplaces = await mgr.listMarketplaces(); - if (marketplaces.length === 0) { - runtime.ctx.showStatus( - "No marketplaces configured. Try:\n /marketplace add anthropics/claude-plugins-official", - ); - } else { - runtime.ctx.showStatus("No plugins available in configured marketplaces"); - } - } else { - const lines = plugins.map( - p => - ` ${p.name}${p.version ? `@${p.version}` : ""}${p.description ? ` - ${p.description}` : ""}`, - ); - runtime.ctx.showStatus(`Available plugins:\n${lines.join("\n")}`); - } - break; - } - case "install": { - // Parse: /marketplace install [--force] [--scope user|project] name@marketplace - const parsed = parseMarketplaceInstallArgs(rest); - if ("error" in parsed) { - runtime.ctx.showStatus(parsed.error); - return; - } - const atIdx = parsed.installSpec.lastIndexOf("@"); - const name = parsed.installSpec.slice(0, atIdx); - const marketplace = parsed.installSpec.slice(atIdx + 1); - await mgr.installPlugin(name, marketplace, { force: parsed.force, scope: parsed.scope }); - runtime.ctx.showStatus(`Installed ${name} from ${marketplace}`); - break; - } - case "uninstall": { - if (!rest) { - // No args → open interactive uninstall selector - runtime.ctx.showPluginSelector("uninstall"); - return; - } - const uninstArgs = parsePluginScopeArgs( - rest, - "Usage: /marketplace uninstall [--scope user|project] <name@marketplace>", - ); - if ("error" in uninstArgs) { - runtime.ctx.showStatus(uninstArgs.error); - return; - } - await mgr.uninstallPlugin(uninstArgs.pluginId, uninstArgs.scope); - runtime.ctx.showStatus(`Uninstalled ${uninstArgs.pluginId}`); - break; - } - case "installed": { - const installed = await mgr.listInstalledPlugins(); - if (installed.length === 0) { - runtime.ctx.showStatus("No marketplace plugins installed"); - } else { - const lines = installed.map( - p => ` ${p.id} [${p.scope}]${p.shadowedBy ? " [shadowed]" : ""} (${p.entries.length} entry)`, - ); - runtime.ctx.showStatus(`Installed plugins:\n${lines.join("\n")}`); - } - break; - } - case "upgrade": { - if (rest) { - const upArgs = parsePluginScopeArgs( - rest, - "Usage: /marketplace upgrade [--scope user|project] <name@marketplace>", - ); - if ("error" in upArgs) { - runtime.ctx.showStatus(upArgs.error); - return; - } - const result = await mgr.upgradePlugin(upArgs.pluginId, upArgs.scope); - runtime.ctx.showStatus(`Upgraded ${upArgs.pluginId} to ${result.version}`); - } else { - const results = await mgr.upgradeAllPlugins(); - if (results.length === 0) { - runtime.ctx.showStatus("All marketplace plugins are up to date"); - } else { - const lines = results.map(r => ` ${r.pluginId}: ${r.from} -> ${r.to}`); - runtime.ctx.showStatus(`Upgraded ${results.length} plugin(s):\n${lines.join("\n")}`); - } - } - break; - } - case "help": { - runtime.ctx.showStatus( - [ - "Marketplace commands:", - " /marketplace Browse and install plugins", - " /marketplace add <source> Add a marketplace (e.g. owner/repo)", - " /marketplace remove <name> Remove a marketplace", - " /marketplace update [name] Re-fetch catalog(s)", - " /marketplace list List configured marketplaces", - " /marketplace discover [marketplace] Browse available plugins", - " /marketplace install <name@marketplace> Install a plugin", - " /marketplace uninstall <name@marketplace> Uninstall a plugin", - " /marketplace installed List installed plugins", - " /marketplace upgrade [name@marketplace] Upgrade plugin(s)", - "", - "Quick start:", - " /marketplace add anthropics/claude-plugins-official", - " /marketplace (opens interactive browser)", - ].join("\n"), - ); - break; - } - default: { - const marketplaces = await mgr.listMarketplaces(); - if (marketplaces.length === 0) { - runtime.ctx.showStatus( - "No marketplaces configured.\n\nGet started:\n /marketplace add anthropics/claude-plugins-official\n\nThen browse plugins with /marketplace or /marketplace discover", - ); - } else { - const lines = marketplaces.map(m => ` ${m.name} ${m.sourceUri}`); - runtime.ctx.showStatus( - `Marketplaces:\n${lines.join("\n")}\n\nUse /marketplace discover to browse plugins, or /marketplace help for all commands`, - ); - } - break; - } - } - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - runtime.ctx.showStatus(`Marketplace error: ${msg}`); - } - }, - }, - { - name: "plugins", - description: "View and manage installed plugins", - acpDescription: "Manage plugins", - acpInputHint: "[list|enable|disable]", - subcommands: [ - { name: "list", description: "List all installed plugins (npm + marketplace)" }, - { name: "enable", description: "Enable a marketplace plugin", usage: "<name@marketplace>" }, - { name: "disable", description: "Disable a marketplace plugin", usage: "<name@marketplace>" }, - ], - allowArgs: true, - handle: async (command, runtime) => { - const { verb, rest } = parseSubcommand(command.args); - try { - if (verb === "enable" || verb === "disable") { - const parsed = parsePluginScopeArgs( - rest, - `Usage: /plugins ${verb} [--scope user|project] <name@marketplace>`, - ); - if ("error" in parsed) return usage(parsed.error, runtime); - const manager = await createMarketplaceManager(runtime); - const isEnable = verb === "enable"; - await manager.setPluginEnabled(parsed.pluginId, isEnable, parsed.scope); - await runtime.reloadPlugins(); - await runtime.output(`${isEnable ? "Enabled" : "Disabled"} ${parsed.pluginId}`); - return commandConsumed(); - } - // Default: list - const lines: string[] = []; - const npmManager = new PluginManager(); - const npmPlugins = await npmManager.list(); - if (npmPlugins.length > 0) { - lines.push("npm plugins:"); - for (const plugin of npmPlugins) { - const status = plugin.enabled === false ? " (disabled)" : ""; - lines.push(` ${plugin.name}@${plugin.version}${status}`); - } - } - - const marketplaceManager = await createMarketplaceManager(runtime); - const marketplacePlugins = await marketplaceManager.listInstalledPlugins(); - if (marketplacePlugins.length > 0) { - if (lines.length > 0) lines.push(""); - lines.push("marketplace plugins:"); - for (const plugin of marketplacePlugins) { - const entry = plugin.entries[0]; - const status = entry?.enabled === false ? " (disabled)" : ""; - const shadowed = plugin.shadowedBy ? " [shadowed]" : ""; - lines.push(` ${plugin.id} v${entry?.version ?? "?"}${status} [${plugin.scope}]${shadowed}`); - } - } - - await runtime.output(lines.length === 0 ? "No plugins installed" : lines.join("\n")); - return commandConsumed(); - } catch (err) { - return usage(`Plugin error: ${errorMessage(err)}`, runtime); - } - }, - handleTui: async (command, runtime) => { - runtime.ctx.editor.setText(""); - const args = command.args.trim().split(/\s+/); - const sub = args[0] || "list"; - const rest = args.slice(1).join(" ").trim(); - - try { - const mgr = new MarketplaceManager({ - marketplacesRegistryPath: getMarketplacesRegistryPath(), - installedRegistryPath: getInstalledPluginsRegistryPath(), - projectInstalledRegistryPath: await resolveOrDefaultProjectRegistryPath( - runtime.ctx.sessionManager.getCwd(), - ), - marketplacesCacheDir: getMarketplacesCacheDir(), - pluginsCacheDir: getPluginsCacheDir(), - clearPluginRootsCache: clearPluginRootsAndCaches, - }); - - switch (sub) { - case "enable": - case "disable": { - const parsed = parsePluginScopeArgs( - rest ?? "", - `Usage: /plugins ${sub} [--scope user|project] <name@marketplace>`, - ); - if ("error" in parsed) { - runtime.ctx.showStatus(parsed.error); - return; - } - const isEnable = sub === "enable"; - await mgr.setPluginEnabled(parsed.pluginId, isEnable, parsed.scope); - runtime.ctx.showStatus(`${isEnable ? "Enabled" : "Disabled"} ${parsed.pluginId}`); - break; - } - default: { - const lines: string[] = []; - - const npm = new PluginManager(); - const npmPlugins = await npm.list(); - if (npmPlugins.length > 0) { - lines.push("npm plugins:"); - for (const p of npmPlugins) { - const status = p.enabled === false ? " (disabled)" : ""; - lines.push(` ${p.name}@${p.version}${status}`); - } - } - - const mktPlugins = await mgr.listInstalledPlugins(); - if (mktPlugins.length > 0) { - if (lines.length > 0) lines.push(""); - lines.push("marketplace plugins:"); - for (const p of mktPlugins) { - const entry = p.entries[0]; - const status = entry?.enabled === false ? " (disabled)" : ""; - const shadowed = p.shadowedBy ? " [shadowed]" : ""; - lines.push(` ${p.id} v${entry?.version ?? "?"}${status} [${p.scope}]${shadowed}`); - } - } - - if (lines.length === 0) { - runtime.ctx.showStatus("No plugins installed"); - } else { - runtime.ctx.showStatus(lines.join("\n")); - } - break; - } - } - } catch (err) { - runtime.ctx.showStatus(`Plugin error: ${err}`); - } - }, - }, - { - name: "reload-plugins", - description: "Reload all plugins (skills, commands, hooks, tools, agents, MCP)", - acpDescription: "Reload all plugins", - handle: async (_command, runtime) => { - await runtime.reloadPlugins(); - await runtime.output("Plugins reloaded."); - return commandConsumed(); - }, - handleTui: async (_command, runtime) => { - await reloadTuiPluginState(runtime.ctx); - runtime.ctx.showStatus("Plugins reloaded."); - runtime.ctx.editor.setText(""); - }, - }, - { - name: "force", - description: "Force next turn to use a specific tool", - aliases: ["force:"], - inlineHint: "<tool-name> [prompt]", - allowArgs: true, - getTuiAutocompleteDescription: runtime => { - const count = runtime.ctx.session.getActiveToolNames().length; - return count === 0 ? "Force: no active tools" : `Force: ${count} active tools`; - }, - handle: async (command, runtime) => { - const spaceIdx = command.args.indexOf(" "); - const toolName = spaceIdx === -1 ? command.args : command.args.slice(0, spaceIdx); - const prompt = spaceIdx === -1 ? "" : command.args.slice(spaceIdx + 1).trim(); - if (!toolName) return usage("Usage: /force:<tool-name> [prompt]", runtime); - try { - runtime.session.setForcedToolChoice(toolName); - } catch (err) { - return usage(errorMessage(err), runtime); - } - await runtime.output(`Next turn forced to use ${toolName}.`); - return prompt ? { prompt } : commandConsumed(); - }, - handleTui: (command, runtime) => { - const spaceIdx = command.args.indexOf(" "); - const toolName = spaceIdx === -1 ? command.args : command.args.slice(0, spaceIdx); - const prompt = spaceIdx === -1 ? "" : command.args.slice(spaceIdx + 1).trim(); - - if (!toolName) { - runtime.ctx.showError("Usage: /force:<tool-name> [prompt]"); - runtime.ctx.editor.setText(""); - return; - } - - try { - runtime.ctx.session.setForcedToolChoice(toolName); - runtime.ctx.showStatus(`Next turn forced to use ${toolName}.`); - } catch (error) { - runtime.ctx.showError(errorMessage(error)); - runtime.ctx.editor.setText(""); - return; - } - - runtime.ctx.editor.setText(""); - - // If a prompt was provided, pass it through as input - if (prompt) return { prompt }; - }, - }, - { - name: "live", - description: "Start Codex-backed realtime voice mode", - handleTui: async (_command, runtime) => { - runtime.ctx.editor.setText(""); - await runtime.ctx.handleLiveCommand(); - }, - }, - { - name: "pause", - description: "Freeze all agents (main, subagents, advisor) until resumed", - handleTui: async (_command, runtime) => { - runtime.ctx.editor.setText(""); - await runPauseScreen(runtime.ctx); - }, - }, - { - name: "quit", - aliases: ["q"], - description: "Quit the application", - handleTui: shutdownHandlerTui, - }, + ...BUILTIN_MODE_SLASH_COMMANDS, + ...BUILTIN_COLLABORATION_SLASH_COMMANDS, + ...BUILTIN_SESSION_SLASH_COMMANDS, + ...BUILTIN_LIFECYCLE_SLASH_COMMANDS, + ...BUILTIN_MARKETPLACE_SLASH_COMMANDS, + ...BUILTIN_CONTROL_SLASH_COMMANDS, ]; const BUILTIN_SLASH_COMMAND_LOOKUP = new Map<string, SlashCommandSpec>(); @@ -2731,288 +53,6 @@ for (const command of BUILTIN_SLASH_COMMAND_REGISTRY) { export const BUILTIN_SLASH_COMMAND_RESERVED_NAMES: ReadonlySet<string> = new Set(BUILTIN_SLASH_COMMAND_LOOKUP.keys()); -/** - * Build getArgumentCompletions from declarative subcommand definitions. - * Returns subcommand names filtered by prefix in the dropdown. - */ -function buildArgumentCompletions(subcommands: SubcommandDef[]): (prefix: string) => AutocompleteItem[] | null { - return (argumentPrefix: string) => { - if (argumentPrefix.includes(" ")) return null; // past the subcommand - const lower = argumentPrefix.toLowerCase(); - const matches = subcommands - .filter(s => s.name.startsWith(lower)) - .map(s => ({ - value: `${s.name} `, - label: s.name, - description: s.description, - hint: s.usage, - })); - return matches.length > 0 ? matches : null; - }; -} - -/** /mcp subcommands whose argument is a server name (per their `usage: "<name>..."`). */ -const MCP_SERVER_NAME_SUBCOMMANDS: Readonly<Record<string, true>> = { - enable: true, - disable: true, - test: true, - remove: true, - reconnect: true, - reauth: true, - unauth: true, -}; - -/** Subcommands that accept names found only in `userConfig.disabledServers`. */ -const MCP_DISABLED_ONLY_ELIGIBLE_SUBCOMMANDS: Readonly<Record<string, true>> = { - enable: true, - disable: true, -}; - -/** - * Subcommands that accept configured servers whose `enabled` flag is false. - * `unauth` can clear persisted credentials without connecting; test, - * reconnect, and reauth explicitly require an enabled server. - */ -const MCP_DISABLED_CONFIG_ELIGIBLE_SUBCOMMANDS: Readonly<Record<string, true>> = { - enable: true, - disable: true, - unauth: true, -}; - -/** - * Build getArgumentCompletions for /mcp. Delegates to the generic - * declarative subcommand completer while the subcommand name itself is - * still being typed, then switches to MCP server-name completion (sourced - * from {@link collectMcpServerNames}) once a recognized server-name - * subcommand (enable/disable/test/remove/reconnect/reauth/unauth) is - * followed by a space. `remove` gets its own scope-aware completions (see - * {@link buildMcpRemoveCompletions}) since — unlike the others — - * it only ever succeeds against a config-file entry. Subcommands with a - * different argument shape (add, smithery-search, ...) get no argument - * completion. - */ -function buildMcpArgumentCompletions( - subcommands: SubcommandDef[], - runtime: TuiSlashCommandRuntime, -): (argumentPrefix: string) => Promise<AutocompleteItem[] | null> { - const genericCompletions = buildArgumentCompletions(subcommands); - return async (argumentPrefix: string) => { - const spaceIndex = argumentPrefix.indexOf(" "); - if (spaceIndex === -1) return genericCompletions(argumentPrefix); - - const rawSubcommand = argumentPrefix.slice(0, spaceIndex); - const lowerSubcommand = rawSubcommand.toLowerCase(); - if (MCP_SERVER_NAME_SUBCOMMANDS[lowerSubcommand] !== true) return null; - const namePrefix = argumentPrefix.slice(spaceIndex + 1).toLowerCase(); - if (lowerSubcommand === "remove") { - return await buildMcpRemoveCompletions(rawSubcommand, namePrefix); - } - - let serverNames: string[]; - try { - serverNames = await collectMcpServerNames( - runtime.ctx, - undefined, - MCP_DISABLED_ONLY_ELIGIBLE_SUBCOMMANDS[lowerSubcommand] === true, - MCP_DISABLED_CONFIG_ELIGIBLE_SUBCOMMANDS[lowerSubcommand] === true, - ); - } catch (error) { - logger.warn("MCP server-name autocomplete failed to read config", { error }); - return null; - } - const matches: AutocompleteItem[] = serverNames - .filter(name => name.toLowerCase().startsWith(namePrefix)) - .map(name => ({ value: `${rawSubcommand} ${name} `, label: name })); - return matches.length > 0 ? matches : null; - }; -} - -/** - * Build `/mcp remove <name>` completions. Unlike the other server-name - * subcommands, `#handleRemove` only ever succeeds against a config-file - * `mcpServers` entry in the target scope (project by default, user with an - * explicit `--scope user`) — a purely runtime-discovered server has no - * config entry to remove and always fails with `Server "<name>" not found - * in <scope> config.`. Completions are therefore restricted to config-file - * names, and a name that exists only in the user config is completed with - * `--scope user` appended so the inserted command is directly executable. - */ -async function buildMcpRemoveCompletions( - rawSubcommand: string, - namePrefix: string, -): Promise<AutocompleteItem[] | null> { - const cwd = getProjectDir(); - let projectNames: string[]; - let userNames: string[]; - try { - const [projectConfig, userConfig] = await Promise.all([ - readMCPConfigFile(getMCPConfigPath("project", cwd)), - readMCPConfigFile(getMCPConfigPath("user", cwd)), - ]); - projectNames = Object.keys(projectConfig.mcpServers ?? {}); - userNames = Object.keys(userConfig.mcpServers ?? {}); - } catch (error) { - logger.warn("MCP remove autocomplete failed to read config", { error }); - return null; - } - - const projectNameSet = new Set(projectNames); - const allNames = new Set([...projectNames, ...userNames]); - const matches: AutocompleteItem[] = [...allNames] - .filter(name => name.toLowerCase().startsWith(namePrefix)) - .map(name => - projectNameSet.has(name) - ? { value: `${rawSubcommand} ${name} `, label: name } - : { value: `${rawSubcommand} ${name} --scope user `, label: `${name} (user)` }, - ) - .sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: "base" })); - return matches.length > 0 ? matches : null; -} - -/** - * Build getInlineHint from declarative subcommand definitions. - * Shows remaining completion + usage as dim ghost text after cursor. - */ -function buildSubcommandInlineHint(subcommands: SubcommandDef[]): (argumentText: string) => string | null { - return (argumentText: string) => { - const trimmed = argumentText.trimStart(); - const spaceIndex = trimmed.indexOf(" "); - - if (spaceIndex === -1) { - // Still typing subcommand name — show remaining chars + usage - const prefix = trimmed.toLowerCase(); - if (prefix.length === 0) return null; - const match = subcommands.find(s => s.name.startsWith(prefix)); - if (!match) return null; - const remaining = match.name.slice(prefix.length); - return remaining + (match.usage ? ` ${match.usage}` : ""); - } - - // Subcommand typed — show remaining usage params - const subName = trimmed.slice(0, spaceIndex).toLowerCase(); - const afterSub = trimmed.slice(spaceIndex + 1); - const sub = subcommands.find(s => s.name === subName); - if (!sub?.usage) return null; - - if (afterSub.length > 0) { - const usageParts = sub.usage.split(" "); - const inputParts = afterSub.trim().split(/\s+/); - const remaining = usageParts.slice(inputParts.length); - return remaining.length > 0 ? remaining.join(" ") : null; - } - - return sub.usage; - }; -} - -/** - * Build getInlineHint for commands with a simple static hint string. - * Shows the hint only when no arguments have been typed yet. - */ -function buildStaticInlineHint(hint: string): (argumentText: string) => string | null { - return (argumentText: string) => (argumentText.trim().length === 0 ? hint : null); -} - -/** - * Build getArgumentCompletions that suggests directories relative to the - * current project directory. Used by /move so users can Tab-complete the - * destination directory. - */ -function buildDirectoryArgumentCompletions(): (prefix: string) => Promise<AutocompleteItem[] | null> { - return async (argumentPrefix: string) => { - const prefix = argumentPrefix.trim(); - - const cwd = getProjectDir(); - const expandedPrefix = expandTilde(prefix); - const isAbsolute = path.isAbsolute(expandedPrefix); - - let searchDir: string; - let searchPrefix: string; - if ( - prefix === "" || - prefix === "." || - prefix === "./" || - prefix === ".." || - prefix === "../" || - prefix === "~" || - prefix === "~/" || - prefix === "/" - ) { - searchDir = isAbsolute ? expandedPrefix : path.join(cwd, expandedPrefix); - searchPrefix = ""; - } else if (expandedPrefix.endsWith("/")) { - searchDir = isAbsolute ? expandedPrefix : path.join(cwd, expandedPrefix); - searchPrefix = ""; - } else { - const dir = path.dirname(expandedPrefix); - searchDir = isAbsolute ? dir : path.join(cwd, dir); - searchPrefix = path.basename(expandedPrefix); - } - - try { - const entries = await fs.readdir(searchDir, { withFileTypes: true }); - const suggestions: AutocompleteItem[] = []; - for (const entry of entries) { - if (!entry.name.toLowerCase().startsWith(searchPrefix.toLowerCase())) continue; - if (entry.name === ".git") continue; - - let isDirectory = entry.isDirectory(); - if (!isDirectory && entry.isSymbolicLink()) { - try { - isDirectory = (await fs.stat(path.join(searchDir, entry.name))).isDirectory(); - } catch { - continue; - } - } - if (!isDirectory) continue; - - const absoluteValue = path.join(searchDir, entry.name); - const displayValue = buildDirectoryCompletionDisplayValue(prefix, absoluteValue, cwd); - suggestions.push({ value: displayValue, label: `${entry.name}/` }); - } - suggestions.sort((a, b) => a.label.localeCompare(b.label)); - return suggestions.length > 0 ? suggestions : null; - } catch { - return null; - } - }; -} -function buildDirectoryCompletionDisplayValue(prefix: string, absoluteValue: string, cwd: string): string { - // Preserve the user's prefix style where possible, but always return a - // value that /move can resolve (absolute or relative) without escaping. - const normalized = path.normalize(absoluteValue); - - if (prefix.startsWith("~/")) { - const home = os.homedir(); - const homeRelative = path.relative(home, normalized); - return `~/${homeRelative.replaceAll("\\", "/")}/`; - } - if (prefix === "~") { - const home = os.homedir(); - const homeRelative = path.relative(home, normalized); - return `~/${homeRelative.replaceAll("\\", "/")}/`; - } - if (prefix.startsWith("/")) { - return `${normalized.replaceAll("\\", "/")}/`; - } - if (prefix.startsWith("./")) { - const relative = path.relative(cwd, normalized); - return `./${relative.replaceAll("\\", "/")}/`; - } - if (prefix.startsWith("../")) { - const relative = path.relative(cwd, normalized); - return `${relative.replaceAll("\\", "/")}/`; - } - if (prefix === "..") { - const relative = path.relative(cwd, normalized); - return `${relative.replaceAll("\\", "/")}/`; - } - - // Default: relative to cwd. - const relative = path.relative(cwd, normalized); - return `${relative.replaceAll("\\", "/")}/`; -} - /** Builtin command metadata used for slash-command autocomplete and help text. */ export const BUILTIN_SLASH_COMMAND_DEFS: ReadonlyArray<BuiltinSlashCommand> = BUILTIN_SLASH_COMMAND_REGISTRY.map( command => ({ @@ -3068,25 +108,6 @@ export function buildTuiBuiltinSlashCommands(runtime: TuiSlashCommandRuntime): R */ export const BUILTIN_SLASH_COMMANDS_INTERNAL: ReadonlyArray<SlashCommandSpec> = BUILTIN_SLASH_COMMAND_REGISTRY; -/** - * Reload the interactive session's plugin runtime: invalidate fs/plugin-root - * caches, rediscover skills, file slash commands, and task agents, reset the - * capability cache, and reconnect MCP servers (rebinding the session's MCP - * tools). Shared by `/reload-plugins`'s TUI handler and the `handle`-adapter's - * `reloadPlugins` hook so both honor the command's documented reload scope. - */ -async function reloadTuiPluginState(ctx: InteractiveModeContext): Promise<void> { - const projectPath = await resolveActiveProjectRegistryPath(ctx.sessionManager.getCwd()); - clearPluginRootsAndCaches(projectPath ? [projectPath] : undefined); - await refreshAgentDiscovery(ctx.sessionManager.getCwd()); - await ctx.refreshSkillState(); - await ctx.refreshSlashCommandState(); - resetCapabilities(); - if (ctx.mcpManager) { - await new MCPCommandController(ctx).reloadServers(); - } -} - /** * Execute a builtin slash command in the interactive TUI. * diff --git a/packages/coding-agent/src/slash-commands/builtin-session.ts b/packages/coding-agent/src/slash-commands/builtin-session.ts new file mode 100644 index 000000000..92cde7e3f --- /dev/null +++ b/packages/coding-agent/src/slash-commands/builtin-session.ts @@ -0,0 +1,592 @@ +import { getOAuthProviders } from "@oh-my-pi/pi-ai/oauth"; +import { settings } from "../config/settings"; +import type { AgentSession } from "../session/agent-session"; +import type { SessionOAuthAccountList } from "../session/agent-session-types"; +import { + getChangelogPath, + parseChangelog, + RECENT_CHANGELOG_ENTRY_LIMIT, + renderChangelogEntries, +} from "../utils/changelog"; +import { formatTokenCount, refreshStatusLine } from "./builtin-modes"; +import { buildContextReportText } from "./helpers/context-report"; +import { formatDuration } from "./helpers/format"; +import { handleMcpAcp } from "./helpers/mcp"; +import { commandConsumed, errorMessage, parseSubcommand, usage } from "./helpers/parse"; +import { describeRedeemOutcome, type ResetUsageAccount, toResetUsageAccounts } from "./helpers/reset-usage"; +import { matchSessionPinAccounts, toSessionPinAccounts } from "./helpers/session-pin"; +import { launchStatsDashboard, parseStatsDashboardArgs } from "./helpers/stats-dashboard"; +import { handleTodoAcp } from "./helpers/todo"; +import { buildUsageReportText } from "./helpers/usage-report"; +import type { SlashCommandRuntime, SlashCommandSpec } from "./types"; + +async function handleUsageResetCommand( + arg: string, + session: AgentSession, + output: SlashCommandRuntime["output"], +): Promise<void> { + let accounts: ResetUsageAccount[]; + try { + accounts = toResetUsageAccounts(await session.listResetCredits()); + } catch (error) { + await output(`Could not load saved resets: ${errorMessage(error)}`); + return; + } + if (accounts.length === 0) { + await output("No Codex accounts found. Use /login to add one."); + return; + } + const targetArg = arg.trim(); + if (!targetArg) { + const lines = ["Saved Codex rate-limit resets:"]; + for (const account of accounts) { + const detail = account.error ? `unavailable (${account.error})` : `${account.availableCount} available`; + lines.push(`- ${account.label}: ${detail}${account.active ? " (active)" : ""}`); + } + lines.push("", "Spend one with `/usage reset <account email>` or `/usage reset active`."); + await output(lines.join("\n")); + return; + } + const wanted = targetArg.toLowerCase(); + const target = + wanted === "active" + ? accounts.find(account => account.active) + : accounts.find( + account => + account.label.toLowerCase() === wanted || + account.target.email?.toLowerCase() === wanted || + account.target.accountId?.toLowerCase() === wanted, + ); + if (!target) { + await output(`No Codex account matches "${targetArg}".`); + return; + } + if (target.availableCount <= 0) { + await output(`${target.label}: no saved resets to spend.`); + return; + } + const outcome = await session.redeemResetCredit(target.target); + await output(describeRedeemOutcome(outcome, target.label)); +} + +async function handleSessionPinCommand( + arg: string, + session: AgentSession, + output: SlashCommandRuntime["output"], +): Promise<void> { + if (session.isStreaming) { + await output("Cannot pin an account while the session is streaming."); + return; + } + let accountList: SessionOAuthAccountList | undefined; + try { + accountList = await session.listCurrentProviderOAuthAccounts(); + } catch (error) { + await output(`Could not load provider accounts: ${errorMessage(error)}`); + return; + } + if (!accountList) { + await output("Select a model before pinning a provider account."); + return; + } + const provider = getOAuthProviders().find(candidate => candidate.id === accountList.provider); + const providerName = provider?.name ?? accountList.provider; + const accounts = toSessionPinAccounts(accountList.accounts); + if (accounts.length === 0) { + const source = session.modelRegistry.authStorage.describeCredentialSource( + accountList.provider, + session.sessionId, + ); + await output( + source + ? `No stored OAuth accounts for ${providerName}. Current auth comes from ${source}.` + : `No stored OAuth accounts for ${providerName}. Use /login to add one.`, + ); + return; + } + + const selector = arg.trim(); + if (!selector) { + const lines = [`OAuth accounts for ${providerName}:`]; + for (const account of accounts) { + lines.push(`${account.position + 1}. ${account.label}${account.active ? " (active)" : ""}`); + } + lines.push("", "Pin one with `/session pin <number|email|account id>`."); + await output(lines.join("\n")); + return; + } + + const matches = matchSessionPinAccounts(accounts, selector); + if (matches.length === 0) { + await output(`No ${providerName} account matches "${selector}".`); + return; + } + if (matches.length > 1) { + await output( + `"${selector}" matches multiple ${providerName} accounts: ${matches + .map(account => `${account.position + 1}. ${account.label}`) + .join(", ")}. Use the account number.`, + ); + return; + } + const account = matches[0]; + if (!account || !session.pinCurrentProviderOAuthAccount(account.credentialId)) { + await output(`${account?.label ?? selector} is no longer available to pin.`); + return; + } + await output(`Pinned ${account.label} to this session for ${providerName}.`); +} + +export const BUILTIN_SESSION_SLASH_COMMANDS: ReadonlyArray<SlashCommandSpec> = [ + { + name: "todo", + description: "View or modify the agent's todo list", + acpDescription: "Manage todos", + acpInputHint: "<subcommand>", + subcommands: [ + { name: "edit", description: "Open todos in $EDITOR (Markdown round-trip)" }, + { name: "copy", description: "Copy todos as Markdown to clipboard" }, + { name: "export", description: "Write todos as Markdown to a file (default: TODO.md)", usage: "[<path>]" }, + { name: "import", description: "Replace todos from a Markdown file (default: TODO.md)", usage: "[<path>]" }, + { + name: "append", + description: "Append a task; phase fuzzy-matched or auto-created", + usage: "[<phase>] <task...>", + }, + { name: "start", description: "Mark task in_progress (fuzzy-matched)", usage: "<task>" }, + { name: "done", description: "Mark task/phase/all completed (fuzzy-matched)", usage: "[<task|phase>]" }, + { name: "drop", description: "Mark task/phase/all abandoned (fuzzy-matched)", usage: "[<task|phase>]" }, + { name: "rm", description: "Remove task/phase/all (fuzzy-matched)", usage: "[<task|phase>]" }, + ], + allowArgs: true, + getTuiAutocompleteDescription: runtime => { + const tasks = runtime.ctx.todoPhases.flatMap(phase => phase.tasks); + if (tasks.length === 0) return "Todos: none"; + const pending = tasks.filter(task => task.status === "pending").length; + const inProgress = tasks.filter(task => task.status === "in_progress").length; + const completed = tasks.filter(task => task.status === "completed").length; + return `Todos: ${pending + inProgress} open (${inProgress} in progress, ${completed} done)`; + }, + handle: handleTodoAcp, + handleTui: async (command, runtime) => { + await runtime.ctx.handleTodoCommand(command.args); + runtime.ctx.editor.setText(""); + }, + }, + { + name: "session", + description: "Session management commands", + acpDescription: "Show or configure the current session", + acpInputHint: "[info|delete|pin [account]]", + subcommands: [ + { name: "info", description: "Show session info and stats" }, + { name: "delete", description: "Delete current session and return to selector" }, + { + name: "pin", + description: "Pin the current provider to a stored OAuth account", + usage: "[account]", + }, + ], + allowArgs: true, + handle: async (command, runtime) => { + const { verb, rest } = parseSubcommand(command.args); + if (!verb || (verb === "info" && !rest)) { + await runtime.output( + [ + `Session: ${runtime.session.sessionId}`, + `Title: ${runtime.session.sessionName}`, + `CWD: ${runtime.cwd}`, + ].join("\n"), + ); + return commandConsumed(); + } + if (verb === "delete" && !rest) { + if (runtime.session.isStreaming) return usage("Cannot delete the session while streaming.", runtime); + const sessionFile = runtime.sessionManager.getSessionFile(); + if (!sessionFile) return usage("No session file to delete (in-memory session).", runtime); + // Route through the active SessionManager so the persist writer is + // closed before the file is deleted. Constructing a fresh + // FileSessionStorage and calling deleteSessionWithArtifacts leaves + // the active writer attached to the now-deleted path, so the next + // prompt would silently resurrect or corrupt the "deleted" file. + try { + await runtime.sessionManager.dropSession(sessionFile); + } catch (err) { + return usage(`Failed to delete session: ${errorMessage(err)}`, runtime); + } + await runtime.output( + `Session deleted: ${sessionFile}. Use ACP \`session/load\` to switch to another session.`, + ); + return commandConsumed(); + } + if (verb === "pin") { + await handleSessionPinCommand(rest, runtime.session, runtime.output); + return commandConsumed(); + } + return usage("Usage: /session [info|delete|pin [account]]", runtime); + }, + handleTui: async (command, runtime) => { + const { verb, rest } = parseSubcommand(command.args); + if (verb === "delete" && !rest) { + runtime.ctx.editor.setText(""); + await runtime.ctx.handleSessionDeleteCommand(); + return; + } + if (verb === "pin") { + if (rest) { + await handleSessionPinCommand(rest, runtime.ctx.session, text => runtime.ctx.showStatus(text)); + refreshStatusLine(runtime.ctx); + } else { + await runtime.ctx.showSessionPinSelector(); + } + runtime.ctx.editor.setText(""); + return; + } + if (!verb || (verb === "info" && !rest)) { + await runtime.ctx.handleSessionCommand(); + } else { + runtime.ctx.showStatus("Usage: /session [info|delete|pin [account]]"); + } + runtime.ctx.editor.setText(""); + }, + }, + { + name: "jobs", + description: "Show async background jobs status", + acpDescription: "Show background jobs", + getTuiAutocompleteDescription: runtime => { + const snapshot = runtime.ctx.session.getAsyncJobSnapshot({ recentLimit: 5 }); + if (!snapshot || (snapshot.running.length === 0 && snapshot.recent.length === 0)) return "Jobs: none"; + return `Jobs: ${snapshot.running.length} running, ${snapshot.recent.length} recent`; + }, + handle: async (_command, runtime) => { + const snapshot = runtime.session.getAsyncJobSnapshot({ recentLimit: 5 }); + if (!snapshot || (snapshot.running.length === 0 && snapshot.recent.length === 0)) { + await runtime.output( + "No background jobs running. (Background jobs run async tools — e.g. long-running bash, debug, or task subagents that would otherwise tie up a turn. They appear here while alive and for ~5 minutes after.)", + ); + return commandConsumed(); + } + const now = Date.now(); + const lines: string[] = ["Background Jobs", `Running: ${snapshot.running.length}`]; + if (snapshot.running.length > 0) { + lines.push("", "Running Jobs"); + for (const job of snapshot.running) { + lines.push(` [${job.id}] ${job.type} (${job.status}) — ${formatDuration(now - job.startTime)}`); + lines.push(` ${job.label}`); + } + } + if (snapshot.recent.length > 0) { + lines.push("", "Recent Jobs"); + for (const job of snapshot.recent) { + lines.push(` [${job.id}] ${job.type} (${job.status}) — ${formatDuration(now - job.startTime)}`); + lines.push(` ${job.label}`); + } + } + await runtime.output(lines.join("\n")); + return commandConsumed(); + }, + handleTui: async (_command, runtime) => { + await runtime.ctx.handleJobsCommand(); + runtime.ctx.editor.setText(""); + }, + }, + { + name: "usage", + description: "Show provider usage and limits", + acpDescription: "Show token usage", + acpInputHint: "[show|reset [account|active]]", + subcommands: [ + { name: "show", description: "Show provider usage and limits" }, + { name: "reset", description: "Spend a saved Codex rate-limit reset", usage: "[account|active]" }, + ], + allowArgs: true, + handle: async (command, runtime) => { + const { verb, rest } = parseSubcommand(command.args); + if (!verb || (verb === "show" && !rest)) { + await runtime.output(await buildUsageReportText(runtime)); + return commandConsumed(); + } + if (verb === "reset") { + await handleUsageResetCommand(rest, runtime.session, runtime.output); + return commandConsumed(); + } + return usage("Usage: /usage [show|reset [account|active]]", runtime); + }, + handleTui: async (command, runtime) => { + const { verb, rest } = parseSubcommand(command.args); + if (!verb || (verb === "show" && !rest)) { + await runtime.ctx.handleUsageCommand(); + runtime.ctx.editor.setText(""); + return; + } + if (verb === "reset") { + if (rest) { + await handleUsageResetCommand(rest, runtime.ctx.session, text => runtime.ctx.showStatus(text)); + } else { + await runtime.ctx.showResetUsageSelector(); + } + runtime.ctx.editor.setText(""); + return; + } + runtime.ctx.showStatus("Usage: /usage [show|reset [account|active]]"); + runtime.ctx.editor.setText(""); + }, + }, + { + name: "stats", + description: "Launch the local stats dashboard", + inlineHint: "[--port <port>]", + allowArgs: true, + handle: async (command, runtime) => { + const parsed = parseStatsDashboardArgs(command.args); + if ("error" in parsed) return usage(parsed.error, runtime); + + await runtime.output("Syncing session files..."); + try { + const result = await launchStatsDashboard(parsed); + await runtime.output(result.message); + } catch (error) { + await runtime.output(`Stats dashboard failed: ${errorMessage(error)}`); + } + return commandConsumed(); + }, + }, + { + name: "changelog", + description: "Show changelog entries", + acpDescription: "Show changelog", + acpInputHint: "[full]", + subcommands: [{ name: "full", description: "Show complete changelog" }], + allowArgs: true, + handle: async (command, runtime) => { + const changelogPath = getChangelogPath(); + const allEntries = await parseChangelog(changelogPath); + const showFull = command.args.trim().toLowerCase() === "full"; + const entriesToShow = showFull ? allEntries : allEntries.slice(0, RECENT_CHANGELOG_ENTRY_LIMIT); + if (entriesToShow.length === 0) { + await runtime.output("No changelog entries found."); + return commandConsumed(); + } + await runtime.output(renderChangelogEntries(entriesToShow).markdown); + return commandConsumed(); + }, + handleTui: async (command, runtime) => { + const showFull = command.args.split(/\s+/).filter(Boolean).includes("full"); + await runtime.ctx.handleChangelogCommand(showFull); + runtime.ctx.editor.setText(""); + }, + }, + { + name: "hotkeys", + description: "Show all keyboard shortcuts", + handleTui: (_command, runtime) => { + runtime.ctx.handleHotkeysCommand(); + runtime.ctx.editor.setText(""); + }, + }, + { + name: "tools", + description: "Show tools currently visible to the agent", + acpDescription: "Show available tools", + getTuiAutocompleteDescription: runtime => { + const active = runtime.ctx.session.getActiveToolNames().length; + const all = runtime.ctx.session.getAllToolNames().length; + return all === 0 ? "Tools: none available" : `Tools: ${active} active / ${all} available`; + }, + handle: async (_command, runtime) => { + const active = runtime.session.getActiveToolNames(); + const all = runtime.session.getAllToolNames(); + if (all.length === 0) { + await runtime.output("No tools are available."); + return commandConsumed(); + } + const lines = all.map(name => `${active.includes(name) ? "*" : "-"} ${name}`); + for (const mounted of runtime.session.getXdevToolEntries()) { + lines.push(`~ xd://${mounted.name}`); + } + await runtime.output(lines.join("\n")); + return commandConsumed(); + }, + handleTui: (_command, runtime) => { + runtime.ctx.handleToolsCommand(); + runtime.ctx.editor.setText(""); + }, + }, + { + name: "context", + description: "Show estimated context usage breakdown", + acpDescription: "Show context usage", + getTuiAutocompleteDescription: runtime => { + const usage = runtime.ctx.session.getContextUsage(); + if (!usage) return "Context: unavailable"; + return `Context: ${Math.round(usage.percent)}% (${formatTokenCount(usage.tokens)}/${formatTokenCount(usage.contextWindow)})`; + }, + handle: async (_command, runtime) => { + await runtime.output(buildContextReportText(runtime)); + return commandConsumed(); + }, + handleTui: (_command, runtime) => { + runtime.ctx.handleContextCommand(); + runtime.ctx.editor.setText(""); + }, + }, + { + name: "extensions", + aliases: ["status"], + description: "Open Extension Control Center dashboard", + handleTui: (_command, runtime) => { + runtime.ctx.showExtensionsDashboard(); + runtime.ctx.editor.setText(""); + }, + }, + { + name: "agents", + description: "Open Agent Control Center dashboard", + handleTui: (_command, runtime) => { + runtime.ctx.showAgentsDashboard(); + runtime.ctx.editor.setText(""); + }, + }, + { + name: "branch", + description: "Create a new branch from a previous message", + handleTui: (_command, runtime) => { + if (settings.get("doubleEscapeAction") === "tree") { + runtime.ctx.showTreeSelector(); + } else { + runtime.ctx.showUserMessageSelector(); + } + runtime.ctx.editor.setText(""); + }, + }, + { + name: "fork", + description: "Create a new fork from a previous message", + handleTui: async (_command, runtime) => { + runtime.ctx.editor.setText(""); + await runtime.ctx.handleForkCommand(); + }, + }, + { + name: "tree", + description: "Navigate session tree (switch branches)", + handleTui: (_command, runtime) => { + runtime.ctx.showTreeSelector(); + runtime.ctx.editor.setText(""); + }, + }, + { + name: "login", + description: "Login with OAuth provider", + inlineHint: "[provider|redirect URL]", + allowArgs: true, + getTuiAutocompleteDescription: runtime => + runtime.ctx.oauthManualInput.hasPending() + ? `Login: waiting for ${runtime.ctx.oauthManualInput.pendingProviderId ?? "OAuth"} callback` + : "Login: choose provider", + handleTui: (command, runtime) => { + const manualInput = runtime.ctx.oauthManualInput; + const args = command.args.trim(); + if (args.length > 0) { + const matchedProvider = getOAuthProviders().find(provider => provider.id === args); + if (matchedProvider) { + if (manualInput.hasPending()) { + const pendingProvider = manualInput.pendingProviderId; + const message = pendingProvider + ? `OAuth login already in progress for ${pendingProvider}. Paste the redirect URL with /login <url>.` + : "OAuth login already in progress. Paste the redirect URL with /login <url>."; + runtime.ctx.showWarning(message); + runtime.ctx.editor.setText(""); + return; + } + void runtime.ctx.showOAuthSelector("login", matchedProvider.id); + runtime.ctx.editor.setText(""); + return; + } + const submitted = manualInput.submit(args); + if (submitted) { + runtime.ctx.showStatus("OAuth callback received; completing login…"); + } else { + runtime.ctx.showWarning("No OAuth login is waiting for a manual callback."); + } + runtime.ctx.editor.setText(""); + return; + } + + if (manualInput.hasPending()) { + const provider = manualInput.pendingProviderId; + const message = provider + ? `OAuth login already in progress for ${provider}. Paste the redirect URL with /login <url>.` + : "OAuth login already in progress. Paste the redirect URL with /login <url>."; + runtime.ctx.showWarning(message); + runtime.ctx.editor.setText(""); + return; + } + + void runtime.ctx.showOAuthSelector("login"); + runtime.ctx.editor.setText(""); + }, + }, + { + name: "logout", + description: "Logout from OAuth provider", + inlineHint: "[provider]", + allowArgs: true, + handleTui: (command, runtime) => { + const providerId = command.args.trim(); + if (providerId) { + const matchedProvider = getOAuthProviders().find(provider => provider.id === providerId); + if (!matchedProvider) { + runtime.ctx.showWarning(`Unknown OAuth provider: ${providerId}`); + runtime.ctx.editor.setText(""); + return; + } + void runtime.ctx.showOAuthSelector("logout", matchedProvider.id); + runtime.ctx.editor.setText(""); + return; + } + void runtime.ctx.showOAuthSelector("logout"); + runtime.ctx.editor.setText(""); + }, + }, + { + name: "mcp", + description: "Manage MCP servers (add, list, remove, test)", + acpDescription: "Manage MCP servers", + inlineHint: "<subcommand>", + subcommands: [ + { + name: "add", + description: "Add a new MCP server", + usage: "<name> [--scope project|user] [--url <url>] [-- <command...>]", + }, + { name: "list", description: "List all configured MCP servers" }, + { name: "remove", description: "Remove an MCP server", usage: "<name> [--scope project|user]" }, + { name: "test", description: "Test connection to a server", usage: "<name>" }, + { name: "reauth", description: "Reauthorize OAuth for a server", usage: "<name>" }, + { name: "unauth", description: "Remove OAuth auth from a server", usage: "<name>" }, + { name: "enable", description: "Enable an MCP server", usage: "<name>" }, + { name: "disable", description: "Disable an MCP server", usage: "<name>" }, + { + name: "smithery-search", + description: "Search Smithery registry and deploy an MCP server", + usage: "<keyword> [--scope project|user] [--limit <1-100>] [--semantic]", + }, + { name: "smithery-login", description: "Login to Smithery and cache API key" }, + { name: "smithery-logout", description: "Remove cached Smithery API key" }, + { name: "reconnect", description: "Reconnect to a specific MCP server", usage: "<name>" }, + { name: "reload", description: "Force reload MCP runtime tools" }, + { name: "resources", description: "List available resources from connected servers" }, + { name: "prompts", description: "List available prompts from connected servers" }, + { name: "notifications", description: "Show notification capabilities and subscriptions" }, + { name: "help", description: "Show help message" }, + ], + allowArgs: true, + handle: handleMcpAcp, + handleTui: async (command, runtime) => { + runtime.ctx.editor.setText(""); + await runtime.ctx.handleMCPCommand(command.text); + }, + }, +];