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
This commit is contained in:
metaphorics
2026-07-03 07:57:19 +09:00
parent afc79e4cda
commit 38a5c8a893
10 changed files with 2231 additions and 48 deletions
@@ -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,
@@ -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<string | undefined>;
/** Show the rich ask dialog when the interactive TUI surface is available. */
askDialog?(
questions: ExtensionAskDialogQuestion[],
dialogOptions?: ExtensionUIDialogOptions,
): Promise<ExtensionAskDialogResult | undefined>;
/** Show a notification to the user. */
notify(message: string, type?: "info" | "warning" | "error"): void;
@@ -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<string | undefined>;
}
interface AskDialogOptions {
timeout?: number;
onTimeout?: () => void;
tui?: TUI;
}
interface QuestionState {
selectedOptions: Set<string>;
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<string>(),
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<void> {
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<void> {
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;
}
}
@@ -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<ExtensionAskDialogResult | undefined> {
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<CollabAskDialogWinner> = 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<ExtensionAskDialogResult | undefined> {
return this.#presentDialog<ExtensionAskDialogResult>(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<string | undefined> => {
if (closed) return Promise.resolve(undefined);
const { promise, resolve } = Promise.withResolvers<string | undefined>();
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<ExtensionAskDialogResult | "unavailable" | undefined> {
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<ExtensionAskDialogResultItem | "unavailable" | undefined> {
const selected = new Set<string>();
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<string | "unavailable" | undefined> {
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<T = string>(
signal: AbortSignal | undefined,
present: (settle: (value: string | undefined) => void) => () => void,
): Promise<string | undefined> {
const { promise, resolve, reject } = Promise.withResolvers<string | undefined>();
present: (settle: (value: T | undefined) => void) => () => void,
): Promise<T | undefined> {
const { promise, resolve, reject } = Promise.withResolvers<T | undefined>();
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);
+204 -40
View File
@@ -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<string, true> = {
[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<SelectionResult, "selectedOptions" | "customInput">;
initialSelection?: Pick<SelectionResult, "selectedOptions" | "customInput" | "note">;
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<typeof askSchema, AskToolDetails> {
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<typeof askSchema, AskToolDetails> {
}));
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<typeof askSchema, AskToolDetails> {
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<typeof askSchema, AskToolDetails> {
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<typeof askSchema, AskToolDetails> {
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<typeof askSchema, AskToolDetails> {
const resultsByIndex: Array<QuestionResult | undefined> = 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<typeof askSchema, AskToolDetails> {
optionLabels,
selectedOptions,
customInput,
note,
navigation: navAction,
cancelled,
timedOut,
@@ -874,6 +1004,7 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
multi: q.multi ?? false,
selectedOptions,
customInput,
note,
timedOut: timedOut || undefined,
};
@@ -885,9 +1016,9 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
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.
+1
View File
@@ -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);
@@ -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<string | undefined>();
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<string | undefined>();
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<string | undefined>();
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);
});
});
@@ -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",
});
});
});
+145 -3
View File
@@ -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> = {}): ToolSession {
return {
@@ -20,7 +25,7 @@ function createSession(overrides: Partial<ToolSession> = {}): 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<string | undefined>;
askDialog?: (
questions: ExtensionAskDialogQuestion[],
dialogOptions?: any,
) => Promise<ExtensionAskDialogResult | undefined>;
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);
});
});
@@ -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({