diff --git a/packages/coding-agent/src/modes/components/hook-selector.ts b/packages/coding-agent/src/modes/components/hook-selector.ts index eaa904c34..4ffcdd030 100644 --- a/packages/coding-agent/src/modes/components/hook-selector.ts +++ b/packages/coding-agent/src/modes/components/hook-selector.ts @@ -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(); + } + }, ); } diff --git a/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts b/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts index d5cb8d623..c2967ba13 100644 --- a/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts +++ b/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts @@ -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 { // 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 { + showHookSelector( + title: string, + options: string[], + dialogOptions?: ExtensionUIDialogOptions, + ): Promise { const { promise, resolve } = Promise.withResolvers(); 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(); diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index 506aec73a..dc72ad04d 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -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 { - return this.extensionUiController.showHookSelector(title, options, initialIndex); + showHookSelector( + title: string, + options: string[], + dialogOptions?: ExtensionUIDialogOptions, + ): Promise { + return this.extensionUiController.showHookSelector(title, options, dialogOptions); } hideHookSelector(): void { diff --git a/packages/coding-agent/src/modes/types.ts b/packages/coding-agent/src/modes/types.ts index 03fb07ce8..dae0a11c7 100644 --- a/packages/coding-agent/src/modes/types.ts +++ b/packages/coding-agent/src/modes/types.ts @@ -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; setHookWidget(key: string, content: unknown): void; setHookStatus(key: string, text: string | undefined): void; - showHookSelector(title: string, options: string[], initialIndex?: number): Promise; + showHookSelector( + title: string, + options: string[], + dialogOptions?: ExtensionUIDialogOptions, + ): Promise; hideHookSelector(): void; showHookInput(title: string, placeholder?: string): Promise; hideHookInput(): void; diff --git a/packages/coding-agent/src/prompts/tools/ask.md b/packages/coding-agent/src/prompts/tools/ask.md index 5e5ea0ff4..01ed320c4 100644 --- a/packages/coding-agent/src/prompts/tools/ask.md +++ b/packages/coding-agent/src/prompts/tools/ask.md @@ -10,7 +10,7 @@ Ask the user a question when you need clarification or input during task executi -- Place recommended option first with " (Recommended)" suffix +- Use `recommended: ` 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 @@ -37,12 +37,13 @@ If you can make a reasonable inference from the user's request, **do it**. Users 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 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} ] diff --git a/packages/coding-agent/src/tools/ask.ts b/packages/coding-agent/src/tools/ask.ts index 7c1668e33..124aac5d5 100644 --- a/packages/coding-agent/src/tools/ask.ts +++ b/packages/coding-agent/src/tools/ask.ts @@ -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: 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; + select( + prompt: string, + options: string[], + options_?: { initialIndex?: number; timeout?: number }, + ): Promise; input(prompt: string): Promise; } @@ -97,6 +123,7 @@ async function askSingleQuestion( question: string, optionLabels: string[], multi: boolean, + recommended?: number, ): Promise { 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 { q.question, optionLabels, q.multi ?? false, + q.recommended, ); results.push({ @@ -275,7 +309,13 @@ export class AskTool implements AgentTool { }; } - const { selectedOptions, customInput } = await askSingleQuestion(ui, question, optionLabels, multi); + const { selectedOptions, customInput } = await askSingleQuestion( + ui, + question, + optionLabels, + multi, + params.recommended, + ); const details: AskToolDetails = { question,