Merge PR #4999: fix(coding-agent): restore ask tool timeout fallback
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user