Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 70 additions & 2 deletions nodejs/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand All @@ -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;
Expand All @@ -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.
*
Expand Down
11 changes: 10 additions & 1 deletion nodejs/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -42,6 +46,11 @@ export type {
SessionContext,
SessionListFilter,
SessionMetadata,
SessionUI,
SelectOption,
SelectOptions,
ConfirmOptions,
InputOptions,
SystemMessageAppendConfig,
SystemMessageConfig,
SystemMessageReplaceConfig,
Expand Down
98 changes: 98 additions & 0 deletions nodejs/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -22,6 +24,7 @@ import type {
SessionEventPayload,
SessionEventType,
SessionHooks,
SessionUI,
Tool,
ToolHandler,
TraceContextProvider,
Expand Down Expand Up @@ -67,11 +70,13 @@ export class CopilotSession {
private typedEventHandlers: Map<SessionEventType, Set<(event: SessionEvent) => void>> =
new Map();
private toolHandlers: Map<string, ToolHandler> = new Map();
private commandHandlers: Map<string, CommandHandler> = new Map();
private permissionHandler?: PermissionHandler;
private userInputHandler?: UserInputHandler;
private hooks?: SessionHooks;
private _rpc: ReturnType<typeof createSessionRpc> | null = null;
private traceContextProvider?: TraceContextProvider;
private _ui: SessionUI | null = null;

/**
* Creates a new CopilotSession instance.
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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);
}
}
}

Expand Down Expand Up @@ -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<void> {
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.
*
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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;
}

/**
Expand Down
Loading
Loading