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:
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user