Merge PR #4999: fix(coding-agent): restore ask tool timeout fallback

This commit is contained in:
can1357
2026-07-10 12:37:36 +02:00
6 changed files with 385 additions and 15 deletions
@@ -125,6 +125,10 @@ export interface ExtensionUIDialogOptions {
timeout?: number;
/** Invoked when the UI times out while waiting for a selection/input */
onTimeout?: () => void;
/** Invoked when the UI-managed timeout countdown starts */
onTimeoutStart?: () => void;
/** Invoked when user input resets a UI-managed timeout countdown */
onTimeoutReset?: () => void;
/** Initial cursor position for select dialogs (0-indexed) */
initialIndex?: number;
/** Render an outlined list for select dialogs */
@@ -176,6 +180,8 @@ export type AutocompleteProviderFactory = (current: AutocompleteProvider) => Aut
// and may be invoked from event handlers that have already taken the agent
// loop's lock — hooks intentionally cannot.
export interface ExtensionUIContext {
/** True when selector timeouts start only after the dialog is presented. */
timeoutStartsOnPresentation?: boolean;
/** Show a selector and return the selected label, even when an option also includes a description. */
select(
title: string,
@@ -61,6 +61,8 @@ export interface HookSelectorOptions {
tui?: TUI;
timeout?: number;
onTimeout?: () => void;
onTimeoutStart?: () => void;
onTimeoutReset?: () => void;
initialIndex?: number;
outline?: boolean;
maxVisible?: number;
@@ -178,6 +180,7 @@ export class HookSelectorComponent extends Container {
#onLeftCallback: (() => void) | undefined;
#onRightCallback: (() => void) | undefined;
#onExternalEditorCallback: (() => void) | undefined;
#onTimeoutResetCallback: (() => void) | undefined;
#slider: HookSelectorSlider | undefined;
#sliderIndex: number = 0;
#sliderComponent: Text | undefined;
@@ -213,6 +216,7 @@ export class HookSelectorComponent extends Container {
this.#onLeftCallback = opts?.onLeft;
this.#onRightCallback = opts?.onRight;
this.#onExternalEditorCallback = opts?.onExternalEditor;
this.#onTimeoutResetCallback = opts?.onTimeoutReset;
if (opts?.slider && opts.slider.segments.length > 0) {
this.#slider = opts.slider;
this.#sliderIndex = Math.max(0, Math.min(opts.slider.index, opts.slider.segments.length - 1));
@@ -232,6 +236,7 @@ export class HookSelectorComponent extends Container {
}
if (opts?.timeout && opts.timeout > 0 && opts.tui) {
opts.onTimeoutStart?.();
this.#countdown = new CountdownTimer(
opts.timeout,
opts.tui,
@@ -633,8 +638,10 @@ export class HookSelectorComponent extends Container {
}
handleInput(keyData: string): void {
// Reset countdown on any interaction
this.#countdown?.reset();
if (this.#countdown) {
this.#countdown.reset();
this.#onTimeoutResetCallback?.();
}
if (matchesSelectCancel(keyData)) {
this.#onCancelCallback();
@@ -61,6 +61,7 @@ export class ExtensionUiController {
async initHooksAndCustomTools(): Promise<void> {
// Create and set hook & tool UI context
const uiContext: ExtensionUIContext = {
timeoutStartsOnPresentation: true,
select: (title, options, dialogOptions) => this.showCollabAwareSelector(title, options, dialogOptions),
confirm: (title, message, _dialogOptions) => this.showHookConfirm(title, message),
input: (title, placeholder, dialogOptions) => this.showHookInput(title, placeholder, dialogOptions),
@@ -624,6 +625,8 @@ export class ExtensionUiController {
initialIndex: dialogOptions?.initialIndex,
timeout: dialogOptions?.timeout,
onTimeout: dialogOptions?.onTimeout,
onTimeoutStart: dialogOptions?.onTimeoutStart,
onTimeoutReset: dialogOptions?.onTimeoutReset,
tui: this.ctx.ui,
outline: dialogOptions?.outline,
disabledIndices: dialogOptions?.disabledIndices,
+51 -13
View File
@@ -380,6 +380,7 @@ interface AskSingleQuestionOptions {
}
interface UIContext {
timeoutStartsOnPresentation?: boolean;
select(
prompt: string,
options: ExtensionUISelectItem[],
@@ -389,6 +390,8 @@ interface UIContext {
signal?: AbortSignal;
outline?: boolean;
onTimeout?: () => void;
onTimeoutStart?: () => void;
onTimeoutReset?: () => void;
onLeft?: () => void;
onRight?: () => void;
helpText?: string;
@@ -432,12 +435,30 @@ async function askSingleQuestion(
const helpText = navigation
? "up/down navigate enter select ←/→ question esc cancel"
: "up/down navigate enter select esc cancel";
const timeoutMs = typeof timeout === "number" && timeout > 0 ? timeout : undefined;
const timeoutController = timeoutMs === undefined ? undefined : new AbortController();
const dialogSignal =
signal && timeoutController
? AbortSignal.any([signal, timeoutController.signal])
: (timeoutController?.signal ?? signal);
let timeoutId: NodeJS.Timeout | undefined;
let timeoutStartedMs = Date.now();
const armFallbackTimeout = (durationMs: number) => {
clearTimeout(timeoutId);
timeoutStartedMs = Date.now();
timeoutId = setTimeout(() => {
timeoutTriggered = true;
timeoutController?.abort();
}, durationMs);
};
const dialogOptions = {
initialIndex,
timeout,
signal,
signal: dialogSignal,
outline: true,
onTimeout,
onTimeoutStart: timeoutMs === undefined ? undefined : () => armFallbackTimeout(timeoutMs),
onTimeoutReset: timeoutMs === undefined ? undefined : () => armFallbackTimeout(timeoutMs),
helpText,
selectionMarker: marker?.selectionMarker,
checkedIndices: marker?.checkedIndices,
@@ -453,19 +474,32 @@ async function askSingleQuestion(
}
: undefined,
};
const startMs = Date.now();
const choice = signal
? await untilAborted(signal, () => ui.select(prompt, optionsToShow, dialogOptions))
: await ui.select(prompt, optionsToShow, dialogOptions);
if (!timeoutTriggered && choice === undefined && typeof timeout === "number") {
// Fallback for UI surfaces that enforce `timeout` without invoking
// `onTimeout`: their auto-cancel resolves right at the deadline. A
// cancel arriving well past the deadline is a deliberate user Esc on
// a surface that kept the dialog open — keep treating it as a cancel.
const elapsed = Date.now() - startMs;
timeoutTriggered = elapsed >= timeout && elapsed <= timeout + TIMEOUT_DETECTION_TOLERANCE_MS;
try {
const runSelect = () => {
const selection = ui.select(prompt, optionsToShow, dialogOptions);
if (timeoutMs !== undefined && !ui.timeoutStartsOnPresentation) {
armFallbackTimeout(timeoutMs);
}
return selection;
};
const choice = dialogSignal ? await untilAborted(dialogSignal, runSelect) : await runSelect();
if (!timeoutTriggered && choice === undefined && typeof timeout === "number") {
// Fallback for UI surfaces that enforce `timeout` without invoking
// `onTimeout`: their auto-cancel resolves right at the deadline. A
// cancel arriving well past the deadline is a deliberate user Esc on
// a surface that kept the dialog open — keep treating it as a cancel.
const elapsed = Date.now() - timeoutStartedMs;
timeoutTriggered = elapsed >= timeout && elapsed <= timeout + TIMEOUT_DETECTION_TOLERANCE_MS;
}
return { choice, timedOut: timeoutTriggered, navigation: navigationAction };
} catch (error) {
if (timeoutTriggered && error instanceof Error && error.name === "AbortError") {
return { choice: undefined, timedOut: true, navigation: navigationAction };
}
throw error;
} finally {
clearTimeout(timeoutId);
}
return { choice, timedOut: timeoutTriggered, navigation: navigationAction };
};
const promptForCustomInput = async (
@@ -614,6 +648,9 @@ async function askSingleQuestion(
customInput = undefined;
break;
}
if (timedOut && selectedOptions.length === 0 && customInput === undefined) {
selectedOptions = getAutoSelectionOnTimeout(questionOptions, recommended);
}
if (navigation?.allowForward) {
return { selectedOptions, customInput, timedOut, navigation: "forward" };
}
@@ -743,6 +780,7 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
const extensionUi = context.ui;
const ui: UIContext = {
timeoutStartsOnPresentation: extensionUi.timeoutStartsOnPresentation,
select: (prompt, options, dialogOptions) => extensionUi.select(prompt, options, dialogOptions),
editor: (title, prefill, dialogOptions, editorOptions) =>
extensionUi.editor(title, prefill, dialogOptions, editorOptions),