From 38a5c8a89335cdcaacf4289dacd834dd75194e5d Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Fri, 3 Jul 2026 07:57:19 +0900 Subject: [PATCH] feat(ask): add rich interactive dialog Adds the rich TUI ask dialog, additive schema fields, ask.enabled tool gating, note/preview/header support, chat redirect, and timeout behavior that defers while nested prompts are active instead of discarding user input. Op: extend --- .../src/config/settings-schema.ts | 11 + .../src/extensibility/extensions/types.ts | 37 + .../src/modes/components/ask-dialog.ts | 837 ++++++++++++++++++ .../controllers/extension-ui-controller.ts | 232 ++++- packages/coding-agent/src/tools/ask.ts | 244 ++++- packages/coding-agent/src/tools/index.ts | 1 + .../test/modes/components/ask-dialog.test.ts | 738 +++++++++++++++ .../modes/components/settings-layout.test.ts | 10 + packages/coding-agent/test/tools/ask.test.ts | 148 +++- .../coding-agent/test/tools/index.test.ts | 21 + 10 files changed, 2231 insertions(+), 48 deletions(-) create mode 100644 packages/coding-agent/src/modes/components/ask-dialog.ts create mode 100644 packages/coding-agent/test/modes/components/ask-dialog.test.ts diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index 2316034da..96bfcb058 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -3630,6 +3630,17 @@ export const SETTINGS_SCHEMA = { }, }, + "ask.enabled": { + type: "boolean", + default: true, + ui: { + tab: "tools", + group: "Available Tools", + label: "Ask", + description: "Enable the ask tool for interactive user questions", + }, + }, + "browser.enabled": { type: "boolean", default: true, diff --git a/packages/coding-agent/src/extensibility/extensions/types.ts b/packages/coding-agent/src/extensibility/extensions/types.ts index b93535142..03e3da10e 100644 --- a/packages/coding-agent/src/extensibility/extensions/types.ts +++ b/packages/coding-agent/src/extensibility/extensions/types.ts @@ -113,6 +113,37 @@ export interface ExtensionUISelectOption { export type ExtensionUISelectItem = string | ExtensionUISelectOption; +export interface ExtensionAskDialogOption { + label: string; + description?: string; + preview?: string; +} + +export interface ExtensionAskDialogQuestion { + id: string; + question: string; + header?: string; + options: ExtensionAskDialogOption[]; + multi?: boolean; + recommended?: number; +} + +export interface ExtensionAskDialogResultItem { + id: string; + question: string; + options: string[]; + multi: boolean; + selectedOptions: string[]; + customInput?: string; + note?: string; + timedOut?: boolean; +} + +export interface ExtensionAskDialogResult { + kind: "submit"; + results: ExtensionAskDialogResultItem[]; +} + export function getExtensionUISelectOptionLabel(option: ExtensionUISelectItem): string { return typeof option === "string" ? option : option.label; } @@ -186,6 +217,12 @@ export interface ExtensionUIContext { /** Show a text input dialog. */ input(title: string, placeholder?: string, dialogOptions?: ExtensionUIDialogOptions): Promise; + /** Show the rich ask dialog when the interactive TUI surface is available. */ + askDialog?( + questions: ExtensionAskDialogQuestion[], + dialogOptions?: ExtensionUIDialogOptions, + ): Promise; + /** Show a notification to the user. */ notify(message: string, type?: "info" | "warning" | "error"): void; diff --git a/packages/coding-agent/src/modes/components/ask-dialog.ts b/packages/coding-agent/src/modes/components/ask-dialog.ts new file mode 100644 index 000000000..de96dcef3 --- /dev/null +++ b/packages/coding-agent/src/modes/components/ask-dialog.ts @@ -0,0 +1,837 @@ +import { + type Component, + Ellipsis, + Markdown, + type MarkdownTheme, + matchesKey, + padding, + renderInlineMarkdown, + replaceTabs, + ScrollView, + type Tab, + TabBar, + Text, + type TUI, + truncateToWidth, + visibleWidth, + wrapTextWithAnsi, +} from "@oh-my-pi/pi-tui"; +import type { + ExtensionAskDialogQuestion, + ExtensionAskDialogResult, + ExtensionAskDialogResultItem, +} from "../../extensibility/extensions"; +import { getTabBarTheme } from "../shared"; +import { getMarkdownTheme, highlightCode, theme } from "../theme/theme"; +import { matchesSelectCancel, matchesSelectDown, matchesSelectUp } from "../utils/keybinding-matchers"; +import { CountdownTimer } from "./countdown-timer"; +import { bottomBorder, divider, row, topBorder } from "./overlay-box"; +import { handleTabSwitchKey } from "./selector-helpers"; + +const OTHER_OPTION = "Other (type your own)"; +const CHAT_ABOUT_THIS_OPTION = "Chat about this"; +const NEXT_OPTION = "Next →"; +const SUBMIT_OPTION = "Submit"; + +const MIN_BODY_ROWS = 5; +const PREVIEW_MIN_WIDTH = 40; +const SIDE_BY_SIDE_LIST_MIN_WIDTH = 30; +const SIDE_BY_SIDE_GAP_WIDTH = 3; +const MAX_HEADER_CHIP_WIDTH = 16; +const PREVIEW_HEADER = "Preview"; + +interface AskDialogCallbacks { + onSubmit(result: ExtensionAskDialogResult): void; + onCancel(): void; + onChat(): void; + onPrompt(title: string, prefill?: string): Promise; +} + +interface AskDialogOptions { + timeout?: number; + onTimeout?: () => void; + tui?: TUI; +} + +interface QuestionState { + selectedOptions: Set; + customInput: string | undefined; + note: string | undefined; + noteRowKey: string | undefined; + cursorIndex: number; + scrollOffset: number; + timedOut: boolean; +} + +type QuestionRowKind = "option" | "other" | "next" | "chat"; +type SubmitRowKind = "submit" | "chat"; + +interface QuestionRow { + kind: QuestionRowKind; + key: string; + label: string; + optionIndex: number | undefined; +} + +interface SubmitRow { + kind: SubmitRowKind; + key: string; + label: string; +} + +interface RenderedList { + lines: string[]; + scrollOffset: number; + indicator: string; +} + +interface PreviewSegment { + kind: "markdown" | "code"; + text: string; + language: string | undefined; +} + +function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(value, max)); +} + +function stripRecommendedSuffix(label: string): string { + const suffix = " (Recommended)"; + return label.endsWith(suffix) ? label.slice(0, -suffix.length) : label; +} + +function questionTabLabel(question: ExtensionAskDialogQuestion, index: number): string { + const base = question.header?.trim() || question.id || `Q${index + 1}`; + return truncateToWidth(replaceTabs(base), MAX_HEADER_CHIP_WIDTH, Ellipsis.Unicode); +} + +function renderQuestionTitle(question: ExtensionAskDialogQuestion, index: number, width: number): string[] { + const chip = question.header?.trim() ? theme.fg("accent", `[${questionTabLabel(question, index)}] `) : ""; + const mdTheme = getMarkdownTheme(); + const questionText = renderInlineMarkdown(replaceTabs(question.question), mdTheme, t => theme.fg("text", t)); + const titleWidth = Math.max(1, width - visibleWidth(chip)); + const wrapped = wrapTextWithAnsi(questionText, titleWidth); + if (wrapped.length === 0) return [chip.trimEnd()]; + return wrapped.map((line, lineIndex) => + lineIndex === 0 ? `${chip}${line}` : `${padding(visibleWidth(chip))}${line}`, + ); +} + +function splitPreviewSegments(preview: string): PreviewSegment[] { + const segments: PreviewSegment[] = []; + const markdownBuffer: string[] = []; + let fenceChar: string | undefined; + let fenceLength = 0; + let fenceLanguage: string | undefined; + let codeBuffer: string[] = []; + + const flushMarkdown = (): void => { + if (markdownBuffer.length === 0) return; + segments.push({ kind: "markdown", text: markdownBuffer.join("\n"), language: undefined }); + markdownBuffer.length = 0; + }; + const flushCode = (): void => { + segments.push({ kind: "code", text: codeBuffer.join("\n"), language: fenceLanguage }); + codeBuffer = []; + fenceChar = undefined; + fenceLength = 0; + fenceLanguage = undefined; + }; + + for (const line of replaceTabs(preview).split("\n")) { + const fenceMatch = /^(\s{0,3})(`{3,}|~{3,})(.*)$/.exec(line); + if (fenceChar !== undefined) { + if (fenceMatch) { + const marker = fenceMatch[2] ?? ""; + const info = fenceMatch[3]?.trim() ?? ""; + if (marker.startsWith(fenceChar) && marker.length >= fenceLength && info === "") { + flushCode(); + continue; + } + } + codeBuffer.push(line); + continue; + } + if (fenceMatch) { + flushMarkdown(); + const marker = fenceMatch[2] ?? ""; + fenceChar = marker[0]; + fenceLength = marker.length; + fenceLanguage = fenceMatch[3]?.trim().split(/\s+/, 1)[0] || undefined; + codeBuffer = []; + continue; + } + markdownBuffer.push(line); + } + + if (fenceChar !== undefined) { + segments.push({ kind: "code", text: codeBuffer.join("\n"), language: fenceLanguage }); + } else { + flushMarkdown(); + } + return segments; +} + +function renderPreviewContent(preview: string | undefined, width: number): string[] { + if (!preview?.trim()) return [theme.fg("muted", "No preview for this option.")]; + const out: string[] = []; + const mdTheme = getMarkdownTheme(); + const accentStyle = { color: (text: string) => theme.fg("muted", text) }; + for (const segment of splitPreviewSegments(preview)) { + if (segment.kind === "code") { + const highlighted = highlightCode(segment.text, segment.language); + const text = new Text(highlighted.join("\n"), 0, 0); + out.push(...text.render(Math.max(1, width))); + continue; + } + const markdown = new Markdown(segment.text, 0, 0, mdTheme, accentStyle); + out.push(...markdown.render(Math.max(1, width))); + } + return out.length > 0 ? out : [theme.fg("muted", "No preview for this option.")]; +} + +function normalizedInlineInput(input: string): string { + return replaceTabs(input).replace(/\s+/g, " ").trim(); +} + +function renderAnswerSummary(question: ExtensionAskDialogQuestion, state: QuestionState): string { + const selected = question.options.map(option => option.label).filter(label => state.selectedOptions.has(label)); + if (question.multi) { + const answers = [...selected]; + if (state.customInput !== undefined) answers.push(`Other: “${normalizedInlineInput(state.customInput)}”`); + return answers.length > 0 ? answers.join(", ") : theme.fg("warning", "unanswered"); + } + if (state.customInput !== undefined) return `“${normalizedInlineInput(state.customInput)}”`; + if (selected.length === 0) return theme.fg("warning", "unanswered"); + return selected[0] ?? theme.fg("warning", "unanswered"); +} + +function clearNote(state: QuestionState): void { + state.note = undefined; + state.noteRowKey = undefined; +} + +function clearNoteIfRow(state: QuestionState, rowKey: string): void { + if (state.noteRowKey === rowKey) clearNote(state); +} + +function clearNoteUnlessRow(state: QuestionState, rowKey: string): void { + if (state.noteRowKey !== undefined && state.noteRowKey !== rowKey) clearNote(state); +} + +function noteForSubmittedAnswer(question: ExtensionAskDialogQuestion, state: QuestionState): string | undefined { + if (state.note === undefined || state.noteRowKey === undefined) return undefined; + if (state.noteRowKey === "other") return state.customInput !== undefined ? state.note : undefined; + const match = /^option:(\d+)$/.exec(state.noteRowKey); + const optionIndex = match?.[1] === undefined ? Number.NaN : Number.parseInt(match[1], 10); + const option = Number.isInteger(optionIndex) ? question.options[optionIndex] : undefined; + return option && state.selectedOptions.has(option.label) ? state.note : undefined; +} + +function optionMarker(question: ExtensionAskDialogQuestion, checked: boolean): string { + if (question.multi) return checked ? theme.checkbox.checked : theme.checkbox.unchecked; + return checked ? theme.radio.selected : theme.radio.unselected; +} + +function renderRowLabel( + rowItem: QuestionRow, + question: ExtensionAskDialogQuestion, + state: QuestionState, + selected: boolean, + mdTheme: MarkdownTheme, + width: number, +): string[] { + const isOption = rowItem.kind === "option"; + const isOther = rowItem.kind === "other"; + const checked = isOption + ? state.selectedOptions.has(stripRecommendedSuffix(rowItem.label)) + : isOther && state.customInput !== undefined; + const color = selected ? "accent" : checked ? "toolOutput" : "text"; + const marker = + isOption || isOther ? `${theme.fg(checked ? "success" : "dim", optionMarker(question, checked))} ` : " "; + const cursor = selected ? theme.fg("accent", `${theme.nav.cursor} `) : " "; + const label = renderInlineMarkdown(rowItem.label, mdTheme, t => theme.fg(color, t)); + const noteMarker = state.note && state.noteRowKey === rowItem.key ? theme.fg("success", " ✎ note") : ""; + const firstLine = `${cursor}${marker}${label}${noteMarker}`; + const lines = [truncateToWidth(firstLine, width, Ellipsis.Unicode)]; + if (rowItem.kind === "option") { + const option = question.options[rowItem.optionIndex ?? -1]; + if (option?.description?.trim()) { + const description = renderInlineMarkdown(option.description.trim(), mdTheme, t => theme.fg("muted", t)); + const wrapped = wrapTextWithAnsi(description, Math.max(1, width - 6)); + for (const line of wrapped.slice(0, 2)) { + lines.push(` ${truncateToWidth(line, Math.max(1, width - 6), Ellipsis.Unicode)}`); + } + } + } + if (isOther && state.customInput !== undefined) { + const preview = replaceTabs(state.customInput).replace(/\s+/g, " ").trim(); + lines.push(theme.fg("muted", ` ${truncateToWidth(preview, Math.max(1, width - 6), Ellipsis.Unicode)}`)); + } + return lines; +} + +export class AskDialogComponent implements Component { + #states: QuestionState[]; + #activeTabIndex = 0; + #submitCursorIndex = 0; + #submitScrollOffset = 0; + #remainingSeconds: number | undefined; + #countdown: CountdownTimer | undefined; + #promptActive = false; + #timeoutExpired = false; + #closed = false; + #tabBar: TabBar | undefined; + + constructor( + private readonly questions: ExtensionAskDialogQuestion[], + private readonly callbacks: AskDialogCallbacks, + private readonly options: AskDialogOptions = {}, + ) { + this.#states = questions.map(question => { + const recommended = Number.isInteger(question.recommended) ? question.recommended : 0; + const maxIndex = Math.max(0, question.options.length - 1); + return { + selectedOptions: new Set(), + customInput: undefined, + note: undefined, + noteRowKey: undefined, + cursorIndex: clamp(recommended ?? 0, 0, maxIndex), + scrollOffset: 0, + timedOut: false, + }; + }); + if (options.timeout && options.timeout > 0) { + this.#countdown = new CountdownTimer( + options.timeout, + options.tui, + seconds => { + this.#remainingSeconds = seconds; + }, + () => this.#handleTimeout(), + ); + } + } + + invalidate(): void { + this.#tabBar?.invalidate(); + } + + dispose(): void { + this.#closed = true; + this.#countdown?.dispose(); + } + + handleInput(keyData: string): void { + if (this.#closed || this.#promptActive) return; + if (matchesSelectCancel(keyData)) { + this.#finishCancel(); + return; + } + if (this.#hasSubmitTab() && handleTabSwitchKey(keyData, direction => this.#switchTab(direction))) { + this.#requestRender(); + return; + } + if (this.#isSubmitTab()) { + this.#handleSubmitTabInput(keyData); + return; + } + this.#handleQuestionInput(keyData); + } + + render(width: number): readonly string[] { + const height = Math.max(12, process.stdout.rows || 40); + const innerWidth = Math.max(1, width - 4); + const headerLines = this.#renderHeader(innerWidth); + const fixedRows = 1 + headerLines.length + 1 + 1 + 1; + const bodyRows = Math.max(MIN_BODY_ROWS, height - fixedRows); + const bodyLines = this.#isSubmitTab() + ? this.#renderSubmitBody(innerWidth, bodyRows) + : this.#renderQuestionBody(innerWidth, bodyRows); + const footer = this.#footerHintText(bodyLines.indicator); + return [ + topBorder(width, this.#titleText()), + ...headerLines.map(line => row(line, width)), + divider(width), + ...bodyLines.lines.map(line => row(line, width)), + divider(width), + row(theme.fg("dim", footer), width), + bottomBorder(width), + ]; + } + + #titleText(): string { + return this.#remainingSeconds === undefined ? "Ask" : `Ask (${this.#remainingSeconds}s)`; + } + + #hasSubmitTab(): boolean { + return this.questions.length > 1; + } + + #submitTabIndex(): number { + return this.questions.length; + } + + #isSubmitTab(): boolean { + return this.#hasSubmitTab() && this.#activeTabIndex === this.#submitTabIndex(); + } + + #currentQuestionIndex(): number { + return clamp(this.#activeTabIndex, 0, Math.max(0, this.questions.length - 1)); + } + + #requestRender(): void { + this.options.tui?.requestRender(); + } + + #renderHeader(width: number): string[] { + const lines: string[] = []; + if (this.#hasSubmitTab()) { + const tabs: Tab[] = [ + ...this.questions.map((question, index) => ({ + id: String(index), + label: questionTabLabel(question, index), + })), + { id: "submit", label: "Submit" }, + ]; + this.#tabBar = new TabBar("", tabs, getTabBarTheme(), this.#activeTabIndex); + this.#tabBar.showHint = false; + lines.push(...this.#tabBar.render(width)); + } + if (this.#isSubmitTab()) { + lines.push(theme.bold(theme.fg("accent", "Review answers"))); + return lines; + } + const questionIndex = this.#currentQuestionIndex(); + const question = this.questions[questionIndex]; + if (!question) return lines; + if (lines.length > 0) lines.push(""); + lines.push(...renderQuestionTitle(question, questionIndex, width)); + return lines; + } + + #footerHintText(indicator: string): string { + const scroll = indicator ? ` ${indicator} scroll ·` : ""; + if (this.#isSubmitTab()) { + return `Enter submit · ↑/↓ move ·${scroll} Esc cancel`; + } + const question = this.questions[this.#currentQuestionIndex()]; + const action = question?.multi ? "Space/Enter toggle · n note · Next → continue" : "Enter select · n note"; + const tabs = this.#hasSubmitTab() ? " · Tab/←/→ tabs" : ""; + return `${action} · ↑/↓ move${tabs} ·${scroll} Esc cancel`; + } + + #questionRows(question: ExtensionAskDialogQuestion): QuestionRow[] { + const rows: QuestionRow[] = question.options.map((option, index) => ({ + kind: "option", + key: `option:${index}`, + label: this.#optionLabel(question, option.label, index), + optionIndex: index, + })); + rows.push({ kind: "other", key: "other", label: OTHER_OPTION, optionIndex: undefined }); + if (question.multi) rows.push({ kind: "next", key: "next", label: NEXT_OPTION, optionIndex: undefined }); + rows.push({ kind: "chat", key: "chat", label: CHAT_ABOUT_THIS_OPTION, optionIndex: undefined }); + return rows; + } + + #optionLabel(question: ExtensionAskDialogQuestion, label: string, index: number): string { + return question.recommended === index ? `${label} (Recommended)` : label; + } + + #activeQuestionState(): { question: ExtensionAskDialogQuestion; state: QuestionState } | undefined { + const question = this.questions[this.#currentQuestionIndex()]; + const state = this.#states[this.#currentQuestionIndex()]; + if (!question || !state) return undefined; + return { question, state }; + } + + #handleQuestionInput(keyData: string): void { + const active = this.#activeQuestionState(); + if (!active) return; + const { question, state } = active; + const rows = this.#questionRows(question); + if (matchesSelectUp(keyData)) { + state.cursorIndex = clamp(state.cursorIndex - 1, 0, Math.max(0, rows.length - 1)); + this.#requestRender(); + return; + } + if (matchesSelectDown(keyData)) { + state.cursorIndex = clamp(state.cursorIndex + 1, 0, Math.max(0, rows.length - 1)); + this.#requestRender(); + return; + } + const rowItem = rows[state.cursorIndex]; + if (!rowItem) return; + if (keyData === "n" || keyData === "N") { + if (rowItem.kind === "option" || rowItem.kind === "other") { + void this.#promptForNote(question, state, rowItem); + } + return; + } + const isEnter = matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n"; + const isSpace = matchesKey(keyData, "space") || keyData === " "; + if (!isEnter && !(question.multi && isSpace)) return; + if (rowItem.kind === "chat") { + this.#finishChat(); + return; + } + if (rowItem.kind === "next") { + this.#advanceAfterQuestion(); + return; + } + if (rowItem.kind === "other") { + void this.#promptForCustomInput(question, state, rowItem); + return; + } + if (rowItem.kind === "option") { + const option = question.options[rowItem.optionIndex ?? -1]; + if (!option) return; + if (question.multi) { + if (state.selectedOptions.has(option.label)) { + state.selectedOptions.delete(option.label); + clearNoteIfRow(state, rowItem.key); + } else { + state.selectedOptions.add(option.label); + } + this.#requestRender(); + return; + } + state.selectedOptions = new Set([option.label]); + state.customInput = undefined; + clearNoteUnlessRow(state, rowItem.key); + this.#advanceAfterQuestion(); + } + } + + #handleSubmitTabInput(keyData: string): void { + const rows = this.#submitRows(); + if (matchesSelectUp(keyData)) { + this.#submitCursorIndex = clamp(this.#submitCursorIndex - 1, 0, Math.max(0, rows.length - 1)); + this.#requestRender(); + return; + } + if (matchesSelectDown(keyData)) { + this.#submitCursorIndex = clamp(this.#submitCursorIndex + 1, 0, Math.max(0, rows.length - 1)); + this.#requestRender(); + return; + } + const isEnter = matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n"; + if (!isEnter) return; + const rowItem = rows[this.#submitCursorIndex]; + if (rowItem?.kind === "chat") { + this.#finishChat(); + return; + } + this.#finishSubmit(); + } + + #switchTab(direction: 1 | -1): void { + const tabCount = this.questions.length + 1; + this.#activeTabIndex = (this.#activeTabIndex + direction + tabCount) % tabCount; + this.#submitCursorIndex = 0; + } + + #advanceAfterQuestion(): void { + const current = this.#currentQuestionIndex(); + if (this.questions.length === 1) { + this.#finishSubmit(); + return; + } + this.#activeTabIndex = current + 1 < this.questions.length ? current + 1 : this.#submitTabIndex(); + this.#submitCursorIndex = 0; + this.#requestRender(); + } + + async #promptForCustomInput( + question: ExtensionAskDialogQuestion, + state: QuestionState, + rowItem: QuestionRow, + ): Promise { + this.#promptActive = true; + try { + const input = await this.callbacks.onPrompt(`Custom answer: ${question.question}`, state.customInput); + if (input === undefined || this.#closed) return; + state.customInput = input; + if (!question.multi) { + state.selectedOptions.clear(); + clearNoteUnlessRow(state, rowItem.key); + } + this.#advanceAfterQuestion(); + } finally { + this.#promptActive = false; + this.#runDeferredTimeout(); + this.#requestRender(); + } + } + + async #promptForNote( + question: ExtensionAskDialogQuestion, + state: QuestionState, + rowItem: QuestionRow, + ): Promise { + this.#promptActive = true; + try { + const input = await this.callbacks.onPrompt(`Note for ${rowItem.label}: ${question.question}`, state.note); + if (input === undefined || this.#closed) return; + state.note = input; + state.noteRowKey = rowItem.key; + } finally { + this.#promptActive = false; + this.#runDeferredTimeout(); + this.#requestRender(); + } + } + + #renderQuestionBody(width: number, rows: number): RenderedList { + const active = this.#activeQuestionState(); + if (!active) return { lines: Array.from({ length: rows }, () => ""), scrollOffset: 0, indicator: "" }; + const { question, state } = active; + const rowItems = this.#questionRows(question); + state.cursorIndex = clamp(state.cursorIndex, 0, Math.max(0, rowItems.length - 1)); + const selectedRow = rowItems[state.cursorIndex]; + const preview = + selectedRow?.kind === "option" ? question.options[selectedRow.optionIndex ?? -1]?.preview : undefined; + const sideBySide = width >= SIDE_BY_SIDE_LIST_MIN_WIDTH + PREVIEW_MIN_WIDTH + SIDE_BY_SIDE_GAP_WIDTH; + if (sideBySide) { + const previewWidth = Math.max(PREVIEW_MIN_WIDTH, Math.floor(width * 0.45)); + const listWidth = Math.max(1, width - previewWidth - SIDE_BY_SIDE_GAP_WIDTH); + const list = this.#renderQuestionList(question, state, rowItems, listWidth, rows); + const previewLines = this.#renderPreviewPane(preview, previewWidth, rows); + const lines: string[] = []; + for (let index = 0; index < rows; index++) { + const left = truncateToWidth(list.lines[index] ?? "", listWidth, Ellipsis.Unicode); + const right = truncateToWidth(previewLines[index] ?? "", previewWidth, Ellipsis.Unicode); + const gap = padding(Math.max(1, listWidth - visibleWidth(left)) + 1); + lines.push(`${left}${gap}${theme.fg("border", "│")} ${right}`); + } + return { lines, scrollOffset: list.scrollOffset, indicator: list.indicator }; + } + const previewRows = Math.max(3, Math.min(8, Math.floor(rows * 0.4))); + const listRows = Math.max(3, rows - previewRows - 1); + const list = this.#renderQuestionList(question, state, rowItems, width, listRows); + const previewLines = this.#renderPreviewPane(preview, width, previewRows); + const lines = [...list.lines, theme.fg("border", "─".repeat(Math.max(1, width))), ...previewLines]; + while (lines.length < rows) lines.push(""); + return { lines: lines.slice(0, rows), scrollOffset: list.scrollOffset, indicator: list.indicator }; + } + + #renderQuestionList( + question: ExtensionAskDialogQuestion, + state: QuestionState, + rowItems: QuestionRow[], + width: number, + rows: number, + ): RenderedList { + const mdTheme = getMarkdownTheme(); + const allLines: string[] = []; + const lineStartByRow: number[] = []; + for (let index = 0; index < rowItems.length; index++) { + lineStartByRow.push(allLines.length); + const rowItem = rowItems[index]; + if (!rowItem) continue; + allLines.push(...renderRowLabel(rowItem, question, state, index === state.cursorIndex, mdTheme, width)); + } + const cursorStart = lineStartByRow[state.cursorIndex] ?? 0; + state.scrollOffset = this.#scrollOffsetForCursor(state.scrollOffset, cursorStart, rows, allLines.length); + const scrollView = new ScrollView(allLines, { + height: rows, + scrollbar: "auto", + totalRows: allLines.length, + theme: { track: t => theme.fg("muted", t), thumb: t => theme.fg("accent", t) }, + }); + scrollView.setScrollOffset(state.scrollOffset); + const rendered = scrollView.render(width); + const lines = [...rendered]; + while (lines.length < rows) lines.push(""); + return { + lines: lines.slice(0, rows), + scrollOffset: state.scrollOffset, + indicator: this.#clipIndicator(state.scrollOffset, rows, allLines.length), + }; + } + + #renderPreviewPane(preview: string | undefined, width: number, rows: number): string[] { + const bodyWidth = Math.max(1, width - 2); + const out = [theme.fg("dim", PREVIEW_HEADER)]; + const contentRows = Math.max(0, rows - 1); + const content = renderPreviewContent(preview, bodyWidth); + const hidden = Math.max(0, content.length - contentRows); + const visibleCount = hidden > 0 ? Math.max(0, contentRows - 1) : Math.min(contentRows, content.length); + for (let index = 0; index < visibleCount; index++) out.push(content[index] ?? ""); + if (hidden > 0) out.push(theme.fg("dim", `… ${hidden + 1} more lines`)); + while (out.length < rows) out.push(""); + return out.slice(0, rows); + } + + #renderSubmitBody(width: number, rows: number): RenderedList { + const allLines: string[] = []; + const unanswered = this.#unansweredCount(); + if (unanswered > 0) { + allLines.push( + theme.fg( + "warning", + `${unanswered} unanswered question${unanswered === 1 ? "" : "s"}; Enter still submits.`, + ), + ); + allLines.push(""); + } + for (let index = 0; index < this.questions.length; index++) { + const question = this.questions[index]; + const state = this.#states[index]; + if (!question || !state) continue; + const label = questionTabLabel(question, index); + const answer = renderAnswerSummary(question, state); + allLines.push(`${theme.fg("dim", `${index + 1}. ${label}:`)} ${answer}`); + const submittedNote = noteForSubmittedAnswer(question, state); + if (submittedNote?.trim()) { + const note = normalizedInlineInput(submittedNote); + allLines.push( + theme.fg("muted", ` Note: ${truncateToWidth(note, Math.max(1, width - 9), Ellipsis.Unicode)}`), + ); + } + } + allLines.push(""); + const rowStart = allLines.length; + const submitRows = this.#submitRows(); + for (let index = 0; index < submitRows.length; index++) { + const rowItem = submitRows[index]; + if (!rowItem) continue; + const cursor = index === this.#submitCursorIndex ? theme.fg("accent", `${theme.nav.cursor} `) : " "; + const color = index === this.#submitCursorIndex ? "accent" : "text"; + allLines.push(`${cursor}${theme.fg(color, rowItem.label)}`); + } + const cursorStart = rowStart + this.#submitCursorIndex; + this.#submitScrollOffset = this.#scrollOffsetForCursor( + this.#submitScrollOffset, + cursorStart, + rows, + allLines.length, + ); + const scrollView = new ScrollView(allLines, { + height: rows, + scrollbar: "auto", + totalRows: allLines.length, + theme: { track: t => theme.fg("muted", t), thumb: t => theme.fg("accent", t) }, + }); + scrollView.setScrollOffset(this.#submitScrollOffset); + const rendered = scrollView.render(width); + const lines = [...rendered]; + while (lines.length < rows) lines.push(""); + return { + lines: lines.slice(0, rows), + scrollOffset: this.#submitScrollOffset, + indicator: this.#clipIndicator(this.#submitScrollOffset, rows, allLines.length), + }; + } + + #submitRows(): SubmitRow[] { + return [ + { kind: "submit", key: "submit", label: SUBMIT_OPTION }, + { kind: "chat", key: "chat", label: CHAT_ABOUT_THIS_OPTION }, + ]; + } + + #scrollOffsetForCursor(currentOffset: number, cursorLine: number, rows: number, totalRows: number): number { + if (totalRows <= rows) return 0; + let nextOffset = clamp(currentOffset, 0, Math.max(0, totalRows - rows)); + if (cursorLine < nextOffset) nextOffset = cursorLine; + if (cursorLine >= nextOffset + rows) nextOffset = cursorLine - rows + 1; + return clamp(nextOffset, 0, Math.max(0, totalRows - rows)); + } + + #clipIndicator(offset: number, rows: number, totalRows: number): string { + const above = offset > 0; + const below = offset + rows < totalRows; + if (above && below) return "↕"; + if (above) return "↑"; + if (below) return "↓"; + return ""; + } + + #unansweredCount(): number { + let count = 0; + for (let index = 0; index < this.questions.length; index++) { + const question = this.questions[index]; + const state = this.#states[index]; + if (!question || !state) continue; + if (state.selectedOptions.size === 0 && state.customInput === undefined) count += 1; + } + return count; + } + + #handleTimeout(): void { + if (this.#closed) return; + if (this.#promptActive) { + this.#timeoutExpired = true; + return; + } + this.options.onTimeout?.(); + for (let index = 0; index < this.questions.length; index++) { + const question = this.questions[index]; + const state = this.#states[index]; + if (!question || !state) continue; + if (state.selectedOptions.size === 0 && state.customInput === undefined) { + const noteMatch = /^option:(\d+)$/.exec(state.noteRowKey ?? ""); + const notedIndex = noteMatch ? Number.parseInt(noteMatch[1], 10) : Number.NaN; + const fallbackIndex = + Number.isInteger(notedIndex) && question.options[notedIndex] + ? notedIndex + : clamp(question.recommended ?? 0, 0, Math.max(0, question.options.length - 1)); + const fallback = question.options[fallbackIndex]; + if (fallback) state.selectedOptions.add(fallback.label); + state.timedOut = true; + } + } + this.#finishSubmit(); + } + + #runDeferredTimeout(): void { + if (!this.#timeoutExpired) return; + this.#timeoutExpired = false; + this.#handleTimeout(); + } + + #finishSubmit(): void { + if (this.#closed) return; + this.#closed = true; + this.#countdown?.dispose(); + this.callbacks.onSubmit({ kind: "submit", results: this.#buildResults() }); + } + + #finishCancel(): void { + if (this.#closed) return; + this.#closed = true; + this.#countdown?.dispose(); + this.callbacks.onCancel(); + } + + #finishChat(): void { + if (this.#closed) return; + this.#closed = true; + this.#countdown?.dispose(); + this.callbacks.onChat(); + } + + #buildResults(): ExtensionAskDialogResultItem[] { + const results: ExtensionAskDialogResultItem[] = []; + for (let index = 0; index < this.questions.length; index++) { + const question = this.questions[index]; + const state = this.#states[index]; + if (!question || !state) continue; + const selectedOptions = question.options + .map(option => option.label) + .filter(label => state.selectedOptions.has(label)); + results.push({ + id: question.id, + question: question.question, + options: question.options.map(option => option.label), + multi: question.multi ?? false, + selectedOptions, + customInput: state.customInput, + note: noteForSubmittedAnswer(question, state), + timedOut: state.timedOut || undefined, + }); + } + return results; + } +} 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 1c9172544..3fd1b5723 100644 --- a/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts +++ b/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts @@ -5,6 +5,9 @@ import { KeybindingsManager } from "../../config/keybindings"; import type { CompactOptions, ExtensionActions, + ExtensionAskDialogQuestion, + ExtensionAskDialogResult, + ExtensionAskDialogResultItem, ExtensionCommandContextActions, ExtensionContextActions, ExtensionError, @@ -19,6 +22,7 @@ import type { } from "../../extensibility/extensions"; import { getSessionSlashCommands } from "../../extensibility/extensions/get-commands-handler"; import { createExtensionModelQuery } from "../../extensibility/extensions/model-api"; +import { AskDialogComponent } from "../../modes/components/ask-dialog"; import { HookEditorComponent } from "../../modes/components/hook-editor"; import { HookInputComponent } from "../../modes/components/hook-input"; import { HookSelectorComponent, type HookSelectorSlider } from "../../modes/components/hook-selector"; @@ -28,12 +32,20 @@ import { USER_INTERRUPT_LABEL } from "../../session/messages"; import { setSessionTerminalTitle, setTerminalTitle } from "../../utils/title-generator"; const MAX_WIDGET_LINES = 10; +const ASK_OTHER_OPTION = "Other (type your own)"; +const ASK_CHAT_OPTION = "Chat about this"; +const ASK_NEXT_OPTION = "Next →"; interface CollabDialogWinner { source: "local" | "remote"; value: string | undefined; } +interface CollabAskDialogWinner { + source: "local" | "remote"; + value: ExtensionAskDialogResult | undefined; +} + function toWireSelectOptions(options: ExtensionUISelectItem[]): CollabUiSelectItem[] { return options.map(option => typeof option === "string" @@ -64,6 +76,7 @@ export class ExtensionUiController { 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), + askDialog: (questions, dialogOptions) => this.showAskDialog(questions, dialogOptions), notify: (message, type) => this.showHookNotify(message, type), onTerminalInput: handler => this.addExtensionTerminalInputListener(handler), setStatus: (key, text) => this.setHookStatus(key, text), @@ -579,6 +592,107 @@ export class ExtensionUiController { ); } + async showAskDialog( + questions: ExtensionAskDialogQuestion[], + dialogOptions?: ExtensionUIDialogOptions, + ): Promise { + const host = this.ctx.collabHost; + if (!host) return this.#showLocalAskDialog(questions, dialogOptions); + const localAbort = new AbortController(); + const remoteAbort = new AbortController(); + const parentSignal = dialogOptions?.signal; + const localSignal = parentSignal ? AbortSignal.any([parentSignal, localAbort.signal]) : localAbort.signal; + const remoteSignal = parentSignal ? AbortSignal.any([parentSignal, remoteAbort.signal]) : remoteAbort.signal; + const localWinner = this.#showLocalAskDialog(questions, { ...dialogOptions, signal: localSignal }).then( + (value): CollabAskDialogWinner => ({ source: "local", value }), + ); + const remoteWinner: Promise = this.#runGuestAskDialog(questions, remoteSignal).then( + result => (result === "unavailable" ? localWinner : { source: "remote", value: result }), + ); + const winner = await Promise.race([localWinner, remoteWinner]); + if (winner.source === "remote") localAbort.abort(); + else remoteAbort.abort(); + return winner.value; + } + + #showLocalAskDialog( + questions: ExtensionAskDialogQuestion[], + dialogOptions?: ExtensionUIDialogOptions, + ): Promise { + return this.#presentDialog(dialogOptions?.signal, settle => { + let askDialog: AskDialogComponent | undefined; + let promptEditor: HookEditorComponent | undefined; + let promptResolve: ((value: string | undefined) => void) | undefined; + let closed = false; + + const restoreAskDialog = (): void => { + if (closed || !askDialog) return; + this.ctx.editorContainer.clear(); + this.ctx.editorContainer.addChild(askDialog); + this.ctx.ui.setFocus(askDialog); + this.ctx.ui.requestRender(); + }; + + const finishPrompt = (value: string | undefined): void => { + const resolvePrompt = promptResolve; + promptResolve = undefined; + promptEditor = undefined; + restoreAskDialog(); + resolvePrompt?.(value); + }; + + const promptForText = (title: string, prefill?: string): Promise => { + if (closed) return Promise.resolve(undefined); + const { promise, resolve } = Promise.withResolvers(); + promptResolve = resolve; + promptEditor = new HookEditorComponent( + this.ctx.ui, + title, + prefill, + value => finishPrompt(value), + () => finishPrompt(undefined), + { promptStyle: true }, + ); + this.ctx.editorContainer.clear(); + this.ctx.editorContainer.addChild(promptEditor); + this.ctx.ui.setFocus(promptEditor); + this.ctx.ui.requestRender(); + return promise; + }; + + askDialog = new AskDialogComponent( + questions, + { + onSubmit: result => settle(result), + onCancel: () => settle(undefined), + onChat: () => settle(undefined), + onPrompt: promptForText, + }, + { + timeout: dialogOptions?.timeout, + onTimeout: dialogOptions?.onTimeout, + tui: this.ctx.ui, + }, + ); + this.ctx.editorContainer.clear(); + this.ctx.editorContainer.addChild(askDialog); + this.ctx.ui.setFocus(askDialog); + this.ctx.ui.requestRender(); + + return () => { + closed = true; + askDialog?.dispose(); + promptResolve?.(undefined); + promptResolve = undefined; + promptEditor = undefined; + this.ctx.editorContainer.clear(); + this.ctx.editorContainer.addChild(this.ctx.editor); + this.ctx.ui.setFocus(this.ctx.editor); + this.ctx.ui.requestRender(); + }; + }); + } + /** * Race the local hook dialog against a mirrored guest ask. First *answer* * wins and cancels the other side. A remote `unavailable` settlement @@ -612,6 +726,114 @@ export class ExtensionUiController { return winner.value; } + async #runGuestAskDialog( + questions: ExtensionAskDialogQuestion[], + signal: AbortSignal, + ): Promise { + const results: ExtensionAskDialogResultItem[] = []; + for (const question of questions) { + const result = await this.#runGuestAskQuestion(question, signal); + if (result === "unavailable" || result === undefined) return result; + results.push(result); + } + return { kind: "submit", results }; + } + + async #runGuestAskQuestion( + question: ExtensionAskDialogQuestion, + signal: AbortSignal, + ): Promise { + const selected = new Set(); + let customInput: string | undefined; + const baseOptions: CollabUiSelectItem[] = question.options.map(option => + option.description?.trim() ? { label: option.label, description: option.description.trim() } : option.label, + ); + if (question.multi) { + while (true) { + const checkedIndices = question.options + .map((option, index) => (selected.has(option.label) ? index : -1)) + .filter(index => index >= 0); + const choice = await this.#requestGuestUiString( + { + kind: "select", + title: question.question, + options: [...baseOptions, ASK_OTHER_OPTION, ASK_NEXT_OPTION, ASK_CHAT_OPTION], + selectionMarker: "checkbox", + checkedIndices, + markableCount: question.options.length, + helpText: "up/down navigate enter toggle Next → continue esc cancel", + }, + signal, + ); + if (choice === "unavailable" || choice === undefined) return choice; + if (choice === ASK_CHAT_OPTION) return undefined; + if (choice === ASK_NEXT_OPTION) break; + if (choice === ASK_OTHER_OPTION) { + const input = await this.#requestGuestUiString( + { kind: "editor", title: `Custom answer: ${question.question}` }, + signal, + ); + if (input === "unavailable" || input === undefined) return input; + customInput = input; + break; + } + if (selected.has(choice)) selected.delete(choice); + else selected.add(choice); + } + } else { + const recommended = + typeof question.recommended === "number" && Number.isInteger(question.recommended) + ? question.recommended + : 0; + const initialIndex = Math.max(0, Math.min(recommended, Math.max(0, question.options.length - 1))); + const choice = await this.#requestGuestUiString( + { + kind: "select", + title: question.question, + options: [...baseOptions, ASK_OTHER_OPTION, ASK_CHAT_OPTION], + initialIndex, + selectionMarker: "radio", + markableCount: question.options.length, + helpText: "up/down navigate enter select esc cancel", + }, + signal, + ); + if (choice === "unavailable" || choice === undefined) return choice; + if (choice === ASK_CHAT_OPTION) return undefined; + if (choice === ASK_OTHER_OPTION) { + const input = await this.#requestGuestUiString( + { kind: "editor", title: `Custom answer: ${question.question}` }, + signal, + ); + if (input === "unavailable" || input === undefined) return input; + customInput = input; + } else { + selected.add(choice); + } + } + return { + id: question.id, + question: question.question, + options: question.options.map(option => option.label), + multi: question.multi ?? false, + selectedOptions: question.options.map(option => option.label).filter(label => selected.has(label)), + customInput, + }; + } + + async #requestGuestUiString( + request: CollabUiRequestDraft, + signal: AbortSignal, + ): Promise { + const host = this.ctx.collabHost; + if (!host) return "unavailable"; + const remote = host.requestGuestUi(request, signal); + if (!remote) return "unavailable"; + const result = await remote; + if (result.kind === "unavailable") return "unavailable"; + return typeof result.value === "string" ? result.value : undefined; + } + /** * Show a selector for hooks. */ @@ -914,11 +1136,11 @@ export class ExtensionUiController { * the current dialog and hands the surface to the next queued request. A request * whose signal aborts before its turn resolves `undefined` and is never shown. */ - #presentDialog( + #presentDialog( signal: AbortSignal | undefined, - present: (settle: (value: string | undefined) => void) => () => void, - ): Promise { - const { promise, resolve, reject } = Promise.withResolvers(); + present: (settle: (value: T | undefined) => void) => () => void, + ): Promise { + const { promise, resolve, reject } = Promise.withResolvers(); let settled = false; let started = false; let hide: (() => void) | undefined; @@ -927,7 +1149,7 @@ export class ExtensionUiController { settle(undefined); } - const settle = (value: string | undefined): void => { + const settle = (value: T | undefined): void => { if (settled) return; settled = true; signal?.removeEventListener("abort", onAbort); diff --git a/packages/coding-agent/src/tools/ask.ts b/packages/coding-agent/src/tools/ask.ts index 42de7f930..d6663962c 100644 --- a/packages/coding-agent/src/tools/ask.ts +++ b/packages/coding-agent/src/tools/ask.ts @@ -23,6 +23,7 @@ import { Markdown, type MarkdownTheme, renderInlineMarkdown, + replaceTabs, TERMINAL, Text, truncateToWidth, @@ -44,17 +45,34 @@ import { ToolAbortError } from "./tool-errors"; // Types // ============================================================================= +const OTHER_OPTION = "Other (type your own)"; +const CHAT_ABOUT_THIS_OPTION = "Chat about this"; +const NEXT_OPTION = "Next →"; +const RESERVED_OPTION_LABELS: Record = { + [OTHER_OPTION]: true, + [CHAT_ABOUT_THIS_OPTION]: true, + [NEXT_OPTION]: true, +}; + const OptionItem = arkType({ label: arkType("string").describe("display label"), "description?": arkType("string").describe("optional explanatory text displayed below the label"), + "preview?": arkType("string").describe("optional rich preview content for interactive ask dialogs"), }); const QuestionItem = arkType({ id: arkType("string").describe("question id"), question: arkType("string").describe("question text"), + "header?": arkType("string").describe("optional short display chip for rich ask dialogs"), options: OptionItem.array().describe("available options"), "multi?": arkType("boolean").describe("allow multiple selections"), "recommended?": arkType("number").describe("recommended option index"), +}).narrow((question, ctx) => { + const reserved = question.options.find(option => RESERVED_OPTION_LABELS[option.label] === true); + return ( + reserved === undefined || + ctx.mustBe(`defined with option labels that do not collide with reserved runtime labels: ${reserved.label}`) + ); }); const askSchema = arkType({ @@ -71,6 +89,8 @@ export interface QuestionResult { multi: boolean; selectedOptions: string[]; customInput?: string; + /** Optional note attached to the selected answer in the rich ask dialog. */ + note?: string; /** True when the answer was auto-selected because the dialog timed out. */ timedOut?: boolean; } @@ -81,6 +101,8 @@ export interface AskToolDetails { multi?: boolean; selectedOptions?: string[]; customInput?: string; + /** Optional note attached to the selected answer in the rich ask dialog. */ + note?: string; /** True when the answer was auto-selected because the dialog timed out. */ timedOut?: boolean; /** Multi-part question mode */ @@ -108,7 +130,6 @@ function toSelectOption(option: AskOption, label = option.label): ExtensionUISel // Constants // ============================================================================= -const OTHER_OPTION = "Other (type your own)"; const RECOMMENDED_SUFFIX = " (Recommended)"; // Window after the timeout deadline within which an `undefined` selection is // attributed to a UI-enforced timeout (for surfaces that close the dialog at @@ -361,6 +382,7 @@ function formatCustomInputTitle( interface SelectionResult { selectedOptions: string[]; customInput?: string; + note?: string; timedOut: boolean; navigation?: "back" | "forward"; cancelled?: boolean; @@ -375,7 +397,7 @@ interface AskSingleQuestionOptions { recommended?: number; timeout?: number; signal?: AbortSignal; - initialSelection?: Pick; + initialSelection?: Pick; navigation?: NavigationControls; } @@ -416,6 +438,7 @@ async function askSingleQuestion( const doneLabel = getDoneOptionLabel(); let selectedOptions = [...(initialSelection?.selectedOptions ?? [])]; let customInput = initialSelection?.customInput; + const note = initialSelection?.note; let timedOut = false; const selectOption = async ( @@ -513,14 +536,14 @@ async function askSingleQuestion( }); if (arrowNavigation) { - return { selectedOptions: Array.from(selected), customInput, timedOut, navigation: arrowNavigation }; + return { selectedOptions: Array.from(selected), customInput, note, timedOut, navigation: arrowNavigation }; } if (choice === undefined) { if (selectTimedOut) { timedOut = true; break; } - return { selectedOptions: Array.from(selected), customInput, timedOut, cancelled: true }; + return { selectedOptions: Array.from(selected), customInput, note, timedOut, cancelled: true }; } if (choice === doneLabel) break; @@ -587,11 +610,11 @@ async function askSingleQuestion( timedOut = selectTimedOut; if (arrowNavigation) { - return { selectedOptions, customInput, timedOut, navigation: arrowNavigation }; + return { selectedOptions, customInput, note, timedOut, navigation: arrowNavigation }; } if (choice === undefined) { if (!timedOut) { - return { selectedOptions, customInput, timedOut, cancelled: true }; + return { selectedOptions, customInput, note, timedOut, cancelled: true }; } break; } @@ -615,7 +638,7 @@ async function askSingleQuestion( break; } if (navigation?.allowForward) { - return { selectedOptions, customInput, timedOut, navigation: "forward" }; + return { selectedOptions, customInput, note, timedOut, navigation: "forward" }; } } @@ -623,20 +646,58 @@ async function askSingleQuestion( selectedOptions = getAutoSelectionOnTimeout(questionOptions, recommended); } - return { selectedOptions, customInput, timedOut }; + return { selectedOptions, customInput, note, timedOut }; } function formatQuestionResult(result: QuestionResult): string { + const noteSuffix = result.note ? ` (note: ${result.note})` : ""; if (result.customInput !== undefined) { - return `${result.id}: "${result.customInput}"`; + return `${result.id}: "${result.customInput}"${noteSuffix}`; } if (result.selectedOptions.length > 0) { - const suffix = result.timedOut ? " (auto-selected after timeout)" : ""; + const suffix = `${result.timedOut ? " (auto-selected after timeout)" : ""}${noteSuffix}`; return result.multi ? `${result.id}: [${result.selectedOptions.join(", ")}]${suffix}` : `${result.id}: ${result.selectedOptions[0]}${suffix}`; } - return `${result.id}: (cancelled)`; + return `${result.id}: (cancelled)${noteSuffix}`; +} + +function formatSingleQuestionResponse(result: { + selectedOptions: string[]; + customInput?: string; + note?: string; + timedOut?: boolean; + multi: boolean; +}): string { + const responseParts: string[] = []; + if (result.selectedOptions.length > 0) { + const selectedText = result.multi + ? `User selected: ${result.selectedOptions.join(", ")}` + : `User selected: ${result.selectedOptions[0]}`; + responseParts.push(result.timedOut ? `${selectedText} (auto-selected after timeout)` : selectedText); + } + if (result.customInput !== undefined) { + responseParts.push( + result.customInput.includes("\n") + ? `User provided custom input:\n${result.customInput + .split("\n") + .map(line => ` ${line}`) + .join("\n")}` + : `User provided custom input: ${result.customInput}`, + ); + } + if (result.note) { + responseParts.push( + result.note.includes("\n") + ? `User added note:\n${result.note + .split("\n") + .map(line => ` ${line}`) + .join("\n")}` + : `User added note: ${result.note}`, + ); + } + return responseParts.length > 0 ? responseParts.join("\n") : "User cancelled the selection"; } // ============================================================================= @@ -772,6 +833,83 @@ export class AskTool implements AgentTool { vocalizer.speak(params.questions.map(q => q.question).join("\n")); } + const richAskDialog = extensionUi.askDialog; + if (richAskDialog) { + try { + const showRichDialog = () => + richAskDialog( + params.questions.map(q => ({ + id: q.id, + question: q.question, + ...(q.header?.trim() ? { header: q.header } : {}), + options: q.options.map(option => ({ + label: option.label, + ...(option.description?.trim() ? { description: option.description.trim() } : {}), + ...(option.preview?.trim() ? { preview: option.preview } : {}), + })), + ...(q.multi !== undefined ? { multi: q.multi } : {}), + ...(q.recommended !== undefined ? { recommended: q.recommended } : {}), + })), + { timeout: timeout ?? undefined, signal }, + ); + const richResult = signal ? await untilAborted(signal, showRichDialog) : await showRichDialog(); + if (!richResult) { + context.abort(); + throw new ToolAbortError("Ask tool was cancelled by the user"); + } + if (richResult.results.length !== params.questions.length) { + throw new Error("Ask dialog returned a result count that does not match the requested questions"); + } + const results: QuestionResult[] = []; + for (let index = 0; index < params.questions.length; index++) { + const question = params.questions[index]; + const result = richResult.results[index]; + if (!question || !result || result.id !== question.id) { + throw new Error("Ask dialog returned results that do not match the requested question order"); + } + results.push({ + id: question.id, + question: question.question, + options: question.options.map(option => option.label), + multi: question.multi ?? false, + selectedOptions: result.selectedOptions, + customInput: result.customInput, + note: result.note, + timedOut: result.timedOut, + }); + } + if (params.questions.length === 1) { + const result = results[0]; + if ( + !result || + (!result.timedOut && result.selectedOptions.length === 0 && result.customInput === undefined) + ) { + context.abort(); + throw new ToolAbortError("Ask tool was cancelled by the user"); + } + const details: AskToolDetails = { + question: result.question, + options: result.options, + multi: result.multi, + selectedOptions: result.selectedOptions, + customInput: result.customInput, + note: result.note, + timedOut: result.timedOut, + }; + const responseText = formatSingleQuestionResponse(result); + return { content: [{ type: "text" as const, text: responseText }], details }; + } + const details: AskToolDetails = { results }; + const responseText = `User answers:\n${results.map(formatQuestionResult).join("\n")}`; + return { content: [{ type: "text" as const, text: responseText }], details }; + } catch (error) { + if (error instanceof Error && error.name === "AbortError") { + throw new ToolAbortError("Ask input was cancelled"); + } + throw error; + } + } + const askQuestion = async ( q: AskParams["questions"][number], options?: { previous?: QuestionResult; navigation?: NavigationControls }, @@ -782,7 +920,7 @@ export class AskTool implements AgentTool { })); const optionLabels = questionOptions.map(getAskOptionLabel); try { - const { selectedOptions, customInput, navigation, cancelled, timedOut } = await askSingleQuestion( + const { selectedOptions, customInput, note, navigation, cancelled, timedOut } = await askSingleQuestion( ui, q.question, questionOptions, @@ -795,7 +933,7 @@ export class AskTool implements AgentTool { navigation: options?.navigation, }, ); - return { optionLabels, selectedOptions, customInput, navigation, cancelled, timedOut }; + return { optionLabels, selectedOptions, customInput, note, navigation, cancelled, timedOut }; } catch (error) { if (error instanceof Error && error.name === "AbortError") { throw new ToolAbortError("Ask input was cancelled"); @@ -806,7 +944,7 @@ export class AskTool implements AgentTool { if (params.questions.length === 1) { const [q] = params.questions; - const { optionLabels, selectedOptions, customInput, cancelled, timedOut } = await askQuestion(q); + const { optionLabels, selectedOptions, customInput, note, cancelled, timedOut } = await askQuestion(q); if (!timedOut && (cancelled || (selectedOptions.length === 0 && customInput === undefined))) { context.abort(); @@ -818,27 +956,17 @@ export class AskTool implements AgentTool { multi: q.multi ?? false, selectedOptions, customInput, + note, timedOut: timedOut || undefined, }; - const responseParts: string[] = []; - if (selectedOptions.length > 0) { - const selectedText = q.multi - ? `User selected: ${selectedOptions.join(", ")}` - : `User selected: ${selectedOptions[0]}`; - responseParts.push(timedOut ? `${selectedText} (auto-selected after timeout)` : selectedText); - } - if (customInput !== undefined) { - responseParts.push( - customInput.includes("\n") - ? `User provided custom input:\n${customInput - .split("\n") - .map(line => ` ${line}`) - .join("\n")}` - : `User provided custom input: ${customInput}`, - ); - } - const responseText = responseParts.length > 0 ? responseParts.join("\n") : "User cancelled the selection"; + const responseText = formatSingleQuestionResponse({ + selectedOptions, + customInput, + note, + timedOut: timedOut || undefined, + multi: q.multi ?? false, + }); return { content: [{ type: "text" as const, text: responseText }], details }; } @@ -846,7 +974,8 @@ export class AskTool implements AgentTool { const resultsByIndex: Array = Array.from({ length: params.questions.length }); let questionIndex = 0; while (questionIndex < params.questions.length) { - const q = params.questions[questionIndex]!; + const q = params.questions[questionIndex]; + if (!q) throw new Error("Ask question index exceeded the requested question list"); const previous = resultsByIndex[questionIndex]; const navigation: NavigationControls = { allowBack: questionIndex > 0, @@ -857,6 +986,7 @@ export class AskTool implements AgentTool { optionLabels, selectedOptions, customInput, + note, navigation: navAction, cancelled, timedOut, @@ -874,6 +1004,7 @@ export class AskTool implements AgentTool { multi: q.multi ?? false, selectedOptions, customInput, + note, timedOut: timedOut || undefined, }; @@ -885,9 +1016,9 @@ export class AskTool implements AgentTool { questionIndex += 1; } - const results = resultsByIndex.map((result, index) => { + const results = params.questions.map((q, index) => { + const result = resultsByIndex[index]; if (result) return result; - const q = params.questions[index]!; return { id: q.id, question: q.question, @@ -986,6 +1117,21 @@ function renderCustomInputLines(uiTheme: Theme, customInput: string): string[] { return out; } +/** Render an answer note with tab replacement and line-width clamping. */ +function renderNoteLines(uiTheme: Theme, note: string, width: number): string[] { + const prefix = " Note: "; + const continuationPrefix = " "; + const firstLineWidth = Math.max(1, width - visibleWidth(prefix)); + const continuationWidth = Math.max(1, width - visibleWidth(continuationPrefix)); + return replaceTabs(note) + .split("\n") + .map((line, index) => { + const linePrefix = index === 0 ? `${uiTheme.fg("dim", " Note:")} ` : continuationPrefix; + const maxWidth = index === 0 ? firstLineWidth : continuationWidth; + return `${linePrefix}${uiTheme.fg("toolOutput", truncateToWidth(line, maxWidth))}`; + }); +} + /** * Marker glyph for a question option. Single-choice questions render circular radio * buttons (pick one); multi-select questions render rectangular checkboxes (pick many). @@ -1026,6 +1172,8 @@ function renderAnswerOptionLines( selectedOptions: string[] | undefined, multi: boolean | undefined, customInput: string | undefined, + note: string | undefined, + width: number, ): string[] { const selected = new Set(selectedOptions ?? []); // Prefer the full recorded option set; fall back to the selected labels when @@ -1033,7 +1181,7 @@ function renderAnswerOptionLines( const list = options && options.length > 0 ? options : (selectedOptions ?? []); // Nothing was chosen (and no custom answer) → a lone cancelled marker. - if (selected.size === 0 && customInput === undefined) { + if (selected.size === 0 && customInput === undefined && note === undefined) { return [` ${uiTheme.styledSymbol("status.warning", "warning")} ${uiTheme.fg("warning", "Cancelled")}`]; } @@ -1048,6 +1196,7 @@ function renderAnswerOptionLines( out.push(` ${markerStyled} ${labelStyled}`); } if (customInput !== undefined) out.push(...renderCustomInputLines(uiTheme, customInput)); + if (note !== undefined) out.push(...renderNoteLines(uiTheme, note, width)); return out; } @@ -1141,7 +1290,10 @@ export const askToolRenderer = { if (details.results && details.results.length > 0) { const results = details.results; const hasAnySelection = results.some( - r => r.customInput !== undefined || (r.selectedOptions && r.selectedOptions.length > 0), + r => + r.customInput !== undefined || + r.note !== undefined || + (r.selectedOptions && r.selectedOptions.length > 0), ); const header = renderStatusLine( { @@ -1156,7 +1308,16 @@ export const askToolRenderer = { // md() returns a shared cached array (module-level Markdown LRU) — copy before appending. const lines = [ ...md(r.question, width), - ...renderAnswerOptionLines(uiTheme, mdTheme, r.options, r.selectedOptions, r.multi, r.customInput), + ...renderAnswerOptionLines( + uiTheme, + mdTheme, + r.options, + r.selectedOptions, + r.multi, + r.customInput, + r.note, + width, + ), ]; return { label: uiTheme.fg("dim", `[${r.id}]`), lines }; }); @@ -1179,7 +1340,9 @@ export const askToolRenderer = { const question = details.question; const hasSelection = - details.customInput !== undefined || (details.selectedOptions && details.selectedOptions.length > 0); + details.customInput !== undefined || + details.note !== undefined || + (details.selectedOptions && details.selectedOptions.length > 0); const header = renderStatusLine( hasSelection ? { iconOverride: uiTheme.styledSymbol("tool.ask", "accent"), title: "Ask" } @@ -1190,12 +1353,13 @@ export const askToolRenderer = { const dSelected = details.selectedOptions; const dMulti = details.multi; const dCustom = details.customInput; + const dNote = details.note; const dTimedOut = details.timedOut; return framedBlock(uiTheme, width => { // md() returns a shared cached array (module-level Markdown LRU) — copy before appending. const bodyLines = [ ...md(question, width), - ...renderAnswerOptionLines(uiTheme, mdTheme, dOptions, dSelected, dMulti, dCustom), + ...renderAnswerOptionLines(uiTheme, mdTheme, dOptions, dSelected, dMulti, dCustom, dNote, width), ]; if (dTimedOut) { // Distinguish auto-selection from a real user choice in the transcript. diff --git a/packages/coding-agent/src/tools/index.ts b/packages/coding-agent/src/tools/index.ts index 0cb1d4b3e..fb856e835 100644 --- a/packages/coding-agent/src/tools/index.ts +++ b/packages/coding-agent/src/tools/index.ts @@ -612,6 +612,7 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P if (name === "web_search") return session.settings.get("web_search.enabled"); // search_tool_bm25 is allowed when either legacy mcp.discoveryMode or new tools.discoveryMode is active. if (name === "search_tool_bm25") return discoveryActive; + if (name === "ask") return session.settings.get("ask.enabled"); if (name === "browser") return session.settings.get("browser.enabled"); if (name === "checkpoint" || name === "rewind") return session.settings.get("checkpoint.enabled"); if (name === "irc") return isIrcEnabled(session.settings, session.taskDepth ?? 0); diff --git a/packages/coding-agent/test/modes/components/ask-dialog.test.ts b/packages/coding-agent/test/modes/components/ask-dialog.test.ts new file mode 100644 index 000000000..f08b67268 --- /dev/null +++ b/packages/coding-agent/test/modes/components/ask-dialog.test.ts @@ -0,0 +1,738 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test"; +import { stripVTControlCharacters } from "node:util"; +import { KeybindingsManager } from "@oh-my-pi/pi-coding-agent/config/keybindings"; +import type { ExtensionAskDialogQuestion } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/types"; +import { AskDialogComponent } from "@oh-my-pi/pi-coding-agent/modes/components/ask-dialog"; +import { getThemeByName, setThemeInstance } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; +import { setKeybindings } from "@oh-my-pi/pi-tui"; + +const DOWN = "\x1b[B"; +const ENTER = "\n"; +const CANCEL = "\x07"; +const SPACE = " "; +const TAB = "\t"; +const SHIFT_TAB = "\x1b[Z"; + +let darkTheme = await getThemeByName("dark"); + +function render(component: AskDialogComponent): string { + return stripVTControlCharacters(component.render(80).join("\n")); +} + +describe("AskDialogComponent", () => { + beforeAll(async () => { + darkTheme = await getThemeByName("dark"); + if (!darkTheme) throw new Error("Failed to load dark theme"); + }); + + beforeEach(() => { + setThemeInstance(darkTheme!); + setKeybindings(KeybindingsManager.inMemory({ "tui.select.cancel": "ctrl+g" })); + }); + + afterEach(() => { + setKeybindings(KeybindingsManager.inMemory()); + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it("single-question, single-select: Enter on option submits immediately", () => { + const onSubmit = vi.fn(); + const onCancel = vi.fn(); + const onChat = vi.fn(); + const onPrompt = vi.fn(); + + const questions: ExtensionAskDialogQuestion[] = [ + { + id: "q1", + question: "Choose one?", + options: [{ label: "Option A" }, { label: "Option B" }], + }, + ]; + + const component = new AskDialogComponent(questions, { + onSubmit, + onCancel, + onChat, + onPrompt, + }); + + component.handleInput(ENTER); + + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit.mock.calls[0][0]).toEqual({ + kind: "submit", + results: [ + { + id: "q1", + question: "Choose one?", + options: ["Option A", "Option B"], + multi: false, + selectedOptions: ["Option A"], + customInput: undefined, + note: undefined, + timedOut: undefined, + }, + ], + }); + }); + + it("single-question, single-select: DOWN then Enter selects second option and submits", () => { + const onSubmit = vi.fn(); + const onCancel = vi.fn(); + const onChat = vi.fn(); + const onPrompt = vi.fn(); + + const questions: ExtensionAskDialogQuestion[] = [ + { + id: "q1", + question: "Choose one?", + options: [{ label: "Option A" }, { label: "Option B" }], + }, + ]; + + const component = new AskDialogComponent(questions, { + onSubmit, + onCancel, + onChat, + onPrompt, + }); + + component.handleInput(DOWN); + component.handleInput(ENTER); + + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit.mock.calls[0][0].results[0].selectedOptions).toEqual(["Option B"]); + }); + + it("multi-question, single-select: Enter on option advances tab, does not submit", () => { + const onSubmit = vi.fn(); + const onCancel = vi.fn(); + const onChat = vi.fn(); + const onPrompt = vi.fn(); + + const questions: ExtensionAskDialogQuestion[] = [ + { + id: "q1", + question: "Q1?", + options: [{ label: "A1" }, { label: "B1" }], + }, + { + id: "q2", + question: "Q2?", + options: [{ label: "A2" }, { label: "B2" }], + }, + ]; + + const component = new AskDialogComponent(questions, { + onSubmit, + onCancel, + onChat, + onPrompt, + }); + + // Press Enter on A1 - should advance tab to Q2 (tab 1), not submit + component.handleInput(ENTER); + expect(onSubmit).not.toHaveBeenCalled(); + + // On Q2: Down to B2 and Enter - should advance tab to Submit (tab 2), not submit + component.handleInput(DOWN); + component.handleInput(ENTER); + expect(onSubmit).not.toHaveBeenCalled(); + + // On Submit tab: Enter on Submit row - should submit + component.handleInput(ENTER); + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit.mock.calls[0][0].results).toEqual([ + { + id: "q1", + question: "Q1?", + options: ["A1", "B1"], + multi: false, + selectedOptions: ["A1"], + customInput: undefined, + note: undefined, + timedOut: undefined, + }, + { + id: "q2", + question: "Q2?", + options: ["A2", "B2"], + multi: false, + selectedOptions: ["B2"], + customInput: undefined, + note: undefined, + timedOut: undefined, + }, + ]); + }); + + it("multi-select: Space and Enter toggle without advancing, Next row advances", () => { + const onSubmit = vi.fn(); + const onCancel = vi.fn(); + const onChat = vi.fn(); + const onPrompt = vi.fn(); + + const questions: ExtensionAskDialogQuestion[] = [ + { + id: "q1", + question: "Choose multiple?", + options: [{ label: "Option A" }, { label: "Option B" }], + multi: true, + }, + ]; + + const component = new AskDialogComponent(questions, { + onSubmit, + onCancel, + onChat, + onPrompt, + }); + + // Space on Option A - toggles A + component.handleInput(SPACE); + + // Down to Option B, Enter - toggles B + component.handleInput(DOWN); + component.handleInput(ENTER); + + expect(onSubmit).not.toHaveBeenCalled(); + + // Down to Other + component.handleInput(DOWN); + // Down to Next + component.handleInput(DOWN); + // Enter on Next to submit + component.handleInput(ENTER); + + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit.mock.calls[0][0].results[0].selectedOptions).toEqual(["Option A", "Option B"]); + }); + + it("tab-state persistence: answer question 0, Tab forward, Tab back, answer still present", () => { + const onSubmit = vi.fn(); + const onCancel = vi.fn(); + const onChat = vi.fn(); + const onPrompt = vi.fn(); + + const questions: ExtensionAskDialogQuestion[] = [ + { + id: "q1", + question: "Q1?", + options: [{ label: "A1" }, { label: "B1" }], + }, + { + id: "q2", + question: "Q2?", + options: [{ label: "A2" }, { label: "B2" }], + }, + ]; + + const component = new AskDialogComponent(questions, { + onSubmit, + onCancel, + onChat, + onPrompt, + }); + + // Enter on A1 selects it and auto-advances to Q2 (tab 1) + component.handleInput(ENTER); + + // Shift+Tab back to Q1 (tab 0) + component.handleInput(SHIFT_TAB); + + // Enter again on Q1's currently selected option (which will re-select/keep it and auto-advance to Q2) + component.handleInput(ENTER); + + // On Q2: select B2 and advance to Submit + component.handleInput(DOWN); + component.handleInput(ENTER); + + // On Submit: Enter to submit + component.handleInput(ENTER); + + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit.mock.calls[0][0].results[0].selectedOptions).toEqual(["A1"]); + expect(onSubmit.mock.calls[0][0].results[1].selectedOptions).toEqual(["B2"]); + }); + + it("Tab and Shift+Tab switches tabs", () => { + const onSubmit = vi.fn(); + const questions: ExtensionAskDialogQuestion[] = [ + { + id: "q1", + question: "Q1?", + options: [{ label: "A1" }, { label: "B1" }], + }, + { + id: "q2", + question: "Q2?", + options: [{ label: "A2" }, { label: "B2" }], + }, + ]; + + const component = new AskDialogComponent(questions, { + onSubmit, + onCancel: vi.fn(), + onChat: vi.fn(), + onPrompt: vi.fn(), + }); + + // Tab from Q1 -> Q2 + component.handleInput(TAB); + // Tab from Q2 -> Submit + component.handleInput(TAB); + // Shift+Tab from Submit -> Q2 + component.handleInput(SHIFT_TAB); + + // Down to B2, Enter -> Submit + component.handleInput(DOWN); + component.handleInput(ENTER); + + // Enter on Submit + component.handleInput(ENTER); + + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit.mock.calls[0][0].results[0].selectedOptions).toEqual([]); + expect(onSubmit.mock.calls[0][0].results[1].selectedOptions).toEqual(["B2"]); + }); + + it("Submit tab shows unanswered warning but Enter still submits", () => { + const onSubmit = vi.fn(); + const questions: ExtensionAskDialogQuestion[] = [ + { + id: "q1", + question: "Q1?", + options: [{ label: "A1" }, { label: "B1" }], + }, + { + id: "q2", + question: "Q2?", + options: [{ label: "A2" }, { label: "B2" }], + }, + ]; + + const component = new AskDialogComponent(questions, { + onSubmit, + onCancel: vi.fn(), + onChat: vi.fn(), + onPrompt: vi.fn(), + }); + + // Tab to Submit + component.handleInput(TAB); + component.handleInput(TAB); + + const output = render(component); + expect(output.toLowerCase()).toContain("unanswered"); + + // Enter on Submit + component.handleInput(ENTER); + + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit.mock.calls[0][0].results[0].selectedOptions).toEqual([]); + expect(onSubmit.mock.calls[0][0].results[1].selectedOptions).toEqual([]); + }); + + it("Esc/cancel fires onCancel", () => { + const onCancel = vi.fn(); + const questions: ExtensionAskDialogQuestion[] = [ + { + id: "q1", + question: "Choose one?", + options: [{ label: "Option A" }], + }, + ]; + + const component = new AskDialogComponent(questions, { + onSubmit: vi.fn(), + onCancel, + onChat: vi.fn(), + onPrompt: vi.fn(), + }); + + component.handleInput(CANCEL); + expect(onCancel).toHaveBeenCalledTimes(1); + }); + + it("selecting 'Chat about this' on a question tab fires onChat", () => { + const onChat = vi.fn(); + const onSubmit = vi.fn(); + const questions: ExtensionAskDialogQuestion[] = [ + { + id: "q1", + question: "Q1?", + options: [{ label: "A1" }], + }, + ]; + + const component = new AskDialogComponent(questions, { + onSubmit, + onCancel: vi.fn(), + onChat, + onPrompt: vi.fn(), + }); + + // Cursor positions: + // 0: A1 + // 1: Other + // 2: Chat about this + component.handleInput(DOWN); + component.handleInput(DOWN); + component.handleInput(ENTER); + + expect(onChat).toHaveBeenCalledTimes(1); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it("selecting 'Chat about this' on Submit tab fires onChat", () => { + const onChat = vi.fn(); + const onSubmit = vi.fn(); + const questions: ExtensionAskDialogQuestion[] = [ + { + id: "q1", + question: "Q1?", + options: [{ label: "A1" }], + }, + { + id: "q2", + question: "Q2?", + options: [{ label: "A2" }], + }, + ]; + + const component = new AskDialogComponent(questions, { + onSubmit, + onCancel: vi.fn(), + onChat, + onPrompt: vi.fn(), + }); + + // Tab to Submit + component.handleInput(TAB); + component.handleInput(TAB); + + // Cursor positions on Submit tab: + // 0: Submit + // 1: Chat about this + component.handleInput(DOWN); + component.handleInput(ENTER); + + expect(onChat).toHaveBeenCalledTimes(1); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it("n on an option calls onPrompt and stores note with marker", async () => { + const onPrompt = vi.fn().mockReturnValue(Promise.resolve("My Custom Note")); + const onSubmit = vi.fn(); + const questions: ExtensionAskDialogQuestion[] = [ + { + id: "q1", + question: "Choose one?", + options: [{ label: "Option A" }], + }, + ]; + + const component = new AskDialogComponent(questions, { + onSubmit, + onCancel: vi.fn(), + onChat: vi.fn(), + onPrompt, + }); + + // Highlight is on Option A. Press 'n'. + component.handleInput("n"); + + // Await microtasks so the async #promptForNote runs + await Promise.resolve(); + await Promise.resolve(); + + expect(onPrompt).toHaveBeenCalledTimes(1); + expect(onPrompt.mock.calls[0][0]).toBe("Note for Option A: Choose one?"); + + // Verify note is saved by submitting + component.handleInput(ENTER); + + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit.mock.calls[0][0].results[0].note).toBe("My Custom Note"); + }); + + it("omits a note when a single-select answer changes to a different option", async () => { + const onPrompt = vi.fn().mockReturnValue(Promise.resolve("Note for A")); + const onSubmit = vi.fn(); + const questions: ExtensionAskDialogQuestion[] = [ + { + id: "q1", + question: "Choose one?", + options: [{ label: "Option A" }, { label: "Option B" }], + }, + ]; + + const component = new AskDialogComponent(questions, { + onSubmit, + onCancel: vi.fn(), + onChat: vi.fn(), + onPrompt, + }); + + component.handleInput("n"); + await Promise.resolve(); + await Promise.resolve(); + + component.handleInput(DOWN); + component.handleInput(ENTER); + + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit.mock.calls[0][0].results[0].selectedOptions).toEqual(["Option B"]); + expect(onSubmit.mock.calls[0][0].results[0].note).toBeUndefined(); + }); + + it("clears the note when a noted multi-select option is toggled off", async () => { + const onPrompt = vi.fn().mockReturnValue(Promise.resolve("Note for A")); + const onSubmit = vi.fn(); + const questions: ExtensionAskDialogQuestion[] = [ + { + id: "q1", + question: "Choose multiple?", + options: [{ label: "Option A" }, { label: "Option B" }], + multi: true, + }, + ]; + + const component = new AskDialogComponent(questions, { + onSubmit, + onCancel: vi.fn(), + onChat: vi.fn(), + onPrompt, + }); + + component.handleInput("n"); + await Promise.resolve(); + await Promise.resolve(); + + component.handleInput(SPACE); + component.handleInput(SPACE); + expect(render(component)).not.toContain("✎ note"); + component.handleInput(DOWN); + component.handleInput(DOWN); + component.handleInput(DOWN); + component.handleInput(ENTER); + + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit.mock.calls[0][0].results[0].selectedOptions).toEqual([]); + expect(onSubmit.mock.calls[0][0].results[0].note).toBeUndefined(); + }); + + it("shows selected multi-select options together with custom input on Submit", async () => { + const onPrompt = vi.fn().mockReturnValue(Promise.resolve("custom detail")); + const onSubmit = vi.fn(); + const questions: ExtensionAskDialogQuestion[] = [ + { + id: "q1", + question: "Choose multiple?", + options: [{ label: "Option A" }, { label: "Option B" }], + multi: true, + }, + { + id: "q2", + question: "Second question?", + options: [{ label: "Option C" }], + }, + ]; + + const component = new AskDialogComponent(questions, { + onSubmit, + onCancel: vi.fn(), + onChat: vi.fn(), + onPrompt, + }); + + component.handleInput(SPACE); + component.handleInput(DOWN); + component.handleInput(DOWN); + component.handleInput(ENTER); + await Promise.resolve(); + await Promise.resolve(); + + component.handleInput(TAB); + const review = render(component); + expect(review).toContain("Option A"); + expect(review).toContain("custom detail"); + + component.handleInput(ENTER); + + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit.mock.calls[0][0].results[0].selectedOptions).toEqual(["Option A"]); + expect(onSubmit.mock.calls[0][0].results[0].customInput).toBe("custom detail"); + }); + + it("defers a timeout that fires during a pending prompt and honors the resolved custom input", async () => { + vi.useFakeTimers(); + const deferred = Promise.withResolvers(); + const onPrompt = vi.fn().mockReturnValue(deferred.promise); + const onSubmit = vi.fn(); + const onTimeout = vi.fn(); + const questions: ExtensionAskDialogQuestion[] = [ + { + id: "q1", + question: "First?", + options: [{ label: "Option A" }, { label: "Option B" }], + }, + { + id: "q2", + question: "Second?", + options: [{ label: "Option C" }, { label: "Option D" }], + recommended: 1, + }, + ]; + + const component = new AskDialogComponent( + questions, + { onSubmit, onCancel: vi.fn(), onChat: vi.fn(), onPrompt }, + { timeout: 1000, onTimeout }, + ); + + // Open the "Other (type your own)" prompt on question 1. + component.handleInput(DOWN); + component.handleInput(DOWN); + component.handleInput(ENTER); + expect(onPrompt).toHaveBeenCalledTimes(1); + + // Timer expires while the prompt is pending: the timeout must be deferred, + // not submit the recommended fallback out from under the user. + vi.advanceTimersByTime(1000); + expect(onTimeout).not.toHaveBeenCalled(); + expect(onSubmit).not.toHaveBeenCalled(); + + // Resolving the prompt honors the typed answer, then runs the deferred + // timeout handling exactly once. + deferred.resolve("my answer"); + await Promise.resolve(); + await Promise.resolve(); + + expect(onTimeout).toHaveBeenCalledTimes(1); + expect(onSubmit).toHaveBeenCalledTimes(1); + const results = onSubmit.mock.calls[0][0].results; + expect(results[0].customInput).toBe("my answer"); + expect(results[0].selectedOptions).toEqual([]); + expect(results[0].timedOut).toBeUndefined(); + expect(results[1].selectedOptions).toEqual(["Option D"]); + expect(results[1].timedOut).toBe(true); + }); + + it("keeps a single-question custom prompt answer when timeout expires while the prompt is pending", async () => { + vi.useFakeTimers(); + const deferred = Promise.withResolvers(); + const onPrompt = vi.fn().mockReturnValue(deferred.promise); + const onSubmit = vi.fn(); + const onTimeout = vi.fn(); + const questions: ExtensionAskDialogQuestion[] = [ + { + id: "q1", + question: "Only question?", + options: [{ label: "Fallback" }], + }, + ]; + + const component = new AskDialogComponent( + questions, + { onSubmit, onCancel: vi.fn(), onChat: vi.fn(), onPrompt }, + { timeout: 1000, onTimeout }, + ); + + component.handleInput(DOWN); + component.handleInput(ENTER); + expect(onPrompt).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(1000); + expect(onTimeout).not.toHaveBeenCalled(); + expect(onSubmit).not.toHaveBeenCalled(); + + deferred.resolve("my answer"); + await Promise.resolve(); + await Promise.resolve(); + + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onTimeout).not.toHaveBeenCalled(); + const result = onSubmit.mock.calls[0][0].results[0]; + expect(result.customInput).toBe("my answer"); + expect(result.selectedOptions).toEqual([]); + expect(result.timedOut).toBeUndefined(); + }); + + it("uses a noted non-recommended option as the timeout fallback", async () => { + vi.useFakeTimers(); + const onPrompt = vi.fn().mockReturnValue(Promise.resolve("why B")); + const onSubmit = vi.fn(); + const onTimeout = vi.fn(); + const questions: ExtensionAskDialogQuestion[] = [ + { + id: "q1", + question: "Choose one?", + options: [{ label: "Option A" }, { label: "Option B" }], + recommended: 0, + }, + ]; + + const component = new AskDialogComponent( + questions, + { onSubmit, onCancel: vi.fn(), onChat: vi.fn(), onPrompt }, + { timeout: 1000, onTimeout }, + ); + + component.handleInput(DOWN); + component.handleInput("n"); + await Promise.resolve(); + await Promise.resolve(); + + vi.advanceTimersByTime(1000); + + expect(onTimeout).toHaveBeenCalledTimes(1); + expect(onSubmit).toHaveBeenCalledTimes(1); + const result = onSubmit.mock.calls[0][0].results[0]; + expect(result.selectedOptions).toEqual(["Option B"]); + expect(result.note).toBe("why B"); + expect(result.timedOut).toBe(true); + }); + + it("preserves a pending note on a non-recommended option when deferred timeout submits", async () => { + vi.useFakeTimers(); + const deferred = Promise.withResolvers(); + const onPrompt = vi.fn().mockReturnValue(deferred.promise); + const onSubmit = vi.fn(); + const onTimeout = vi.fn(); + const questions: ExtensionAskDialogQuestion[] = [ + { + id: "q1", + question: "Choose one?", + options: [{ label: "Option A" }, { label: "Option B" }], + recommended: 0, + }, + ]; + + const component = new AskDialogComponent( + questions, + { onSubmit, onCancel: vi.fn(), onChat: vi.fn(), onPrompt }, + { timeout: 1000, onTimeout }, + ); + + component.handleInput(DOWN); + component.handleInput("n"); + expect(onPrompt).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(1000); + expect(onTimeout).not.toHaveBeenCalled(); + expect(onSubmit).not.toHaveBeenCalled(); + + deferred.resolve("why B"); + await Promise.resolve(); + await Promise.resolve(); + + expect(onTimeout).toHaveBeenCalledTimes(1); + expect(onSubmit).toHaveBeenCalledTimes(1); + const result = onSubmit.mock.calls[0][0].results[0]; + expect(result.selectedOptions).toEqual(["Option B"]); + expect(result.note).toBe("why B"); + expect(result.timedOut).toBe(true); + }); +}); diff --git a/packages/coding-agent/test/modes/components/settings-layout.test.ts b/packages/coding-agent/test/modes/components/settings-layout.test.ts index dbcbd1b0e..88f89b798 100644 --- a/packages/coding-agent/test/modes/components/settings-layout.test.ts +++ b/packages/coding-agent/test/modes/components/settings-layout.test.ts @@ -97,4 +97,14 @@ describe("settings layout", () => { group: "Services", }); }); + + it("exposes ask.enabled as a boolean under Available Tools", () => { + const def = getSettingsForTab("tools").find(def => def.path === "ask.enabled"); + + expect(def).toMatchObject({ + type: "boolean", + label: "Ask", + group: "Available Tools", + }); + }); }); diff --git a/packages/coding-agent/test/tools/ask.test.ts b/packages/coding-agent/test/tools/ask.test.ts index efd7b3c30..ed2b68bdf 100644 --- a/packages/coding-agent/test/tools/ask.test.ts +++ b/packages/coding-agent/test/tools/ask.test.ts @@ -2,11 +2,16 @@ import { beforeAll, describe, expect, it, vi } from "bun:test"; import { stripVTControlCharacters } from "node:util"; import type { AgentToolContext } from "@oh-my-pi/pi-agent-core"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; -import type { ExtensionUISelectItem } from "@oh-my-pi/pi-coding-agent/extensibility/extensions"; +import type { + ExtensionAskDialogQuestion, + ExtensionAskDialogResult, + ExtensionUISelectItem, +} from "@oh-my-pi/pi-coding-agent/extensibility/extensions"; import { getThemeByName, initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; import { AskTool, askToolRenderer } from "@oh-my-pi/pi-coding-agent/tools/ask"; import { ToolAbortError } from "@oh-my-pi/pi-coding-agent/tools/tool-errors"; +import { type } from "arktype"; function createSession(overrides: Partial = {}): ToolSession { return { @@ -20,7 +25,7 @@ function createSession(overrides: Partial = {}): ToolSession { } function createContext(args: { - select: ( + select?: ( prompt: string, options: ExtensionUISelectItem[], dialogOptions?: { @@ -42,13 +47,18 @@ function createContext(args: { dialogOptions?: { signal?: AbortSignal }, editorOptions?: { promptStyle?: boolean }, ) => Promise; + askDialog?: ( + questions: ExtensionAskDialogQuestion[], + dialogOptions?: any, + ) => Promise; abort?: () => void; }): AgentToolContext { // AgentToolContext includes many runtime fields; tests only need UI + abort behavior. return { hasUI: true, ui: { - select: args.select, + ...(args.select ? { select: args.select } : {}), + ...(args.askDialog ? { askDialog: args.askDialog } : {}), editor: ( title: string, prefill?: string, @@ -1528,3 +1538,135 @@ describe("askToolRenderer malformed call args", () => { expect(text).toContain("Proper"); }); }); + +describe("AskTool rich ask dialog", () => { + it("accepts new schema fields (header, preview, note) and maps them into AskToolDetails", async () => { + const tool = new AskTool(createSession()); + const askDialog = vi.fn().mockResolvedValue({ + kind: "submit", + results: [ + { + id: "q1", + question: "Q1?", + options: ["Option A"], + multi: false, + selectedOptions: ["Option A"], + note: "My Custom Note", + timedOut: undefined, + }, + ], + }); + const context = createContext({ askDialog }); + + const result = await tool.execute( + "call-rich-dialog", + { + questions: [ + { + id: "q1", + question: "Q1?", + header: "Chip Header", + options: [{ label: "Option A", preview: "My Preview" }], + }, + ], + }, + undefined, + undefined, + context, + ); + + expect(askDialog).toHaveBeenCalledTimes(1); + // Check that header and preview were forwarded + expect(askDialog.mock.calls[0][0]).toEqual([ + { + id: "q1", + question: "Q1?", + header: "Chip Header", + options: [{ label: "Option A", preview: "My Preview" }], + }, + ]); + + // Verify result contains details with note mapping + expect(result.details).toEqual({ + question: "Q1?", + options: ["Option A"], + multi: false, + selectedOptions: ["Option A"], + customInput: undefined, + note: "My Custom Note", + timedOut: undefined, + }); + }); + + it("aborts and throws ToolAbortError when askDialog returns undefined", async () => { + const tool = new AskTool(createSession()); + const abort = vi.fn(); + const askDialog = vi.fn().mockResolvedValue(undefined); + const context = createContext({ askDialog, abort }); + + await expect( + tool.execute( + "call-rich-dialog-cancel", + { + questions: [{ id: "q1", question: "Q1?", options: [{ label: "Option A" }] }], + }, + undefined, + undefined, + context, + ), + ).rejects.toThrow(ToolAbortError); + + expect(abort).toHaveBeenCalledTimes(1); + }); + + it("ignores preview and header in degraded select path", async () => { + const tool = new AskTool(createSession()); + const select = vi.fn().mockResolvedValue("Option A"); + const context = createContext({ select }); + + await tool.execute( + "call-degraded", + { + questions: [ + { + id: "q1", + question: "Q1?", + header: "Chip Header", + options: [{ label: "Option A", description: "Desc A", preview: "My Preview" }], + }, + ], + }, + undefined, + undefined, + context, + ); + + expect(select).toHaveBeenCalledTimes(1); + // verify preview/header are NOT forwarded to select options + expect(select.mock.calls[0][1]).toEqual([{ label: "Option A", description: "Desc A" }, "Other (type your own)"]); + }); + + it("rejects reserved-label collision in parameters validation", async () => { + const tool = new AskTool(createSession()); + + const valid = tool.parameters({ + questions: [{ id: "q1", question: "Q?", options: [{ label: "ok" }] }], + }); + expect(valid instanceof type.errors).toBe(false); + + const reservedOther = tool.parameters({ + questions: [{ id: "q1", question: "Q?", options: [{ label: "Other (type your own)" }] }], + }); + expect(reservedOther instanceof type.errors).toBe(true); + + const reservedChat = tool.parameters({ + questions: [{ id: "q1", question: "Q?", options: [{ label: "Chat about this" }] }], + }); + expect(reservedChat instanceof type.errors).toBe(true); + + const reservedNext = tool.parameters({ + questions: [{ id: "q1", question: "Q?", options: [{ label: "Next →" }] }], + }); + expect(reservedNext instanceof type.errors).toBe(true); + }); +}); diff --git a/packages/coding-agent/test/tools/index.test.ts b/packages/coding-agent/test/tools/index.test.ts index eae16b79c..09a2b3aff 100644 --- a/packages/coding-agent/test/tools/index.test.ts +++ b/packages/coding-agent/test/tools/index.test.ts @@ -208,6 +208,27 @@ describe("createTools", () => { expect(names).toContain("ask"); }); + it("excludes ask tool when ask.enabled is false", async () => { + const session = createTestSession({ + hasUI: true, + settings: createSettingsWithOverrides({ "ask.enabled": false }), + }); + const tools = await createTools(session); + expect(tools.map(t => t.name)).not.toContain("ask"); + + const requested = await createTools(session, ["ask", "read"]); + expect(requested.map(t => t.name)).toEqual(["read", "resolve"]); + }); + + it("includes ask tool when ask.enabled is true and hasUI is true", async () => { + const session = createTestSession({ + hasUI: true, + settings: createSettingsWithOverrides({ "ask.enabled": true }), + }); + const tools = await createTools(session); + expect(tools.map(t => t.name)).toContain("ask"); + }); + it("filters disabled builtin tools by settings", async () => { const session = createTestSession({ settings: createSettingsWithOverrides({