feat(coding-agent): add timeout option to extension dialogs with live countdown

Extension UI dialogs (select, confirm, input) now support a timeout option
that auto-dismisses with a live countdown display. Simpler alternative to
manually managing AbortSignal for timed dialogs.

Also adds ExtensionUIDialogOptions type export and updates RPC mode to
forward timeout to clients.
This commit is contained in:
Nico Bailon 2026-01-06 21:34:23 -08:00
parent fcb3b4aa72
commit 77477f6166
12 changed files with 262 additions and 191 deletions

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();
}
}