Merge origin/main into feat/no-extensions-flag

# Conflicts:
#	packages/coding-agent/CHANGELOG.md
This commit is contained in:
Carlos Villela 2026-01-07 02:27:16 -08:00
commit 2f16c7f7fc
No known key found for this signature in database
12 changed files with 259 additions and 191 deletions

View file

@ -36,6 +36,7 @@ export type {
ExtensionHandler,
ExtensionShortcut,
ExtensionUIContext,
ExtensionUIDialogOptions,
FindToolResultEvent,
GetActiveToolsHandler,
GetAllToolsHandler,

View file

@ -46,19 +46,27 @@ export type { AgentToolResult, AgentToolUpdateCallback };
// UI Context
// ============================================================================
/** Options for extension UI dialogs. */
export interface ExtensionUIDialogOptions {
/** AbortSignal to programmatically dismiss the dialog. */
signal?: AbortSignal;
/** Timeout in milliseconds. Dialog auto-dismisses with live countdown display. */
timeout?: number;
}
/**
* UI context for extensions to request interactive UI.
* Each mode (interactive, RPC, print) provides its own implementation.
*/
export interface ExtensionUIContext {
/** Show a selector and return the user's choice. */
select(title: string, options: string[], opts?: { signal?: AbortSignal }): Promise<string | undefined>;
select(title: string, options: string[], opts?: ExtensionUIDialogOptions): Promise<string | undefined>;
/** Show a confirmation dialog. */
confirm(title: string, message: string, opts?: { signal?: AbortSignal }): Promise<boolean>;
confirm(title: string, message: string, opts?: ExtensionUIDialogOptions): Promise<boolean>;
/** Show a text input dialog. */
input(title: string, placeholder?: string, opts?: { signal?: AbortSignal }): Promise<string | undefined>;
input(title: string, placeholder?: string, opts?: ExtensionUIDialogOptions): Promise<string | undefined>;
/** Show a notification to the user. */
notify(message: string, type?: "info" | "warning" | "error"): void;

View file

@ -54,6 +54,7 @@ export type {
ExtensionHandler,
ExtensionShortcut,
ExtensionUIContext,
ExtensionUIDialogOptions,
LoadExtensionsResult,
LoadedExtension,
MessageRenderer,

View file

@ -0,0 +1,38 @@
/**
* Reusable countdown timer for dialog components.
*/
import type { TUI } from "@mariozechner/pi-tui";
export class CountdownTimer {
private intervalId: ReturnType<typeof setInterval> | undefined;
private remainingSeconds: number;
constructor(
timeoutMs: number,
private tui: TUI | undefined,
private onTick: (seconds: number) => void,
private onExpire: () => void,
) {
this.remainingSeconds = Math.ceil(timeoutMs / 1000);
this.onTick(this.remainingSeconds);
this.intervalId = setInterval(() => {
this.remainingSeconds--;
this.onTick(this.remainingSeconds);
this.tui?.requestRender();
if (this.remainingSeconds <= 0) {
this.dispose();
this.onExpire();
}
}, 1000);
}
dispose(): void {
if (this.intervalId) {
clearInterval(this.intervalId);
this.intervalId = undefined;
}
}
}

View file

@ -2,64 +2,73 @@
* Simple text input component for extensions.
*/
import { Container, getEditorKeybindings, Input, Spacer, Text } from "@mariozechner/pi-tui";
import { Container, getEditorKeybindings, Input, Spacer, Text, type TUI } from "@mariozechner/pi-tui";
import { theme } from "../theme/theme.js";
import { CountdownTimer } from "./countdown-timer.js";
import { DynamicBorder } from "./dynamic-border.js";
export interface ExtensionInputOptions {
tui?: TUI;
timeout?: number;
}
export class ExtensionInputComponent extends Container {
private input: Input;
private onSubmitCallback: (value: string) => void;
private onCancelCallback: () => void;
private titleText: Text;
private baseTitle: string;
private countdown: CountdownTimer | undefined;
constructor(
title: string,
_placeholder: string | undefined,
onSubmit: (value: string) => void,
onCancel: () => void,
opts?: ExtensionInputOptions,
) {
super();
this.onSubmitCallback = onSubmit;
this.onCancelCallback = onCancel;
this.baseTitle = title;
// Add top border
this.addChild(new DynamicBorder());
this.addChild(new Spacer(1));
// Add title
this.addChild(new Text(theme.fg("accent", title), 1, 0));
this.titleText = new Text(theme.fg("accent", title), 1, 0);
this.addChild(this.titleText);
this.addChild(new Spacer(1));
// Create input
if (opts?.timeout && opts.timeout > 0 && opts.tui) {
this.countdown = new CountdownTimer(
opts.timeout,
opts.tui,
(s) => this.titleText.setText(theme.fg("accent", `${this.baseTitle} (${s}s)`)),
() => this.onCancelCallback(),
);
}
this.input = new Input();
this.addChild(this.input);
this.addChild(new Spacer(1));
// Add hint
this.addChild(new Text(theme.fg("dim", "enter submit esc cancel"), 1, 0));
this.addChild(new Spacer(1));
// Add bottom border
this.addChild(new DynamicBorder());
}
handleInput(keyData: string): void {
const kb = getEditorKeybindings();
// Enter
if (kb.matches(keyData, "selectConfirm") || keyData === "\n") {
this.onSubmitCallback(this.input.getValue());
return;
}
// Escape or Ctrl+C to cancel
if (kb.matches(keyData, "selectCancel")) {
} else if (kb.matches(keyData, "selectCancel")) {
this.onCancelCallback();
return;
} else {
this.input.handleInput(keyData);
}
}
// Forward to input
this.input.handleInput(keyData);
dispose(): void {
this.countdown?.dispose();
}
}

View file

@ -3,90 +3,94 @@
* Displays a list of string options with keyboard navigation.
*/
import { Container, getEditorKeybindings, Spacer, Text } from "@mariozechner/pi-tui";
import { Container, getEditorKeybindings, Spacer, Text, type TUI } from "@mariozechner/pi-tui";
import { theme } from "../theme/theme.js";
import { CountdownTimer } from "./countdown-timer.js";
import { DynamicBorder } from "./dynamic-border.js";
export interface ExtensionSelectorOptions {
tui?: TUI;
timeout?: number;
}
export class ExtensionSelectorComponent extends Container {
private options: string[];
private selectedIndex = 0;
private listContainer: Container;
private onSelectCallback: (option: string) => void;
private onCancelCallback: () => void;
private titleText: Text;
private baseTitle: string;
private countdown: CountdownTimer | undefined;
constructor(title: string, options: string[], onSelect: (option: string) => void, onCancel: () => void) {
constructor(
title: string,
options: string[],
onSelect: (option: string) => void,
onCancel: () => void,
opts?: ExtensionSelectorOptions,
) {
super();
this.options = options;
this.onSelectCallback = onSelect;
this.onCancelCallback = onCancel;
this.baseTitle = title;
// Add top border
this.addChild(new DynamicBorder());
this.addChild(new Spacer(1));
// Add title
this.addChild(new Text(theme.fg("accent", title), 1, 0));
this.titleText = new Text(theme.fg("accent", title), 1, 0);
this.addChild(this.titleText);
this.addChild(new Spacer(1));
// Create list container
if (opts?.timeout && opts.timeout > 0 && opts.tui) {
this.countdown = new CountdownTimer(
opts.timeout,
opts.tui,
(s) => this.titleText.setText(theme.fg("accent", `${this.baseTitle} (${s}s)`)),
() => this.onCancelCallback(),
);
}
this.listContainer = new Container();
this.addChild(this.listContainer);
this.addChild(new Spacer(1));
// Add hint
this.addChild(new Text(theme.fg("dim", "↑↓ navigate enter select esc cancel"), 1, 0));
this.addChild(new Spacer(1));
// Add bottom border
this.addChild(new DynamicBorder());
// Initial render
this.updateList();
}
private updateList(): void {
this.listContainer.clear();
for (let i = 0; i < this.options.length; i++) {
const option = this.options[i];
const isSelected = i === this.selectedIndex;
let text = "";
if (isSelected) {
text = theme.fg("accent", "→ ") + theme.fg("accent", option);
} else {
text = ` ${theme.fg("text", option)}`;
}
const text = isSelected
? theme.fg("accent", "→ ") + theme.fg("accent", this.options[i])
: ` ${theme.fg("text", this.options[i])}`;
this.listContainer.addChild(new Text(text, 1, 0));
}
}
handleInput(keyData: string): void {
const kb = getEditorKeybindings();
// Up arrow or k
if (kb.matches(keyData, "selectUp") || keyData === "k") {
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
this.updateList();
}
// Down arrow or j
else if (kb.matches(keyData, "selectDown") || keyData === "j") {
} else if (kb.matches(keyData, "selectDown") || keyData === "j") {
this.selectedIndex = Math.min(this.options.length - 1, this.selectedIndex + 1);
this.updateList();
}
// Enter
else if (kb.matches(keyData, "selectConfirm") || keyData === "\n") {
} else if (kb.matches(keyData, "selectConfirm") || keyData === "\n") {
const selected = this.options[this.selectedIndex];
if (selected) {
this.onSelectCallback(selected);
}
}
// Escape or Ctrl+C
else if (kb.matches(keyData, "selectCancel")) {
if (selected) this.onSelectCallback(selected);
} else if (kb.matches(keyData, "selectCancel")) {
this.onCancelCallback();
}
}
dispose(): void {
this.countdown?.dispose();
}
}

View file

@ -32,6 +32,7 @@ import type {
ExtensionContext,
ExtensionRunner,
ExtensionUIContext,
ExtensionUIDialogOptions,
LoadedExtension,
} from "../../core/extensions/index.js";
import { KeybindingsManager } from "../../core/keybindings.js";
@ -764,7 +765,7 @@ export class InteractiveMode {
private showExtensionSelector(
title: string,
options: string[],
opts?: { signal?: AbortSignal },
opts?: ExtensionUIDialogOptions,
): Promise<string | undefined> {
return new Promise((resolve) => {
if (opts?.signal?.aborted) {
@ -791,6 +792,7 @@ export class InteractiveMode {
this.hideExtensionSelector();
resolve(undefined);
},
{ tui: this.ui, timeout: opts?.timeout },
);
this.editorContainer.clear();
@ -804,6 +806,7 @@ export class InteractiveMode {
* Hide the extension selector.
*/
private hideExtensionSelector(): void {
this.extensionSelector?.dispose();
this.editorContainer.clear();
this.editorContainer.addChild(this.editor);
this.extensionSelector = undefined;
@ -817,7 +820,7 @@ export class InteractiveMode {
private async showExtensionConfirm(
title: string,
message: string,
opts?: { signal?: AbortSignal },
opts?: ExtensionUIDialogOptions,
): Promise<boolean> {
const result = await this.showExtensionSelector(`${title}\n${message}`, ["Yes", "No"], opts);
return result === "Yes";
@ -829,7 +832,7 @@ export class InteractiveMode {
private showExtensionInput(
title: string,
placeholder?: string,
opts?: { signal?: AbortSignal },
opts?: ExtensionUIDialogOptions,
): Promise<string | undefined> {
return new Promise((resolve) => {
if (opts?.signal?.aborted) {
@ -856,6 +859,7 @@ export class InteractiveMode {
this.hideExtensionInput();
resolve(undefined);
},
{ tui: this.ui, timeout: opts?.timeout },
);
this.editorContainer.clear();
@ -869,6 +873,7 @@ export class InteractiveMode {
* Hide the extension input.
*/
private hideExtensionInput(): void {
this.extensionInput?.dispose();
this.editorContainer.clear();
this.editorContainer.addChild(this.editor);
this.extensionInput = undefined;

View file

@ -14,7 +14,7 @@
import * as crypto from "node:crypto";
import * as readline from "readline";
import type { AgentSession } from "../../core/agent-session.js";
import type { ExtensionUIContext } from "../../core/extensions/index.js";
import type { ExtensionUIContext, ExtensionUIDialogOptions } from "../../core/extensions/index.js";
import { theme } from "../interactive/theme/theme.js";
import type {
RpcCommand,
@ -63,99 +63,67 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
{ resolve: (value: any) => void; reject: (error: Error) => void }
>();
/** Helper for dialog methods with signal/timeout support */
function createDialogPromise<T>(
opts: ExtensionUIDialogOptions | undefined,
defaultValue: T,
request: Record<string, unknown>,
parseResponse: (response: RpcExtensionUIResponse) => T,
): Promise<T> {
if (opts?.signal?.aborted) return Promise.resolve(defaultValue);
const id = crypto.randomUUID();
return new Promise((resolve, reject) => {
let timeoutId: ReturnType<typeof setTimeout> | undefined;
const cleanup = () => {
if (timeoutId) clearTimeout(timeoutId);
opts?.signal?.removeEventListener("abort", onAbort);
pendingExtensionRequests.delete(id);
};
const onAbort = () => {
cleanup();
resolve(defaultValue);
};
opts?.signal?.addEventListener("abort", onAbort, { once: true });
if (opts?.timeout) {
timeoutId = setTimeout(() => {
cleanup();
resolve(defaultValue);
}, opts.timeout);
}
pendingExtensionRequests.set(id, {
resolve: (response: RpcExtensionUIResponse) => {
cleanup();
resolve(parseResponse(response));
},
reject,
});
output({ type: "extension_ui_request", id, ...request } as RpcExtensionUIRequest);
});
}
/**
* Create an extension UI context that uses the RPC protocol.
*/
const createExtensionUIContext = (): ExtensionUIContext => ({
async select(title: string, options: string[], opts?: { signal?: AbortSignal }): Promise<string | undefined> {
if (opts?.signal?.aborted) {
return undefined;
}
select: (title, options, opts) =>
createDialogPromise(opts, undefined, { method: "select", title, options, timeout: opts?.timeout }, (r) =>
"cancelled" in r && r.cancelled ? undefined : "value" in r ? r.value : undefined,
),
const id = crypto.randomUUID();
return new Promise((resolve, reject) => {
const onAbort = () => {
pendingExtensionRequests.delete(id);
resolve(undefined);
};
opts?.signal?.addEventListener("abort", onAbort, { once: true });
confirm: (title, message, opts) =>
createDialogPromise(opts, false, { method: "confirm", title, message, timeout: opts?.timeout }, (r) =>
"cancelled" in r && r.cancelled ? false : "confirmed" in r ? r.confirmed : false,
),
pendingExtensionRequests.set(id, {
resolve: (response: RpcExtensionUIResponse) => {
opts?.signal?.removeEventListener("abort", onAbort);
if ("cancelled" in response && response.cancelled) {
resolve(undefined);
} else if ("value" in response) {
resolve(response.value);
} else {
resolve(undefined);
}
},
reject,
});
output({ type: "extension_ui_request", id, method: "select", title, options } as RpcExtensionUIRequest);
});
},
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) {
resolve(response.confirmed);
} else {
resolve(false);
}
},
reject,
});
output({ type: "extension_ui_request", id, method: "confirm", title, message } as RpcExtensionUIRequest);
});
},
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) {
resolve(response.value);
} else {
resolve(undefined);
}
},
reject,
});
output({ type: "extension_ui_request", id, method: "input", title, placeholder } as RpcExtensionUIRequest);
});
},
input: (title, placeholder, opts) =>
createDialogPromise(opts, undefined, { method: "input", title, placeholder, timeout: opts?.timeout }, (r) =>
"cancelled" in r && r.cancelled ? undefined : "value" in r ? r.value : undefined,
),
notify(message: string, type?: "info" | "warning" | "error"): void {
// Fire and forget - no response needed

View file

@ -177,9 +177,16 @@ export type RpcResponse =
/** Emitted when an extension needs user input */
export type RpcExtensionUIRequest =
| { type: "extension_ui_request"; id: string; method: "select"; title: string; options: string[] }
| { type: "extension_ui_request"; id: string; method: "confirm"; title: string; message: string }
| { type: "extension_ui_request"; id: string; method: "input"; title: string; placeholder?: string }
| { type: "extension_ui_request"; id: string; method: "select"; title: string; options: string[]; timeout?: number }
| { type: "extension_ui_request"; id: string; method: "confirm"; title: string; message: string; timeout?: number }
| {
type: "extension_ui_request";
id: string;
method: "input";
title: string;
placeholder?: string;
timeout?: number;
}
| { type: "extension_ui_request"; id: string; method: "editor"; title: string; prefill?: string }
| {
type: "extension_ui_request";