Skip to content

Commit 8c9b4da

Browse files
[Node] Add Support For Commands and Simple UI
1 parent 485ea5e commit 8c9b4da

5 files changed

Lines changed: 616 additions & 3 deletions

File tree

nodejs/src/client.ts

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -576,6 +576,7 @@ export class CopilotClient {
576576
this.onGetTraceContext
577577
);
578578
session.registerTools(config.tools);
579+
session.registerCommands(config.commands);
579580
session.registerPermissionHandler(config.onPermissionRequest);
580581
if (config.onUserInputRequest) {
581582
session.registerUserInputHandler(config.onUserInputRequest);
@@ -602,6 +603,10 @@ export class CopilotClient {
602603
overridesBuiltInTool: tool.overridesBuiltInTool,
603604
skipPermission: tool.skipPermission,
604605
})),
606+
commands: config.commands?.map((cmd) => ({
607+
name: cmd.name,
608+
description: cmd.description,
609+
})),
605610
systemMessage: config.systemMessage,
606611
availableTools: config.availableTools,
607612
excludedTools: config.excludedTools,
@@ -621,11 +626,15 @@ export class CopilotClient {
621626
infiniteSessions: config.infiniteSessions,
622627
});
623628

624-
const { workspacePath } = response as {
629+
const { workspacePath, capabilities } = response as {
625630
sessionId: string;
626631
workspacePath?: string;
632+
capabilities?: { ui?: boolean };
627633
};
628634
session["_workspacePath"] = workspacePath;
635+
if (capabilities?.ui) {
636+
this._wireUI(session);
637+
}
629638
} catch (e) {
630639
this.sessions.delete(sessionId);
631640
throw e;
@@ -682,6 +691,7 @@ export class CopilotClient {
682691
this.onGetTraceContext
683692
);
684693
session.registerTools(config.tools);
694+
session.registerCommands(config.commands);
685695
session.registerPermissionHandler(config.onPermissionRequest);
686696
if (config.onUserInputRequest) {
687697
session.registerUserInputHandler(config.onUserInputRequest);
@@ -711,6 +721,10 @@ export class CopilotClient {
711721
overridesBuiltInTool: tool.overridesBuiltInTool,
712722
skipPermission: tool.skipPermission,
713723
})),
724+
commands: config.commands?.map((cmd) => ({
725+
name: cmd.name,
726+
description: cmd.description,
727+
})),
714728
provider: config.provider,
715729
requestPermission: true,
716730
requestUserInput: !!config.onUserInputRequest,
@@ -728,11 +742,15 @@ export class CopilotClient {
728742
disableResume: config.disableResume,
729743
});
730744

