Files
oh-my-pi/packages/coding-agent/src/slash-commands/builtin-registry.ts
T
can1357 8588aa5802 refactor(coding-agent/slash-commands): removed collab qrcode slash commands
- Removed qrcode and qrcode-view subcommands from the collab command.
- Updated showCollabLink to always trigger QR code display.
- Refactored internal verb logic to simplify session sharing flow.
2026-06-19 01:30:26 +02:00

2318 lines
82 KiB
TypeScript

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 { setNextRequestDebugPath } from "@oh-my-pi/pi-ai/utils/request-debug";
import { type AutocompleteItem, Spacer } from "@oh-my-pi/pi-tui";
import { APP_NAME, setProjectDir } from "@oh-my-pi/pi-utils";
import { COLLAB_GUEST_ALLOWED_COMMANDS, CollabGuestLink } from "../collab/guest";
import { CollabHost } from "../collab/host";
import type { SettingPath, SettingValue } from "../config/settings";
import { settings } from "../config/settings";
import {
clearPluginRootsAndCaches,
resolveActiveProjectRegistryPath,
resolveOrDefaultProjectRegistryPath,
} from "../discovery/helpers.js";
import { shareSession } from "../export/share";
import { PluginManager } from "../extensibility/plugins";
import {
getInstalledPluginsRegistryPath,
getMarketplacesCacheDir,
getMarketplacesRegistryPath,
getPluginsCacheDir,
MarketplaceManager,
} from "../extensibility/plugins/marketplace";
import { resolveMemoryBackend } from "../memory-backend";
import { theme } from "../modes/theme/theme";
import type { InteractiveModeContext } from "../modes/types";
import type { AgentSession, FreshSessionResult } from "../session/agent-session";
import { formatShakeSummary, type ShakeMode } from "../session/shake-types";
import { urlHyperlinkAlways } from "../tui";
import { getChangelogPath, parseChangelog } from "../utils/changelog";
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 { 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";
import type {
BuiltinSlashCommand,
ParsedSlashCommand,
SlashCommandResult,
SlashCommandRuntime,
SlashCommandSpec,
SubcommandDef,
TuiSlashCommandRuntime,
} from "./types";
export type { BuiltinSlashCommand, SubcommandDef } from "./types";
/** TUI-specific runtime accepted by `executeBuiltinSlashCommand`. */
export type BuiltinSlashCommandRuntime = TuiSlashCommandRuntime;
function refreshStatusLine(ctx: InteractiveModeContext): void {
ctx.statusLine.invalidate();
ctx.updateEditorTopBorder();
ctx.ui.requestRender();
}
/** `/fast status` label: "off", "on", or scope-qualified "on (… only)". */
function formatFastModeStatus(session: AgentSession): string {
if (!session.isFastModeEnabled()) return "off";
switch (session.serviceTier) {
case "openai-only":
return "on (OpenAI only)";
case "claude-only":
return "on (Claude only)";
default:
return "on";
}
}
/** 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));
}
const DEBUG_DUMP_NEXT_REQUEST_USAGE = "Usage: /debug dump-next-request <path>";
function resolveDebugRequestDumpPath(target: string, cwd: string): string {
const expanded =
target === "~"
? os.homedir()
: target.startsWith("~/") || target.startsWith("~\\")
? path.join(os.homedir(), target.slice(2))
: target;
return path.resolve(cwd, expanded);
}
async function handleDebugSubcommand(
args: string,
cwd: string,
output: (text: string) => Promise<void> | void,
): Promise<SlashCommandResult> {
const { verb, rest } = parseSubcommand(args);
switch (verb) {
case "":
await output(DEBUG_DUMP_NEXT_REQUEST_USAGE);
return commandConsumed();
case "dump-next-request":
case "dump-request":
case "next-request": {
if (!rest) {
await output(DEBUG_DUMP_NEXT_REQUEST_USAGE);
return commandConsumed();
}
const requestPath = resolveDebugRequestDumpPath(rest, cwd);
setNextRequestDebugPath(requestPath);
await output(`Next AI provider request will be dumped to ${requestPath}`);
return commandConsumed();
}
default:
await output(`Unknown /debug subcommand "${verb}". ${DEBUG_DUMP_NEXT_REQUEST_USAGE}`);
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.` };
}
const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
{
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,
handleTui: async (command, runtime) => {
const hadArgs = !!command.args;
await runtime.ctx.handlePlanModeCommand(command.args || undefined);
if (hadArgs) {
runtime.ctx.editor.addToHistory(command.text);
}
runtime.ctx.editor.setText("");
},
},
{
name: "plan-review",
description: "Re-open the plan review for the latest plan (plan mode only)",
handleTui: async (_command, runtime) => {
await runtime.ctx.openPlanReview();
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,
handleTui: async (command, runtime) => {
const hadArgs = !!command.args;
await runtime.ctx.handleGoalModeCommand(command.args || undefined);
if (hadArgs) {
runtime.ctx.editor.addToHistory(command.text);
}
runtime.ctx.editor.setText("");
},
},
{
name: "guided-goal",
description: "Interview and refine a goal before enabling goal mode",
inlineHint: "[rough objective]",
allowArgs: true,
handleTui: async (command, runtime) => {
await runtime.ctx.handleGuidedGoalCommand(command.args || undefined);
runtime.ctx.editor.setText("");
},
},
{
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]",
allowArgs: true,
handleTui: async (command, runtime) => {
await runtime.ctx.handleLoopCommand(command.args);
runtime.ctx.editor.setText("");
},
},
{
name: "model",
aliases: ["models"],
description: "Switch model for this session",
acpDescription: "Show current model selection",
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)",
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,
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") {
runtime.session.setFastMode(true);
await runtime.output("Fast mode enabled.");
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") {
runtime.ctx.session.setFastMode(true);
refreshStatusLine(runtime.ctx);
runtime.ctx.showStatus("Fast mode enabled.");
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: "advisor",
description: "Toggle the advisor (a second model that reviews each turn and injects notes)",
acpDescription: "Toggle advisor",
acpInputHint: "[on|off|status|dump [raw]]",
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]" },
],
allowArgs: true,
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();
}
return usage("Usage: /advisor [on|off|status|dump [raw]]", 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;
}
runtime.ctx.showStatus("Usage: /advisor [on|off|status|dump [raw]]");
runtime.ctx.editor.setText("");
},
},
{
name: "export",
description: "Export session to HTML file",
inlineHint: "[path]",
allowArgs: true,
handle: async (command, runtime) => {
const arg = command.args.trim();
// Match the interactive `/export` behavior: clipboard aliases are not a
// valid export target. Without this, the literal value (`copy`,
// `--copy`, `clipboard`) is passed to `exportToHtml` and becomes the
// output filename.
if (arg === "--copy" || arg === "clipboard" || arg === "copy") {
return usage("Use /dump to copy the session to clipboard.", runtime);
}
try {
const filePath = await runtime.session.exportToHtml(arg || undefined);
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",
acpDescription: "Return full transcript as plain text",
allowArgs: true,
handle: async (_command, runtime) => {
const text = runtime.session.formatSessionAsText();
await runtime.output(text || "No messages to dump yet.");
return commandConsumed();
},
handleTui: (_command, runtime) => {
runtime.ctx.handleDumpCommand();
runtime.ctx.editor.setText("");
},
},
{
name: "share",
description: "Share session via an encrypted link (secret gist or share server)",
handle: async (_command, runtime) => {
try {
const result = await shareSession(runtime.sessionManager, {
serverUrl: runtime.settings.get("share.serverUrl"),
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,
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",
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,
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",
handleTui: (_command, runtime) => {
runtime.ctx.showCopySelector();
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,
handle: handleTodoAcp,
handleTui: async (command, runtime) => {
await runtime.ctx.handleTodoCommand(command.args);
runtime.ctx.editor.setText("");
},
},
{
name: "session",
description: "Session management commands",
acpDescription: "Show session information",
acpInputHint: "info|delete",
subcommands: [
{ name: "info", description: "Show session info and stats" },
{ name: "delete", description: "Delete current session and return to selector" },
],
allowArgs: true,
handle: async (command, runtime) => {
if (!command.args || command.args === "info") {
await runtime.output(
[
`Session: ${runtime.session.sessionId}`,
`Title: ${runtime.session.sessionName}`,
`CWD: ${runtime.cwd}`,
].join("\n"),
);
return commandConsumed();
}
if (command.args === "delete") {
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();
}
return usage("Usage: /session [info|delete]", runtime);
},
handleTui: async (command, runtime) => {
const sub = command.args.trim().toLowerCase() || "info";
if (sub === "delete") {
runtime.ctx.editor.setText("");
await runtime.ctx.handleSessionDeleteCommand();
return;
}
// Default: show session info
await runtime.ctx.handleSessionCommand();
runtime.ctx.editor.setText("");
},
},
{
name: "jobs",
description: "Show async background jobs status",
acpDescription: "Show background jobs",
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, 3);
if (entriesToShow.length === 0) {
await runtime.output("No changelog entries found.");
return commandConsumed();
}
await runtime.output(
[...entriesToShow]
.reverse()
.map(entry => entry.content)
.join("\n\n"),
);
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",
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();
}
await runtime.output(all.map(name => `${active.includes(name) ? "*" : "-"} ${name}`).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",
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,
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.addToHistory(command.text);
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.addToHistory(command.text);
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",
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: "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",
inlineHint: "[focus instructions]",
allowArgs: true,
handle: async (command, runtime) => {
const before = runtime.session.getContextUsage?.();
const beforeTokens = before?.tokens;
try {
await runtime.session.compact(command.args || 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 customInstructions = command.args || undefined;
runtime.ctx.editor.setText("");
await runtime.ctx.handleCompactCommand(customInstructions);
},
},
{
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",
handleTui: (_command, runtime) => {
runtime.ctx.showSessionSelector();
runtime.ctx.editor.setText("");
},
},
{
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",
allowArgs: true,
subcommands: [
{
name: "dump-next-request",
description: "Dump the next AI provider HTTP request as JSON",
usage: "<path>",
},
],
handle: async (command, runtime) =>
handleDebugSubcommand(command.args, runtime.cwd, text => runtime.output(text)),
handleTui: async (command, runtime) => {
const args = command.args.trim();
if (args.length === 0) {
runtime.ctx.showDebugSelector();
} else {
await handleDebugSubcommand(args, runtime.ctx.sessionManager.getCwd(), text =>
runtime.ctx.showStatus(text),
);
}
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 ?? `Memory ${verb} is not available for the ${backend.id} backend.`);
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 session to a different working directory",
acpDescription: "Move the current session file",
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 = path.resolve(runtime.cwd, command.args);
let isDirectory: boolean;
try {
isDirectory = (await fs.stat(resolvedPath)).isDirectory();
} catch {
return usage(`Directory does not exist or is not a directory: ${resolvedPath}`, runtime);
}
if (!isDirectory) return usage(`Directory does not exist or is not a directory: ${resolvedPath}`, runtime);
try {
await runtime.sessionManager.flush();
await runtime.sessionManager.moveTo(resolvedPath);
} catch (err) {
return usage(`Move failed: ${errorMessage(err)}`, runtime);
}
setProjectDir(resolvedPath);
// Reload plugin/capability caches so the next prompt sees commands and
// capabilities scoped to the new cwd.
await runtime.reloadPlugins();
await runtime.notifyTitleChanged?.();
await runtime.output(`Session moved to ${runtime.sessionManager.getCwd()}.`);
return commandConsumed();
},
handleTui: async (command, runtime) => {
const targetPath = command.args;
if (!targetPath) {
runtime.ctx.showError("Usage: /move <path>");
runtime.ctx.editor.setText("");
return;
}
runtime.ctx.editor.setText("");
await runtime.ctx.handleMoveCommand(targetPath);
},
},
{
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) => {
// Invalidate registry fs caches and the plugin roots cache so
// listClaudePluginRoots re-reads from disk on next access.
const projectPath = await resolveActiveProjectRegistryPath(runtime.ctx.sessionManager.getCwd());
clearPluginRootsAndCaches(projectPath ? [projectPath] : undefined);
await runtime.ctx.refreshSlashCommandState();
await runtime.ctx.session.refreshSshTool({ activateIfAvailable: true });
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,
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: "quit",
description: "Quit the application",
handleTui: shutdownHandlerTui,
},
];
const BUILTIN_SLASH_COMMAND_LOOKUP = new Map<string, SlashCommandSpec>();
for (const command of BUILTIN_SLASH_COMMAND_REGISTRY) {
BUILTIN_SLASH_COMMAND_LOOKUP.set(command.name, command);
for (const alias of command.aliases ?? []) {
BUILTIN_SLASH_COMMAND_LOOKUP.set(alias, command);
}
}
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;
};
}
/**
* 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);
}
/** 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 => ({
name: command.name,
aliases: command.aliases,
description: command.description,
subcommands: command.subcommands,
inlineHint: command.inlineHint,
}),
);
/**
* Materialized builtin slash commands with completion functions derived from
* declarative subcommand/hint definitions.
*/
export const BUILTIN_SLASH_COMMANDS: ReadonlyArray<
BuiltinSlashCommand & {
getArgumentCompletions?: (prefix: string) => AutocompleteItem[] | null;
getInlineHint?: (argumentText: string) => string | null;
}
> = BUILTIN_SLASH_COMMAND_DEFS.map(cmd => {
if (cmd.subcommands) {
return {
...cmd,
getArgumentCompletions: buildArgumentCompletions(cmd.subcommands),
getInlineHint: buildSubcommandInlineHint(cmd.subcommands),
};
}
if (cmd.inlineHint) {
return {
...cmd,
getInlineHint: buildStaticInlineHint(cmd.inlineHint),
};
}
return cmd;
});
/**
* Unified registry exposed for cross-mode tooling. Each spec carries at least
* one of `handle` / `handleTui`. The TUI dispatcher prefers `handleTui`; the
* ACP dispatcher requires `handle` and skips TUI-only entries.
*/
export const BUILTIN_SLASH_COMMANDS_INTERNAL: ReadonlyArray<SlashCommandSpec> = BUILTIN_SLASH_COMMAND_REGISTRY;
/**
* Execute a builtin slash command in the interactive TUI.
*
* Returns `false` when no builtin matched. Returns `true` when a command
* consumed the input entirely. Returns a `string` when the command was handled
* but remaining text should be sent as a prompt.
*/
export async function executeBuiltinSlashCommand(
text: string,
runtime: BuiltinSlashCommandRuntime,
): Promise<string | boolean> {
const parsed = parseSlashCommand(text);
if (!parsed) return false;
const command = BUILTIN_SLASH_COMMAND_LOOKUP.get(parsed.name);
if (!command) return false;
if (parsed.args.length > 0 && !command.allowArgs) {
return false;
}
// Collab guests run a read-mostly replica: session-mutating builtins are
// host-only; the allowlist covers purely local/read-only commands.
if (runtime.ctx.collabGuest && !COLLAB_GUEST_ALLOWED_COMMANDS[command.name]) {
runtime.ctx.showStatus(`/${command.name} is host-only during a collab session`);
runtime.ctx.editor.setText("");
return true;
}
if (command.handleTui) {
const result = await command.handleTui(parsed, runtime);
if (result && typeof result === "object" && "prompt" in result) return result.prompt;
return true;
}
if (command.handle) {
// No TUI-specific override → adapt the ACP/text-mode `handle` to the
// TUI by routing `runtime.output` through `ctx.showStatus`, clearing
// the editor after the call, and reusing the active session's plugin
// reload pipeline. Spec authors get a single body usable from either
// dispatcher without forcing every TUI test to construct the full
// `SlashCommandRuntime` shape.
const ctx = runtime.ctx;
const adapted: SlashCommandRuntime = {
session: ctx.session,
sessionManager: ctx.sessionManager,
settings: ctx.settings,
cwd: ctx.sessionManager.getCwd(),
output: (text: string) => {
ctx.showStatus(text);
},
refreshCommands: () => ctx.refreshSlashCommandState(),
reloadPlugins: async () => {
const projectPath = await resolveActiveProjectRegistryPath(ctx.sessionManager.getCwd());
clearPluginRootsAndCaches(projectPath ? [projectPath] : undefined);
await ctx.refreshSlashCommandState();
await ctx.session.refreshSshTool({ activateIfAvailable: true });
},
};
const result = await command.handle(parsed, adapted);
ctx.editor.setText("");
if (result && typeof result === "object" && "prompt" in result) return result.prompt;
return true;
}
return false;
}
/** Look up a unified spec by name or alias. Used by the ACP dispatcher. */
export function lookupBuiltinSlashCommand(name: string): SlashCommandSpec | undefined {
return BUILTIN_SLASH_COMMAND_LOOKUP.get(name);
}
export type { ParsedSlashCommand, SlashCommandResult, SlashCommandRuntime, SlashCommandSpec, TuiSlashCommandRuntime };