diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index b8e7b31dc..a86cc7530 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -576,6 +576,7 @@ export class CopilotClient { this.onGetTraceContext ); session.registerTools(config.tools); + session.registerCommands(config.commands); session.registerPermissionHandler(config.onPermissionRequest); if (config.onUserInputRequest) { session.registerUserInputHandler(config.onUserInputRequest); @@ -602,6 +603,10 @@ export class CopilotClient { overridesBuiltInTool: tool.overridesBuiltInTool, skipPermission: tool.skipPermission, })), + commands: config.commands?.map((cmd) => ({ + name: cmd.name, + description: cmd.description, + })), systemMessage: config.systemMessage, availableTools: config.availableTools, excludedTools: config.excludedTools, @@ -621,11 +626,15 @@ export class CopilotClient { infiniteSessions: config.infiniteSessions, }); - const { workspacePath } = response as { + const { workspacePath, capabilities } = response as { sessionId: string; workspacePath?: string; + capabilities?: { ui?: boolean }; }; session["_workspacePath"] = workspacePath; + if (capabilities?.ui) { + this._wireUI(session); + } } catch (e) { this.sessions.delete(sessionId); throw e; @@ -682,6 +691,7 @@ export class CopilotClient { this.onGetTraceContext ); session.registerTools(config.tools); + session.registerCommands(config.commands); session.registerPermissionHandler(config.onPermissionRequest); if (config.onUserInputRequest) { session.registerUserInputHandler(config.onUserInputRequest); @@ -711,6 +721,10 @@ export class CopilotClient { overridesBuiltInTool: tool.overridesBuiltInTool, skipPermission: tool.skipPermission, })), + commands: config.commands?.map((cmd) => ({ + name: cmd.name, + description: cmd.description, + })), provider: config.provider, requestPermission: true, requestUserInput: !!config.onUserInputRequest, @@ -728,11 +742,15 @@ export class CopilotClient { disableResume: config.disableResume, }); - const { workspacePath } = response as { + const { workspacePath, capabilities } = response as { sessionId: string; workspacePath?: string; + capabilities?: { ui?: boolean }; }; session["_workspacePath"] = workspacePath; + if (capabilities?.ui) { + this._wireUI(session); + } } catch (e) { this.sessions.delete(sessionId); throw e; @@ -741,6 +759,56 @@ export class CopilotClient { return session; } + /** + * Creates and attaches a SessionUI implementation to the session. + * The UI methods send JSON-RPC requests to the CLI host. + * @internal + */ + private _wireUI(session: CopilotSession): void { + const connection = this.connection!; + const sessionId = session.sessionId; + + session._setUI({ + async confirm(title, message, options) { + const response = await connection.sendRequest("session.ui.confirm", { + sessionId, + title, + message, + default: options?.default, + }); + return (response as { confirmed: boolean }).confirmed; + }, + + async select(title, options, selectOptions) { + const normalizedOptions = options.map((opt) => + typeof opt === "string" ? { value: opt, label: opt } : opt + ); + const response = await connection.sendRequest("session.ui.select", { + sessionId, + title, + options: normalizedOptions, + description: selectOptions?.description, + default: selectOptions?.default, + }); + return (response as { selected: string | null }).selected; + }, + + async input(title, options) { + const response = await connection.sendRequest("session.ui.input", { + sessionId, + title, + placeholder: options?.placeholder, + description: options?.description, + default: options?.default, + format: options?.format, + minLength: options?.minLength, + maxLength: options?.maxLength, + }); + return (response as { value: string | null }).value; + }, + }); + } + /** * Gets the current connection state of the client. * diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts index 214b80050..1df413ff6 100644 --- a/nodejs/src/index.ts +++ b/nodejs/src/index.ts @@ -10,14 +10,18 @@ export { CopilotClient } from "./client.js"; export { CopilotSession, type AssistantMessageEvent } from "./session.js"; -export { defineTool, approveAll } from "./types.js"; +export { defineTool, approveAll, defineCommand } from "./types.js"; export type { + Command, + CommandHandler, + CommandInvocation, ConnectionState, CopilotClientOptions, CustomAgentConfig, ForegroundSessionInfo, GetAuthStatusResponse, GetStatusResponse, + HostCapabilities, InfiniteSessionConfig, MCPLocalServerConfig, MCPRemoteServerConfig, @@ -42,6 +46,11 @@ export type { SessionContext, SessionListFilter, SessionMetadata, + SessionUI, + SelectOption, + SelectOptions, + ConfirmOptions, + InputOptions, SystemMessageAppendConfig, SystemMessageConfig, SystemMessageReplaceConfig, diff --git a/nodejs/src/session.ts b/nodejs/src/session.ts index 674526764..da1ff6654 100644 --- a/nodejs/src/session.ts +++ b/nodejs/src/session.ts @@ -12,6 +12,8 @@ import { ConnectionError, ResponseError } from "vscode-jsonrpc/node.js"; import { createSessionRpc } from "./generated/rpc.js"; import { getTraceContext } from "./telemetry.js"; import type { + Command, + CommandHandler, MessageOptions, PermissionHandler, PermissionRequest, @@ -22,6 +24,7 @@ import type { SessionEventPayload, SessionEventType, SessionHooks, + SessionUI, Tool, ToolHandler, TraceContextProvider, @@ -67,11 +70,13 @@ export class CopilotSession { private typedEventHandlers: Map void>> = new Map(); private toolHandlers: Map = new Map(); + private commandHandlers: Map = new Map(); private permissionHandler?: PermissionHandler; private userInputHandler?: UserInputHandler; private hooks?: SessionHooks; private _rpc: ReturnType | null = null; private traceContextProvider?: TraceContextProvider; + private _ui: SessionUI | null = null; /** * Creates a new CopilotSession instance. @@ -110,6 +115,23 @@ export class CopilotSession { return this._workspacePath; } + /** + * Interactive UI methods for showing dialogs to the user. + * + * Returns `undefined` when the host does not support interactive UI + * (e.g., GitHub Actions, headless SDK usage). + * + * @example + * ```typescript + * if (session.ui) { + * const ok = await session.ui.confirm("Deploy?", "Push to production?"); + * } + * ``` + */ + get ui(): SessionUI | undefined { + return this._ui ?? undefined; + } + /** * Sends a message to this session and waits for the response. * @@ -367,6 +389,16 @@ export class CopilotSession { if (this.permissionHandler) { void this._executePermissionAndRespond(requestId, permissionRequest); } + } else if ((event.type as string) === "command.requested") { + const { requestId, commandName, args } = (event as unknown as { data: { + requestId: string; + commandName: string; + args: string; + } }).data; + const handler = this.commandHandlers.get(commandName); + if (handler) { + void this._executeCommandAndRespond(requestId, commandName, args, handler); + } } } @@ -447,6 +479,41 @@ export class CopilotSession { } } + /** + * Executes a command handler and sends the result back via RPC. + * @internal + */ + private async _executeCommandAndRespond( + requestId: string, + _commandName: string, + args: string, + handler: CommandHandler + ): Promise { + try { + await handler(args, { + sessionId: this.sessionId, + }); + await this.connection.sendRequest("session.commands.handlePendingCommand", { + sessionId: this.sessionId, + requestId, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + try { + await this.connection.sendRequest("session.commands.handlePendingCommand", { + sessionId: this.sessionId, + requestId, + error: message, + }); + } catch (rpcError) { + if (!(rpcError instanceof ConnectionError || rpcError instanceof ResponseError)) { + throw rpcError; + } + // Connection lost or RPC error — nothing we can do + } + } + } + /** * Registers custom tool handlers for this session. * @@ -478,6 +545,35 @@ export class CopilotSession { return this.toolHandlers.get(name); } + /** + * Registers slash commands for this session. + * + * Commands are invoked by the user typing `/name` in the input. + * + * @param commands - An array of command definitions, or undefined to clear all commands + * @internal This method is typically called internally when creating a session with commands. + */ + registerCommands(commands?: Command[]): void { + this.commandHandlers.clear(); + if (!commands) { + return; + } + + for (const command of commands) { + this.commandHandlers.set(command.name, command.handler); + } + } + + /** + * Sets the SessionUI implementation for this session. + * + * @param ui - The UI implementation, or null to disable UI + * @internal This method is called by the client after session creation. + */ + _setUI(ui: SessionUI | null): void { + this._ui = ui; + } + /** * Registers a handler for permission requests. * @@ -667,7 +763,9 @@ export class CopilotSession { this.eventHandlers.clear(); this.typedEventHandlers.clear(); this.toolHandlers.clear(); + this.commandHandlers.clear(); this.permissionHandler = undefined; + this._ui = null; } /** diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 9576b6925..cda6e87b7 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -576,6 +576,176 @@ export interface SessionHooks { onErrorOccurred?: ErrorOccurredHandler; } +// ============================================================================ +// Command Types +// ============================================================================ + +/** + * Invocation context passed to command handlers. + */ +export interface CommandInvocation { + sessionId: string; +} + +/** + * Handler for a slash command. + * Receives the raw argument string (everything after `/commandname `). + */ +export type CommandHandler = ( + args: string, + invocation: CommandInvocation +) => Promise | void; + +/** + * Slash command definition. + * Commands are registered on the session and invoked by the user typing `/name`. + */ +export interface Command { + /** Command name (without the leading `/`). */ + name: string; + + /** Human-readable description shown in command completion UI. */ + description?: string; + + /** The handler called when the user invokes this command. */ + handler: CommandHandler; +} + +/** + * Helper to define a command (mirrors {@link defineTool}). + */ +export function defineCommand( + name: string, + config: { + description?: string; + handler: CommandHandler; + } +): Command { + return { name, ...config }; +} + +// ============================================================================ +// Session UI Types +// ============================================================================ + +/** + * Interactive UI methods available when the session host supports them. + * + * Access via `session.ui`. The property is `undefined` when the host + * does not support interactive UI (e.g., GitHub Actions, headless SDK usage). + * + * @example + * ```typescript + * if (session.ui) { + * const ok = await session.ui.confirm("Deploy?", "This will push to production."); + * } + * ``` + */ +export interface SessionUI { + /** + * Show a confirmation dialog. + * + * @param title - Dialog title + * @param message - Dialog body text + * @param options - Optional configuration + * @returns `true` if the user confirmed, `false` if declined or cancelled + */ + confirm(title: string, message: string, options?: ConfirmOptions): Promise; + + /** + * Show a selection dialog. The user picks one option from a list. + * + * Options can be plain strings or `{value, label}` pairs. When pairs are used, + * `label` is displayed to the user and `value` is returned. + * + * @param title - Dialog title + * @param options - List of options to choose from + * @param selectOptions - Optional configuration + * @returns The selected option value, or `null` if cancelled + * + * @example + * ```typescript + * // Simple string options + * const db = await session.ui.select("Pick a database", ["PostgreSQL", "MySQL", "SQLite"]); + * + * // Labeled options — user sees labels, you get values + * const env = await session.ui.select("Target environment", [ + * { value: "prod", label: "Production" }, + * { value: "staging", label: "Staging" }, + * ]); + * ``` + */ + select( + title: string, + options: Array, + selectOptions?: SelectOptions + ): Promise; + + /** + * Show a text input dialog. + * + * @param title - Dialog title / prompt + * @param options - Optional configuration + * @returns The entered text, or `null` if cancelled + */ + input(title: string, options?: InputOptions): Promise; +} + +/** + * Options for {@link SessionUI.confirm}. + */ +export interface ConfirmOptions { + /** Default selection (`true` for confirm, `false` for decline). */ + default?: boolean; +} + +/** + * An option in a {@link SessionUI.select} dialog. + */ +export interface SelectOption { + /** The value returned when this option is selected. */ + value: string; + /** The label displayed to the user. Defaults to `value` if omitted. */ + label: string; +} + +/** + * Options for {@link SessionUI.select}. + */ +export interface SelectOptions { + /** Description shown below the title. */ + description?: string; + /** Pre-selected value. */ + default?: string; +} + +/** + * Options for {@link SessionUI.input}. + */ +export interface InputOptions { + /** Placeholder text shown when the input is empty. */ + placeholder?: string; + /** Description shown below the title. */ + description?: string; + /** Default value pre-filled in the input. */ + default?: string; + /** Input format hint. Enables validation in the host UI. */ + format?: "email" | "uri" | "date" | "date-time"; + /** Minimum length. */ + minLength?: number; + /** Maximum length. */ + maxLength?: number; +} + +/** + * Capabilities reported by the session host in the create/resume response. + * The SDK uses these to determine which optional features to wire up. + */ +export interface HostCapabilities { + /** Whether the host supports interactive UI dialogs (confirm, select, input). */ + ui?: boolean; +} + // ============================================================================ // MCP Server Configuration Types // ============================================================================ @@ -742,6 +912,12 @@ export interface SessionConfig { // eslint-disable-next-line @typescript-eslint/no-explicit-any tools?: Tool[]; + /** + * Slash commands exposed to the user. + * Commands are invoked by the user typing `/name` in the input. + */ + commands?: Command[]; + /** * System message configuration * Controls how the system prompt is constructed @@ -854,6 +1030,7 @@ export type ResumeSessionConfig = Pick< | "clientName" | "model" | "tools" + | "commands" | "systemMessage" | "availableTools" | "excludedTools" diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index 3d13d27ff..f6080165e 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -650,4 +650,265 @@ describe("CopilotClient", () => { expect(params.tracestate).toBeUndefined(); }); }); + + describe("commands in session creation", () => { + it("forwards commands metadata in session.create request", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => client.forceStop()); + + const spy = vi.spyOn((client as any).connection!, "sendRequest"); + await client.createSession({ + onPermissionRequest: approveAll, + commands: [ + { name: "deploy", description: "Deploy to production", handler: async () => {} }, + { name: "status", handler: async () => {} }, + ], + }); + + expect(spy).toHaveBeenCalledWith( + "session.create", + expect.objectContaining({ + commands: [ + { name: "deploy", description: "Deploy to production" }, + { name: "status", description: undefined }, + ], + }) + ); + }); + + it("forwards commands metadata in session.resume request", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => client.forceStop()); + + const session = await client.createSession({ onPermissionRequest: approveAll }); + const spy = vi + .spyOn((client as any).connection!, "sendRequest") + .mockImplementation(async (method: string, params: any) => { + if (method === "session.resume") return { sessionId: params.sessionId }; + throw new Error(`Unexpected method: ${method}`); + }); + + await client.resumeSession(session.sessionId, { + onPermissionRequest: approveAll, + commands: [{ name: "test-cmd", description: "A test", handler: async () => {} }], + }); + + expect(spy).toHaveBeenCalledWith( + "session.resume", + expect.objectContaining({ + commands: [{ name: "test-cmd", description: "A test" }], + }) + ); + spy.mockRestore(); + }); + + it("sends undefined commands when none are provided", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => client.forceStop()); + + const spy = vi.spyOn((client as any).connection!, "sendRequest"); + await client.createSession({ onPermissionRequest: approveAll }); + + const [, params] = spy.mock.calls.find(([method]) => method === "session.create")!; + expect(params.commands).toBeUndefined(); + }); + }); + + describe("session.ui capability negotiation", () => { + it("session.ui is undefined when host does not report ui capability", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => client.forceStop()); + + // Default CLI response doesn't include capabilities.ui + const session = await client.createSession({ onPermissionRequest: approveAll }); + expect(session.ui).toBeUndefined(); + }); + + it("session.ui is wired up when host reports ui capability", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => client.forceStop()); + + // Mock session.create to return capabilities.ui = true + const origSendRequest = (client as any).connection!.sendRequest.bind( + (client as any).connection! + ); + const spy = vi + .spyOn((client as any).connection!, "sendRequest") + .mockImplementation(async (method: string, params: any) => { + if (method === "session.create") { + const result = await origSendRequest(method, params); + return { ...result, capabilities: { ui: true } }; + } + return origSendRequest(method, params); + }); + + const session = await client.createSession({ onPermissionRequest: approveAll }); + expect(session.ui).toBeDefined(); + + spy.mockRestore(); + }); + + it("session.ui.confirm sends correct RPC", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => client.forceStop()); + + // Mock to return ui capability + handle ui.confirm + const origSendRequest = (client as any).connection!.sendRequest.bind( + (client as any).connection! + ); + const spy = vi + .spyOn((client as any).connection!, "sendRequest") + .mockImplementation(async (method: string, params: any) => { + if (method === "session.create") { + const result = await origSendRequest(method, params); + return { ...result, capabilities: { ui: true } }; + } + if (method === "session.ui.confirm") { + return { confirmed: true }; + } + return origSendRequest(method, params); + }); + + const session = await client.createSession({ onPermissionRequest: approveAll }); + const result = await session.ui!.confirm("Deploy?", "Push to production?"); + + expect(result).toBe(true); + expect(spy).toHaveBeenCalledWith( + "session.ui.confirm", + expect.objectContaining({ + title: "Deploy?", + message: "Push to production?", + }) + ); + + spy.mockRestore(); + }); + + it("session.ui.select sends correct RPC with labeled options", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => client.forceStop()); + + const origSendRequest = (client as any).connection!.sendRequest.bind( + (client as any).connection! + ); + const spy = vi + .spyOn((client as any).connection!, "sendRequest") + .mockImplementation(async (method: string, params: any) => { + if (method === "session.create") { + const result = await origSendRequest(method, params); + return { ...result, capabilities: { ui: true } }; + } + if (method === "session.ui.select") { + return { selected: "prod" }; + } + return origSendRequest(method, params); + }); + + const session = await client.createSession({ onPermissionRequest: approveAll }); + const result = await session.ui!.select("Target", [ + { value: "prod", label: "Production" }, + { value: "staging", label: "Staging" }, + ]); + + expect(result).toBe("prod"); + expect(spy).toHaveBeenCalledWith( + "session.ui.select", + expect.objectContaining({ + title: "Target", + options: [ + { value: "prod", label: "Production" }, + { value: "staging", label: "Staging" }, + ], + }) + ); + + spy.mockRestore(); + }); + + it("session.ui.select normalizes string options to value/label pairs", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => client.forceStop()); + + const origSendRequest = (client as any).connection!.sendRequest.bind( + (client as any).connection! + ); + const spy = vi + .spyOn((client as any).connection!, "sendRequest") + .mockImplementation(async (method: string, params: any) => { + if (method === "session.create") { + const result = await origSendRequest(method, params); + return { ...result, capabilities: { ui: true } }; + } + if (method === "session.ui.select") { + return { selected: "MySQL" }; + } + return origSendRequest(method, params); + }); + + const session = await client.createSession({ onPermissionRequest: approveAll }); + await session.ui!.select("Pick DB", ["PostgreSQL", "MySQL"]); + + expect(spy).toHaveBeenCalledWith( + "session.ui.select", + expect.objectContaining({ + options: [ + { value: "PostgreSQL", label: "PostgreSQL" }, + { value: "MySQL", label: "MySQL" }, + ], + }) + ); + + spy.mockRestore(); + }); + + it("session.ui.input sends correct RPC with options", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => client.forceStop()); + + const origSendRequest = (client as any).connection!.sendRequest.bind( + (client as any).connection! + ); + const spy = vi + .spyOn((client as any).connection!, "sendRequest") + .mockImplementation(async (method: string, params: any) => { + if (method === "session.create") { + const result = await origSendRequest(method, params); + return { ...result, capabilities: { ui: true } }; + } + if (method === "session.ui.input") { + return { value: "user@test.com" }; + } + return origSendRequest(method, params); + }); + + const session = await client.createSession({ onPermissionRequest: approveAll }); + const result = await session.ui!.input("Email", { + placeholder: "you@example.com", + format: "email", + default: "test@test.com", + }); + + expect(result).toBe("user@test.com"); + expect(spy).toHaveBeenCalledWith( + "session.ui.input", + expect.objectContaining({ + title: "Email", + placeholder: "you@example.com", + format: "email", + default: "test@test.com", + }) + ); + + spy.mockRestore(); + }); + }); });