731-
const { workspacePath } = response as {
745+
const { workspacePath, capabilities } = response as {
732746
sessionId: string;
733747
workspacePath?: string;
748+
capabilities?: { ui?: boolean };
734749
};
735750
session["_workspacePath"] = workspacePath;
751+
if (capabilities?.ui) {
752+
this._wireUI(session);
753+
}
736754
} catch (e) {
737755
this.sessions.delete(sessionId);
738756
throw e;
@@ -741,6 +759,56 @@ export class CopilotClient {
741759
return session;
742760
}
743761

762+
/**
763+
* Creates and attaches a SessionUI implementation to the session.
764+
* The UI methods send JSON-RPC requests to the CLI host.
765+
* @internal
766+
*/
767+
private _wireUI(session: CopilotSession): void {
768+
const connection = this.connection!;
769+
const sessionId = session.sessionId;
770+
771+
session._setUI({
772+
async confirm(title, message, options) {
773+
const response = await connection.sendRequest("session.ui.confirm", {
774+
sessionId,
775+
title,
776+
message,
777+
default: options?.default,
778+
});
779+
return (response as { confirmed: boolean }).confirmed;
780+
},
781+
782+
async select(title, options, selectOptions) {
783+
const normalizedOptions = options.map((opt) =>
784+
typeof opt === "string" ? { value: opt, label: opt } : opt
785+
);
786+
const response = await connection.sendRequest("session.ui.select", {
787+
sessionId,
788+
title,
789+
options: normalizedOptions,
790+
description: selectOptions?.description,
791+
default: selectOptions?.default,
792+
});
793+
return (response as { selected: string | null }).selected;
794+
},
795+
796+
async input(title, options) {
797+
const response = await connection.sendRequest("session.ui.input", {
798+
sessionId,
799+
title,
800+
placeholder: options?.placeholder,
801+
description: options?.description,
802+
default: options?.default,
803+
format: options?.format,
804+
minLength: options?.minLength,
805+
maxLength: options?.maxLength,
806+
});
807+
return (response as { value: string | null }).value;
808+
},
809+
});
810+
}
811+
744812
/**
745813
* Gets the current connection state of the client.
746814
*

nodejs/src/index.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,18 @@
1010

1111
export { CopilotClient } from "./client.js";
1212
export { CopilotSession, type AssistantMessageEvent } from "./session.js";
13-
export { defineTool, approveAll } from "./types.js";
13+
export { defineTool, approveAll, defineCommand } from "./types.js";
1414
export type {
15+
Command,
16+
CommandHandler,
17+
CommandInvocation,
1518
ConnectionState,
1619
CopilotClientOptions,
1720
CustomAgentConfig,
1821
ForegroundSessionInfo,
1922
GetAuthStatusResponse,
2023
GetStatusResponse,
24+
HostCapabilities,
2125
InfiniteSessionConfig,
2226
MCPLocalServerConfig,
2327
MCPRemoteServerConfig,
@@ -42,6 +46,11 @@ export type {
4246
SessionContext,
4347
SessionListFilter,
4448
SessionMetadata,
49+
SessionUI,
50+
SelectOption,
51+
SelectOptions,
52+
ConfirmOptions,
53+
InputOptions,
4554
SystemMessageAppendConfig,
4655
SystemMessageConfig,
4756
SystemMessageReplaceConfig,

nodejs/src/session.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import { ConnectionError, ResponseError } from "vscode-jsonrpc/node.js";
1212
import { createSessionRpc } from "./generated/rpc.js";
1313
import { getTraceContext } from "./telemetry.js";
1414
import type {
15+
Command,
16+
CommandHandler,
1517
MessageOptions,
1618
PermissionHandler,
1719
PermissionRequest,
@@ -22,6 +24,7 @@ import type {
2224
SessionEventPayload,
2325
SessionEventType,
2426
SessionHooks,
27+
SessionUI,
2528
Tool,
2629
ToolHandler,
2730
TraceContextProvider,
@@ -67,11 +70,13 @@ export class CopilotSession {
6770
private typedEventHandlers: Map<SessionEventType, Set<(event: SessionEvent) => void>> =
6871
new Map();
6972
private toolHandlers: Map<string, ToolHandler> = new Map();
73+
private commandHandlers: Map<string, CommandHandler> = new Map();
7074
private permissionHandler?: PermissionHandler;
7175
private userInputHandler?: UserInputHandler;
7276
private hooks?: SessionHooks;
7377
private _rpc: ReturnType<typeof createSessionRpc> | null = null;
7478
private traceContextProvider?: TraceContextProvider;
79+
private _ui: SessionUI | null = null;
7580

7681
/**
7782
* Creates a new CopilotSession instance.
@@ -110,6 +115,23 @@ export class CopilotSession {
110115
return this._workspacePath;
111116
}
112117

118+
/**
119+
* Interactive UI methods for showing dialogs to the user.
120+
*
121+
* Returns `undefined` when the host does not support interactive UI
122+
* (e.g., GitHub Actions, headless SDK usage).
123+
*
124+
* @example
125+
* ```typescript
126+
* if (session.ui) {
127+
* const ok = await session.ui.confirm("Deploy?", "Push to production?");
128+
* }
129+
* ```
130+
*/
131+
get ui(): SessionUI | undefined {
132+
return this._ui ?? undefined;
133+
}
134+
113135
/**
114136
* Sends a message to this session and waits for the response.
115137
*
@@ -367,6 +389,16 @@ export class CopilotSession {
367389
if (this.permissionHandler) {
368390
void this._executePermissionAndRespond(requestId, permissionRequest);
369391
}
392+
} else if ((event.type as string) === "command.requested") {
393+
const { requestId, commandName, args } = (event as unknown as { data: {
394+
requestId: string;
395+
commandName: string;
396+
args: string;
397+
} }).data;
398+
const handler = this.commandHandlers.get(commandName);
399+
if (handler) {
400+
void this._executeCommandAndRespond(requestId, commandName, args, handler);
401+
}
370402
}
371403
}
372404

@@ -447,6 +479,41 @@ export class CopilotSession {
447479
}
448480
}
449481

482+
/**
483+
* Executes a command handler and sends the result back via RPC.
484+
* @internal
485+
*/
486+
private async _executeCommandAndRespond(
487+
requestId: string,
488+
_commandName: string,
489+
args: string,
490+
handler: CommandHandler
491+
): Promise<void> {
492+
try {
493+
await handler(args, {
494+
sessionId: this.sessionId,
495+
});
496+
await this.connection.sendRequest("session.commands.handlePendingCommand", {
497+
sessionId: this.sessionId,
498+
requestId,
499+
});
500+
} catch (error) {
501+
const message = error instanceof Error ? error.message : String(error);
502+
try {
503+
await this.connection.sendRequest("session.commands.handlePendingCommand", {
504+
sessionId: this.sessionId,
505+
requestId,
506+
error: message,
507+
});
508+
} catch (rpcError) {
509+
if (!(rpcError instanceof ConnectionError || rpcError instanceof ResponseError)) {
510+
throw rpcError;
511+
}
512+
// Connection lost or RPC error — nothing we can do
513+
}
514+
}
515+
}
516+
450517
/**
451518
* Registers custom tool handlers for this session.
452519
*
@@ -478,6 +545,35 @@ export class CopilotSession {
478545
return this.toolHandlers.get(name);
479546
}
480547

548+
/**
549+
* Registers slash commands for this session.
550+
*
551+
* Commands are invoked by the user typing `/name` in the input.
552+
*
553+
* @param commands - An array of command definitions, or undefined to clear all commands
554+
* @internal This method is typically called internally when creating a session with commands.
555+
*/
556+
registerCommands(commands?: Command[]): void {
557+
this.commandHandlers.clear();
558+
if (!commands) {
559+
return;
560+
}
561+
562+
for (const command of commands) {
563+
this.commandHandlers.set(command.name, command.handler);
564+
}
565+
}
566+
567+
/**
568+
* Sets the SessionUI implementation for this session.
569+
*
570+
* @param ui - The UI implementation, or null to disable UI
571+
* @internal This method is called by the client after session creation.
572+
*/
573+
_setUI(ui: SessionUI | null): void {
574+
this._ui = ui;
575+
}
576+
481577
/**
482578
* Registers a handler for permission requests.
483579
*
@@ -667,7 +763,9 @@ export class CopilotSession {
667763
this.eventHandlers.clear();
668764
this.typedEventHandlers.clear();
669765
this.toolHandlers.clear();
766+
this.commandHandlers.clear();
670767
this.permissionHandler = undefined;
768+
this._ui = null;
671769
}
672770

673771
/**

0 commit comments

Comments
 (0)