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.
This commit is contained in:
can1357
2026-08-08 06:32:01 +02:00
parent 71af89cbaa
commit 8cf3dce6fb
8 changed files with 3077 additions and 3000 deletions
@@ -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<SlashCommandSpec> = [
{
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("");
},
},
];
@@ -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: "<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.
*/
export 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.
*/
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<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("\\", "/")}/`;
}
@@ -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<SlashCommandSpec> = [
{
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,
},
];
@@ -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<SlashCommandSpec> = [
{
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,
},
];
@@ -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("");
},
},
];
@@ -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();
},
},
];
File diff suppressed because it is too large Load Diff
@@ -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);
},
},
];