feat(rpc): expose slash command metadata

This commit is contained in:
danzaio
2026-06-10 14:10:02 -03:00
parent bbe85b6661
commit 1f2fcff15f
8 changed files with 224 additions and 67 deletions
@@ -56,7 +56,7 @@ import {
} from "../../extensibility/extensions";
import { runExtensionCompact } from "../../extensibility/extensions/compact-handler";
import { getSessionSlashCommands } from "../../extensibility/extensions/get-commands-handler";
import { buildSkillPromptMessage, getSkillSlashCommandName } from "../../extensibility/skills";
import { buildSkillPromptMessage } from "../../extensibility/skills";
import { loadSlashCommands } from "../../extensibility/slash-commands";
import { resolveLocalUrlToPath } from "../../internal-urls";
import { MCPManager } from "../../mcp/manager";
@@ -71,12 +71,8 @@ import {
type SessionInfo as StoredSessionInfo,
type UsageStatistics,
} from "../../session/session-manager";
import {
ACP_BUILTIN_RESERVED_NAMES,
ACP_BUILTIN_SLASH_COMMANDS,
executeAcpBuiltinSlashCommand,
isAcpBuiltinShadowedName,
} from "../../slash-commands/acp-builtins";
import { executeAcpBuiltinSlashCommand } from "../../slash-commands/acp-builtins";
import { buildAvailableSlashCommands, toAcpAvailableCommands } from "../../slash-commands/available-commands";
import { AUTO_THINKING, parseConfiguredThinkingLevel } from "../../thinking";
import { normalizeLocalScheme } from "../../tools/path-utils";
import { runResolveInvocation } from "../../tools/resolve";
@@ -1662,66 +1658,7 @@ export class AcpAgent implements Agent {
}
async #buildAvailableCommands(session: AgentSession): Promise<AvailableCommand[]> {
const commands: AvailableCommand[] = [];
const seenNames = new Set<string>();
const appendCommand = (command: AvailableCommand): void => {
if (seenNames.has(command.name)) {
return;
}
seenNames.add(command.name);
commands.push(command);
};
// Advertise in the order dispatch resolves them (mirrors AgentSession
// dispatch: builtins → skills → extensions → custom TS → file-based).
// `appendCommand` dedupes by name so earlier entries win; extension
// commands therefore correctly shadow custom TS commands of the same
// name, matching the runtime behaviour of #tryExecuteExtensionCommand
// running before #tryExecuteCustomCommand.
for (const command of ACP_BUILTIN_SLASH_COMMANDS) {
appendCommand(command);
}
if (session.skillsSettings?.enableSkillCommands) {
for (const skill of session.skills) {
appendCommand({
name: getSkillSlashCommandName(skill),
description: skill.description || `Run ${skill.name} skill`,
input: { hint: "arguments" },
});
}
}
for (const command of session.extensionRunner?.getRegisteredCommands(ACP_BUILTIN_RESERVED_NAMES) ?? []) {
// Reserved-set filtering in getRegisteredCommands only covers exact
// names; colon-namespaced names whose prefix is a builtin (e.g.
// `model:foo`) would still dispatch to the builtin in ACP.
if (isAcpBuiltinShadowedName(command.name)) {
continue;
}
appendCommand({
name: command.name,
description: command.description ?? "(extension command)",
input: { hint: "arguments" },
});
}
for (const command of session.customCommands) {
appendCommand({
name: command.command.name,
description: command.command.description,
input: { hint: "arguments" },
});
}
for (const command of await loadSlashCommands({ cwd: session.sessionManager.getCwd() })) {
appendCommand({
name: command.name,
description: command.description,
});
}
return commands;
return toAcpAvailableCommands(await buildAvailableSlashCommands(session));
}
#toSessionInfo(session: StoredSessionInfo): SessionInfo {
@@ -13,6 +13,7 @@ import type { FileSink } from "bun";
import type { BashResult } from "../../exec/bash-executor";
import type { AgentSessionEvent, SessionStats } from "../../session/agent-session";
import type {
RpcAvailableSlashCommand,
RpcCommand,
RpcExtensionUIRequest,
RpcHandoffResult,
@@ -511,6 +512,14 @@ export class RpcClient {
return this.#getData<{ models: ModelInfo[] }>(response).models;
}
/**
* Get list of available slash commands.
*/
async getAvailableCommands(): Promise<RpcAvailableSlashCommand[]> {
const response = await this.#send({ type: "get_available_commands" });
return this.#getData<{ commands: RpcAvailableSlashCommand[] }>(response).commands;
}
/**
* Set thinking level.
*/
@@ -21,6 +21,8 @@ import {
} from "../../extensibility/extensions";
import { type Theme, theme } from "../../modes/theme/theme";
import type { AgentSession } from "../../session/agent-session";
import { executeAcpBuiltinSlashCommand } from "../../slash-commands/acp-builtins";
import { buildAvailableSlashCommands } from "../../slash-commands/available-commands";
import type { EventBus } from "../../utils/event-bus";
import { initializeExtensions } from "../runtime-init";
import { isRpcHostToolResult, isRpcHostToolUpdate, RpcHostToolBridge } from "./host-tools";
@@ -511,6 +513,12 @@ export async function runRpcMode(
output(event);
});
const getAvailableCommands = async () => buildAvailableSlashCommands(session);
const emitAvailableCommandsUpdate = async () => {
output({ type: "available_commands_update", commands: await getAvailableCommands() });
};
await emitAvailableCommandsUpdate();
// Handle a single command
const handleCommand = async (command: RpcCommand): Promise<RpcResponse> => {
const id = command.id;
@@ -521,6 +529,26 @@ export async function runRpcMode(
// =================================================================
case "prompt": {
const builtinResult = await executeAcpBuiltinSlashCommand(command.message, {
session,
sessionManager: session.sessionManager,
settings: session.settings,
cwd: session.sessionManager.getCwd(),
output: text => output({ type: "command_output", text }),
refreshCommands: emitAvailableCommandsUpdate,
reloadPlugins: async () => {},
notifyTitleChanged: async () => {},
notifyConfigChanged: async () => {},
});
if (builtinResult !== false) {
if ("prompt" in builtinResult) {
session
.prompt(builtinResult.prompt, { images: command.images })
.catch(e => output(error(id, "prompt", e.message)));
}
return success(id, "prompt");
}
// Don't await - events will stream
// Extension commands are executed immediately, file prompt templates are expanded
// If streaming and streamingBehavior specified, queues via steer/followUp
@@ -592,6 +620,10 @@ export async function runRpcMode(
return success(id, "get_state", state);
}
case "get_available_commands": {
return success(id, "get_available_commands", { commands: await getAvailableCommands() });
}
case "set_todos": {
session.setTodoPhases(command.phases);
return success(id, "set_todos", { todoPhases: session.getTodoPhases() });
@@ -11,6 +11,7 @@ import type { BashResult } from "../../exec/bash-executor";
import type { ContextUsage } from "../../extensibility/extensions/types";
import type { AgentSessionEvent, SessionStats } from "../../session/agent-session";
import type { FileEntry } from "../../session/session-manager";
import type { AvailableSlashCommandSource } from "../../slash-commands/available-commands";
import type {
AgentProgress,
SubagentEventPayload,
@@ -34,6 +35,7 @@ export type RpcCommand =
// State
| { id?: string; type: "get_state" }
| { id?: string; type: "get_available_commands" }
| { id?: string; type: "set_todos"; phases: TodoPhase[] }
| { id?: string; type: "set_host_tools"; tools: RpcHostToolDefinition[] }
| { id?: string; type: "set_host_uri_schemes"; schemes: RpcHostUriSchemeDefinition[] }
@@ -110,6 +112,15 @@ export interface RpcSessionState {
contextUsage?: ContextUsage;
}
export interface RpcAvailableSlashCommand {
name: string;
aliases?: string[];
description?: string;
input?: { hint?: string };
subcommands?: Array<{ name: string; description?: string; usage?: string }>;
source: AvailableSlashCommandSource;
}
export interface RpcHandoffResult {
savedPath?: string;
}
@@ -156,6 +167,13 @@ export type RpcResponse =
// State
| { id?: string; type: "response"; command: "get_state"; success: true; data: RpcSessionState }
| {
id?: string;
type: "response";
command: "get_available_commands";
success: true;
data: { commands: RpcAvailableSlashCommand[] };
}
| { id?: string; type: "response"; command: "set_todos"; success: true; data: { todoPhases: TodoPhase[] } }
| { id?: string; type: "response"; command: "set_host_tools"; success: true; data: { toolNames: string[] } }
| { id?: string; type: "response"; command: "set_host_uri_schemes"; success: true; data: { schemes: string[] } }