Merge pull request #512 from nicobailon/feat/abort-signal-ui-dialogs

Add AbortSignal support to extension UI dialogs
This commit is contained in:
Mario Zechner 2026-01-07 00:10:20 +01:00 committed by GitHub
commit 2015964c40
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 184 additions and 13 deletions

View file

@ -52,13 +52,13 @@ export type { AgentToolResult, AgentToolUpdateCallback };
*/
export interface ExtensionUIContext {
/** Show a selector and return the user's choice. */
select(title: string, options: string[]): Promise<string | undefined>;
select(title: string, options: string[], opts?: { signal?: AbortSignal }): Promise<string | undefined>;
/** Show a confirmation dialog. */
confirm(title: string, message: string): Promise<boolean>;
confirm(title: string, message: string, opts?: { signal?: AbortSignal }): Promise<boolean>;
/** Show a text input dialog. */
input(title: string, placeholder?: string): Promise<string | undefined>;
input(title: string, placeholder?: string, opts?: { signal?: AbortSignal }): Promise<string | undefined>;
/** Show a notification to the user. */
notify(message: string, type?: "info" | "warning" | "error"): void;

View file

@ -739,9 +739,9 @@ export class InteractiveMode {
*/
private createExtensionUIContext(): ExtensionUIContext {
return {
select: (title, options) => this.showExtensionSelector(title, options),
confirm: (title, message) => this.showExtensionConfirm(title, message),
input: (title, placeholder) => this.showExtensionInput(title, placeholder),
select: (title, options, opts) => this.showExtensionSelector(title, options, opts),
confirm: (title, message, opts) => this.showExtensionConfirm(title, message, opts),
input: (title, placeholder, opts) => this.showExtensionInput(title, placeholder, opts),
notify: (message, type) => this.showExtensionNotify(message, type),
setStatus: (key, text) => this.setExtensionStatus(key, text),
setWidget: (key, content) => this.setExtensionWidget(key, content),
@ -761,16 +761,33 @@ export class InteractiveMode {
/**
* Show a selector for extensions.
*/
private showExtensionSelector(title: string, options: string[]): Promise<string | undefined> {
private showExtensionSelector(
title: string,
options: string[],
opts?: { signal?: AbortSignal },
): Promise<string | undefined> {
return new Promise((resolve) => {
if (opts?.signal?.aborted) {
resolve(undefined);
return;
}
const onAbort = () => {
this.hideExtensionSelector();
resolve(undefined);
};
opts?.signal?.addEventListener("abort", onAbort, { once: true });
this.extensionSelector = new ExtensionSelectorComponent(
title,
options,
(option) => {
opts?.signal?.removeEventListener("abort", onAbort);
this.hideExtensionSelector();
resolve(option);
},
() => {
opts?.signal?.removeEventListener("abort", onAbort);
this.hideExtensionSelector();
resolve(undefined);
},
@ -797,24 +814,45 @@ export class InteractiveMode {
/**
* Show a confirmation dialog for extensions.
*/
private async showExtensionConfirm(title: string, message: string): Promise<boolean> {
const result = await this.showExtensionSelector(`${title}\n${message}`, ["Yes", "No"]);
private async showExtensionConfirm(
title: string,
message: string,
opts?: { signal?: AbortSignal },
): Promise<boolean> {
const result = await this.showExtensionSelector(`${title}\n${message}`, ["Yes", "No"], opts);
return result === "Yes";
}
/**
* Show a text input for extensions.
*/
private showExtensionInput(title: string, placeholder?: string): Promise<string | undefined> {
private showExtensionInput(
title: string,
placeholder?: string,
opts?: { signal?: AbortSignal },
): Promise<string | undefined> {
return new Promise((resolve) => {
if (opts?.signal?.aborted) {
resolve(undefined);
return;
}
const onAbort = () => {
this.hideExtensionInput();
resolve(undefined);
};
opts?.signal?.addEventListener("abort", onAbort, { once: true });
this.extensionInput = new ExtensionInputComponent(
title,
placeholder,
(value) => {
opts?.signal?.removeEventListener("abort", onAbort);
this.hideExtensionInput();
resolve(value);
},
() => {
opts?.signal?.removeEventListener("abort", onAbort);
this.hideExtensionInput();
resolve(undefined);
},

View file

@ -67,11 +67,22 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
* Create an extension UI context that uses the RPC protocol.
*/
const createExtensionUIContext = (): ExtensionUIContext => ({
async select(title: string, options: string[]): Promise<string | undefined> {
async select(title: string, options: string[], opts?: { signal?: AbortSignal }): Promise<string | undefined> {
if (opts?.signal?.aborted) {
return undefined;
}
const id = crypto.randomUUID();
return new Promise((resolve, reject) => {
const onAbort = () => {
pendingExtensionRequests.delete(id);
resolve(undefined);
};
opts?.signal?.addEventListener("abort", onAbort, { once: true });
pendingExtensionRequests.set(id, {
resolve: (response: RpcExtensionUIResponse) => {
opts?.signal?.removeEventListener("abort", onAbort);
if ("cancelled" in response && response.cancelled) {
resolve(undefined);
} else if ("value" in response) {
@ -86,11 +97,22 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
});
},
async confirm(title: string, message: string): Promise<boolean> {
async confirm(title: string, message: string, opts?: { signal?: AbortSignal }): Promise<boolean> {
if (opts?.signal?.aborted) {
return false;
}
const id = crypto.randomUUID();
return new Promise((resolve, reject) => {
const onAbort = () => {
pendingExtensionRequests.delete(id);
resolve(false);
};
opts?.signal?.addEventListener("abort", onAbort, { once: true });
pendingExtensionRequests.set(id, {
resolve: (response: RpcExtensionUIResponse) => {
opts?.signal?.removeEventListener("abort", onAbort);
if ("cancelled" in response && response.cancelled) {
resolve(false);
} else if ("confirmed" in response) {
@ -105,11 +127,22 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
});
},
async input(title: string, placeholder?: string): Promise<string | undefined> {
async input(title: string, placeholder?: string, opts?: { signal?: AbortSignal }): Promise<string | undefined> {
if (opts?.signal?.aborted) {
return undefined;
}
const id = crypto.randomUUID();
return new Promise((resolve, reject) => {
const onAbort = () => {
pendingExtensionRequests.delete(id);
resolve(undefined);
};
opts?.signal?.addEventListener("abort", onAbort, { once: true });
pendingExtensionRequests.set(id, {
resolve: (response: RpcExtensionUIResponse) => {
opts?.signal?.removeEventListener("abort", onAbort);
if ("cancelled" in response && response.cancelled) {
resolve(undefined);
} else if ("value" in response) {