feat(coding-agent/modes): added timeout support to hook selector with auto-selection on timeout

- Added timeout support to hook selector with auto-selection behavior on timeout.
- Extended ExtensionUIDialogOptions to include timeout and tui configuration properties.
- Changed hook selector timeout behavior from cancellation to auto-selecting the currently highlighted option.
- Updated UIContext.select() method signature to accept timeout option for automated selection scenarios.
This commit is contained in:
can1357
2026-01-28 12:56:50 +01:00
parent 0471a3fde7
commit 88c46b9c88
6 changed files with 80 additions and 18 deletions
@@ -50,7 +50,15 @@ export class HookSelectorComponent extends Container {
opts.timeout,
opts.tui,
s => this.titleText.setText(theme.fg("accent", `${this.baseTitle} (${s}s)`)),
() => this.onCancelCallback(),
() => {
// Auto-select current option on timeout (typically the first/recommended option)
const selected = this.options[this.selectedIndex];
if (selected) {
this.onSelectCallback(selected);
} else {
this.onCancelCallback();
}
},
);
}
@@ -8,6 +8,7 @@ import type {
ExtensionContextActions,
ExtensionError,
ExtensionUIContext,
ExtensionUIDialogOptions,
} from "../../extensibility/extensions";
import { HookEditorComponent } from "../../modes/components/hook-editor";
import { HookInputComponent } from "../../modes/components/hook-input";
@@ -25,7 +26,7 @@ export class ExtensionUiController {
async initHooksAndCustomTools(): Promise<void> {
// Create and set hook & tool UI context
const uiContext: ExtensionUIContext = {
select: (title, options, dialogOptions) => this.showHookSelector(title, options, dialogOptions?.initialIndex),
select: (title, options, dialogOptions) => this.showHookSelector(title, options, dialogOptions),
confirm: (title, message, _dialogOptions) => this.showHookConfirm(title, message),
input: (title, placeholder, _dialogOptions) => this.showHookInput(title, placeholder),
notify: (message, type) => this.showHookNotify(message, type),
@@ -484,7 +485,11 @@ export class ExtensionUiController {
/**
* Show a selector for hooks.
*/
showHookSelector(title: string, options: string[], initialIndex?: number): Promise<string | undefined> {
showHookSelector(
title: string,
options: string[],
dialogOptions?: ExtensionUIDialogOptions,
): Promise<string | undefined> {
const { promise, resolve } = Promise.withResolvers<string | undefined>();
this.ctx.hookSelector = new HookSelectorComponent(
title,
@@ -497,7 +502,7 @@ export class ExtensionUiController {
this.hideHookSelector();
resolve(undefined);
},
{ initialIndex },
{ initialIndex: dialogOptions?.initialIndex, timeout: dialogOptions?.timeout, tui: this.ctx.ui },
);
this.ctx.editorContainer.clear();
@@ -20,7 +20,7 @@ import chalk from "chalk";
import { KeybindingsManager } from "../config/keybindings";
import { renderPromptTemplate } from "../config/prompt-templates";
import type { SettingsManager } from "../config/settings-manager";
import type { ExtensionUIContext } from "../extensibility/extensions";
import type { ExtensionUIContext, ExtensionUIDialogOptions } from "../extensibility/extensions";
import type { CompactOptions } from "../extensibility/extensions/types";
import { loadSlashCommands } from "../extensibility/slash-commands";
import { resolvePlanUrlToPath } from "../internal-urls";
@@ -1020,8 +1020,12 @@ export class InteractiveMode implements InteractiveModeContext {
this.extensionUiController.setHookStatus(key, text);
}
showHookSelector(title: string, options: string[], initialIndex?: number): Promise<string | undefined> {
return this.extensionUiController.showHookSelector(title, options, initialIndex);
showHookSelector(
title: string,
options: string[],
dialogOptions?: ExtensionUIDialogOptions,
): Promise<string | undefined> {
return this.extensionUiController.showHookSelector(title, options, dialogOptions);
}
hideHookSelector(): void {
+6 -2
View File
@@ -3,7 +3,7 @@ import type { AssistantMessage, ImageContent, Message, UsageReport } from "@oh-m
import type { Component, Container, Loader, Spacer, Text, TUI } from "@oh-my-pi/pi-tui";
import type { KeybindingsManager } from "../config/keybindings";
import type { SettingsManager } from "../config/settings-manager";
import type { ExtensionUIContext } from "../extensibility/extensions";
import type { ExtensionUIContext, ExtensionUIDialogOptions } from "../extensibility/extensions";
import type { CompactOptions } from "../extensibility/extensions/types";
import type { MCPManager } from "../mcp";
import type { AgentSession, AgentSessionEvent } from "../session/agent-session";
@@ -189,7 +189,11 @@ export interface InteractiveModeContext {
): Promise<void>;
setHookWidget(key: string, content: unknown): void;
setHookStatus(key: string, text: string | undefined): void;
showHookSelector(title: string, options: string[], initialIndex?: number): Promise<string | undefined>;
showHookSelector(
title: string,
options: string[],
dialogOptions?: ExtensionUIDialogOptions,
): Promise<string | undefined>;
hideHookSelector(): void;
showHookInput(title: string, placeholder?: string): Promise<string | undefined>;
hideHookInput(): void;
@@ -10,7 +10,7 @@ Ask the user a question when you need clarification or input during task executi
</conditions>
<instruction>
- Place recommended option first with " (Recommended)" suffix
- Use `recommended: <index>` to mark the default option (0-indexed); " (Recommended)" suffix is added automatically
- Use `questions` array for multiple related questions instead of asking one at a time
- Set `multi: true` on a question to allow multiple selections
</instruction>
@@ -37,12 +37,13 @@ If you can make a reasonable inference from the user's request, **do it**. Users
<example name="single">
question: "Which authentication method should this API use?"
options: [{"label": "JWT (Recommended)"}, {"label": "OAuth2"}, {"label": "Session cookies"}]
options: [{"label": "JWT"}, {"label": "OAuth2"}, {"label": "Session cookies"}]
recommended: 0
</example>
<example name="multi-part">
questions: [
{"id": "auth", "question": "Which auth method?", "options": [{"label": "JWT"}, {"label": "OAuth2"}]},
{"id": "auth", "question": "Which auth method?", "options": [{"label": "JWT"}, {"label": "OAuth2"}], "recommended": 0},
{"id": "cache", "question": "Enable caching?", "options": [{"label": "Yes"}, {"label": "No"}]},
{"id": "features", "question": "Which features to include?", "options": [{"label": "Logging"}, {"label": "Metrics"}, {"label": "Tracing"}], "multi": true}
]
+46 -6
View File
@@ -11,8 +11,8 @@
* Usage notes:
* - Users will always be able to select "Other" to provide custom text input
* - Use multi: true to allow multiple answers to be selected for a question
* - If you recommend a specific option, make that the first option in the list
* and add "(Recommended)" at the end of the label
* - Use recommended: <index> to mark the default option; "(Recommended)" suffix is added automatically
* - Questions time out after 30 seconds and auto-select the recommended option
*/
import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
import type { Component } from "@oh-my-pi/pi-tui";
@@ -39,12 +39,14 @@ const QuestionItem = Type.Object({
question: Type.String({ description: "Question text" }),
options: Type.Array(OptionItem, { description: "Available options" }),
multi: Type.Optional(Type.Boolean({ description: "Allow multiple selections" })),
recommended: Type.Optional(Type.Number({ description: "Index of recommended option (0-indexed)" })),
});
const askSchema = Type.Object({
question: Type.Optional(Type.String({ description: "Question to ask" })),
options: Type.Optional(Type.Array(OptionItem, { description: "Available options" })),
multi: Type.Optional(Type.Boolean({ description: "Allow multiple selections (default: false)" })),
recommended: Type.Optional(Type.Number({ description: "Index of recommended option (0-indexed, default: 0)" })),
questions: Type.Optional(Type.Array(QuestionItem, { description: "Multiple questions in sequence" })),
});
@@ -74,10 +76,30 @@ export interface AskToolDetails {
// =============================================================================
const OTHER_OPTION = "Other (type your own)";
const RECOMMENDED_SUFFIX = " (Recommended)";
function getDoneOptionLabel(): string {
return `${theme.status.success} Done selecting`;
}
/** Add "(Recommended)" suffix to the option at the given index if not already present */
function addRecommendedSuffix(labels: string[], recommendedIndex?: number): string[] {
if (recommendedIndex === undefined || recommendedIndex < 0 || recommendedIndex >= labels.length) {
return labels;
}
return labels.map((label, i) => {
if (i === recommendedIndex && !label.endsWith(RECOMMENDED_SUFFIX)) {
return label + RECOMMENDED_SUFFIX;
}
return label;
});
}
/** Strip "(Recommended)" suffix from a label */
function stripRecommendedSuffix(label: string): string {
return label.endsWith(RECOMMENDED_SUFFIX) ? label.slice(0, -RECOMMENDED_SUFFIX.length) : label;
}
// =============================================================================
// Question Selection Logic
// =============================================================================
@@ -88,7 +110,11 @@ interface SelectionResult {
}
interface UIContext {
select(prompt: string, options: string[], options_?: { initialIndex?: number }): Promise<string | undefined>;
select(
prompt: string,
options: string[],
options_?: { initialIndex?: number; timeout?: number },
): Promise<string | undefined>;
input(prompt: string): Promise<string | undefined>;
}
@@ -97,6 +123,7 @@ async function askSingleQuestion(
question: string,
optionLabels: string[],
multi: boolean,
recommended?: number,
): Promise<SelectionResult> {
const doneLabel = getDoneOptionLabel();
let selectedOptions: string[] = [];
@@ -155,12 +182,16 @@ async function askSingleQuestion(
}
selectedOptions = Array.from(selected);
} else {
const choice = await ui.select(question, [...optionLabels, OTHER_OPTION]);
const displayLabels = addRecommendedSuffix(optionLabels, recommended);
const choice = await ui.select(question, [...displayLabels, OTHER_OPTION], {
timeout: 30000,
initialIndex: recommended,
});
if (choice === OTHER_OPTION) {
const input = await ui.input("Enter your response:");
if (input) customInput = input;
} else if (choice) {
selectedOptions = [choice];
selectedOptions = [stripRecommendedSuffix(choice)];
}
}
@@ -187,11 +218,13 @@ interface AskParams {
question?: string;
options?: Array<{ label: string }>;
multi?: boolean;
recommended?: number;
questions?: Array<{
id: string;
question: string;
options: Array<{ label: string }>;
multi?: boolean;
recommended?: number;
}>;
}
@@ -243,6 +276,7 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
q.question,
optionLabels,
q.multi ?? false,
q.recommended,
);
results.push({
@@ -275,7 +309,13 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
};
}
const { selectedOptions, customInput } = await askSingleQuestion(ui, question, optionLabels, multi);
const { selectedOptions, customInput } = await askSingleQuestion(
ui,
question,
optionLabels,
multi,
params.recommended,
);
const details: AskToolDetails = {
question,