diff --git a/extensions/ask-user-question.ts b/extensions/ask-user-question.ts new file mode 100644 index 000000000..52d6ff352 --- /dev/null +++ b/extensions/ask-user-question.ts @@ -0,0 +1,210 @@ +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { DynamicBorder } from "@earendil-works/pi-coding-agent"; +import { Text } from "@earendil-works/pi-tui"; +import { createNativeFullscreenInteraction } from "../lib/native-fullscreen-interaction.ts"; +import { type QuestionParams, QuestionParamsSchema } from "../lib/questionnaire/schema.ts"; +import { + QuestionnaireView, + type AnswerRow, + type QuestionnaireResult, +} from "../lib/questionnaire/questionnaire-view.ts"; +import { validateQuestionnaire, type QuestionnaireError } from "../lib/questionnaire/validate.ts"; + +const QUESTION_TOOL_NAME = "ask_user_question"; +const ASK_USER_QUESTION_BLOCKED_EVENT = "gentle-pi:ask-user-question:blocked"; + +/** Maximum characters kept from a renderCall question summary. */ +const CALL_SUMMARY_LIMIT = 120; + +/** Structured details returned by the tool for UI rendering and callers. */ +interface QuestionnaireDetails { + cancelled?: boolean; + answers?: AnswerRow[]; + error?: QuestionnaireError; + errorKind?: string; +} + +/** Content plus details returned by `execute`. */ +interface QuestionnaireToolResult { + content: Array<{ type: "text"; text: string }>; + details: QuestionnaireDetails; +} + +/** + * Invalid-parameter result. `AgentToolResult` has no `isError` field, so this + * follows the repository convention for rejected tool input: a leading error + * sentence in `content` plus a machine-readable payload in `details` + * (`extensions/gentle-todo.ts` returns `Error: ...` with `details.error`). + */ +function invalidQuestionnaireResult(error: QuestionnaireError): QuestionnaireToolResult { + return { + content: [{ type: "text", text: `Invalid questionnaire: ${error.message}` }], + details: { error, errorKind: error.code }, + }; +} + +/** Non-interactive result; parity with ask_user_choice's TUI-only guard. */ +function unavailableResult(): QuestionnaireToolResult { + return { + content: [{ type: "text", text: "Error: ask_user_question is unavailable outside the interactive TUI" }], + details: { errorKind: "unavailable_outside_tui" }, + }; +} + +/** + * Human-readable body for one answer. A custom answer on a multiSelect + * question keeps the toggled options, so the text must name them explicitly: + * the free-text value alone would silently drop the user's selections. Plain + * custom answers (no selections) stay concise. + */ +function answerBody(answer: AnswerRow): string { + if (answer.kind === "multi") return `selected: ${(answer.selected ?? []).join(", ")}`; + if (answer.kind === "custom") { + const body = `(custom) ${answer.answer ?? ""}`; + const selected = answer.selected ?? []; + return selected.length > 0 ? `${body} — selected: ${selected.join(", ")}` : body; + } + return answer.answer ?? ""; +} + +/** + * Compact LLM-facing transcript of the committed answers. Each row keeps the + * original one-based question index so a partially answered questionnaire + * (the last question committed early) still reads in order. + */ +function answersText(answers: AnswerRow[]): string { + if (answers.length === 0) return "The user answered the questionnaire."; + const lines: string[] = []; + for (const answer of answers) { + const prefix = `${answer.questionIndex + 1}. ${answer.question}`; + lines.push(`${prefix} — ${answerBody(answer)}`); + if (answer.preview !== undefined) lines.push(` selected preview: ${answer.preview}`); + } + return lines.join("\n"); +} + +/** Single-line summary of one question for the collapsed tool call row. */ +function callSummary(question: unknown, index: number): string { + const source = typeof question === "object" && question !== null ? question as { header?: unknown; options?: unknown } : {}; + const header = typeof source.header === "string" ? source.header : ""; + const labels = Array.isArray(source.options) + ? source.options + .map((option) => (typeof option === "object" && option !== null && typeof (option as { label?: unknown }).label === "string" + ? (option as { label: string }).label + : "")) + .filter((label) => label.length > 0) + : []; + const labelsPart = labels.length > 0 ? ` (${labels.join(", ")})` : ""; + return `${index + 1}. ${header}${labelsPart}`; +} + +function truncate(text: string, limit: number): string { + return text.length <= limit ? text : `${text.slice(0, Math.max(0, limit - 1))}…`; +} + +/** + * Register the first-party questionnaire tool. + * + * Name-collision semantics (live-verified against the installed Pi runtime): + * - Tool names are exclusive across extensions. Pi has no precedence, override, + * or silent shadowing: loading two extensions that register the same tool + * name fails the whole load with a hard error + * (`Tool "ask_user_question" conflicts with `; the runtime + * exits non-zero). The name is either free or fatal, full stop. + * - `registerTool` writes into the calling extension's own tool map keyed by + * name, so re-registering inside one extension overwrites that entry + * (`loader.js:240`). That same-name write is the only one Pi tolerates. + * - This first-party tool ships as THE `ask_user_question` provider. A competing + * provider such as the third-party `@juicesharp/rpiv-ask-user-question` + * package fails the load by design and must be removed from the user's Pi + * settings; that deletion is the documented migration path, not a runtime + * precedence choice. + */ +export default function askUserQuestion(pi: ExtensionAPI): void { + pi.registerTool({ + name: QUESTION_TOOL_NAME, + renderShell: "self", + label: "Ask User Question", + description: "Ask one to four structured questions in a single call, each with two to four ordered options, and read the user's answers back in one result.", + promptGuidelines: [ + "Use ask_user_question to collect decisions in one batch: ask one to four questions at a time, each with two to four options.", + "Keep each header a short chip of at most 16 characters and each option label at most 60 characters.", + "Add a preview to an option when the user needs to compare rich detail side-by-side with the options.", + "Set multiSelect when the choices are not mutually exclusive.", + "The free-text \"Type something.\" row is always available and is also how the user bails out into a normal conversation; never rely on it as a hidden escape hatch.", + "Never use this tool for decisions that must not be delegated to the user.", + ], + parameters: QuestionParamsSchema, + executionMode: "sequential", + async execute( + _toolCallId: string, + params: QuestionParams, + _signal: AbortSignal | undefined, + _onUpdate: undefined, + ctx, + ): Promise { + const error = validateQuestionnaire(params); + if (error) return invalidQuestionnaireResult(error); + if (ctx.mode !== "tui") return unavailableResult(); + + let selection: QuestionnaireResult | undefined; + try { + pi.events.emit(ASK_USER_QUESTION_BLOCKED_EVENT, { active: true }); + selection = await ctx.ui.custom((tui, theme, keybindings, done) => { + const view = new QuestionnaireView({ + questions: params.questions, + theme, + keybindings, + onComplete: (result) => done(result), + }); + // Native dock swap, never an overlay: the transcript stays scrollable + // while the questionnaire owns focus. No `overlay` option is passed. + const container = createNativeFullscreenInteraction({ + keyboardTarget: view, + requestRender: () => tui.requestRender(), + }); + container.addChild(new DynamicBorder((text: string) => theme.fg("accent", text))); + container.addChild(view); + container.addChild(new DynamicBorder((text: string) => theme.fg("accent", text))); + return container; + }); + } + finally { + pi.events.emit(ASK_USER_QUESTION_BLOCKED_EVENT, { active: false }); + } + + if (selection === undefined || selection.cancelled) { + return { + content: [{ type: "text", text: "User cancelled the questionnaire" }], + details: { cancelled: true }, + }; + } + return { + content: [{ type: "text", text: answersText(selection.answers) }], + details: { answers: selection.answers }, + }; + }, + renderCall(args: QuestionParams, theme) { + const questions = Array.isArray(args.questions) ? args.questions : []; + const summary = truncate(questions.map((question, index) => callSummary(question, index)).join(" "), CALL_SUMMARY_LIMIT); + return new Text( + theme.fg("toolTitle", theme.bold("ask_user_question ")) + + theme.fg("muted", summary), + 0, + 0, + ); + }, + renderResult(result, _options, theme) { + const details = result.details as QuestionnaireDetails | undefined; + if (details?.cancelled === true) return new Text(theme.fg("warning", "Cancelled"), 0, 0); + const answers = Array.isArray(details?.answers) ? details.answers : []; + if (answers.length === 0) return new Text(theme.fg("warning", "No answers"), 0, 0); + const lines = answers.map((answer) => { + if (answer.kind === "multi") return theme.fg("success", `✓ ${answer.question} — ${(answer.selected ?? []).join(", ")}`); + if (answer.kind === "custom") return theme.fg("success", `✓ ${answer.question} — ${answerBody(answer)}`); + return theme.fg("success", `✓ ${answer.question} — ${answer.answer ?? ""}`); + }); + return new Text(lines.join("\n"), 0, 0); + }, + }); +} diff --git a/extensions/gentle-ai.ts b/extensions/gentle-ai.ts index ee9e0408f..9947807ae 100644 --- a/extensions/gentle-ai.ts +++ b/extensions/gentle-ai.ts @@ -8327,11 +8327,13 @@ async function executeReviewControllerOperation( // native START are resolved, and re-derive the target for that range, // so all three agree on one base-diff identity. Adopting the offer // later left the workspace target and the base-diff candidate view - // disagreeing, and START failed with identity-mismatch. Both an - // explicit caller baseRef and any START with an untracked selection in - // play keep today's single-STATUS flow; only an adopted offer pays the - // second read-only STATUS. - if (canonicalBaseRef === undefined && untrackedSelection.untrackedScope === undefined && untrackedSubmission === undefined) { + // disagreeing, and START failed with identity-mismatch. An explicit + // caller baseRef still wins (it already is the adopted range), but an + // in-play untracked selection now also pays this second read-only + // STATUS: the renegotiated target is a base-diff projection, so the + // candidate view must be materialized WITH the offered base instead of + // the base-less view that tripped candidate-target-projection-drift. + if (canonicalBaseRef === undefined) { const offeredBaseRef = offeredCommittedRangeBaseRef(target); if (offeredBaseRef !== undefined) { const renegotiated = await negotiatedStatusForHostTransport(nativeReviewCli, { @@ -8339,6 +8341,8 @@ async function executeReviewControllerOperation( ...(parameters.lineageId === undefined ? {} : { lineageId: parameters.lineageId }), baseRef: offeredBaseRef, committedOnly: true, + ...(untrackedSelection.untrackedScope === undefined ? {} : untrackedSelection), + ...(untrackedSubmission === undefined ? {} : { intendedUntrackedSelection: untrackedSubmission }), ...(signal === undefined ? {} : { signal }), }, retainedUntrackedSelections, defaultCwd); if (renegotiated.transport !== undefined) return hostTransportUnavailable(parameters.operation, renegotiated.transport); diff --git a/lib/questionnaire/questionnaire-view.ts b/lib/questionnaire/questionnaire-view.ts new file mode 100644 index 000000000..61284f011 --- /dev/null +++ b/lib/questionnaire/questionnaire-view.ts @@ -0,0 +1,603 @@ +import { + Container, + Input, + isKeyRelease, + matchesKey, + Text, + visibleWidth, + type Focusable, + type KeybindingsManager, + type TuiMouseEvent, +} from "@earendil-works/pi-tui"; +import { CUSTOM_ROW_LABEL, type QuestionData } from "./schema.ts"; + +/** Minimum terminal width at which the preview pane splits beside the list. */ +export const MIN_PREVIEW_WIDTH = 80; + +/** Fraction of the width given to the option column when a preview pane is shown. */ +const PREVIEW_SPLIT = 0.45; + +/** Two-space gutter between the option column and the preview pane. No divider frame. */ +const PREVIEW_GAP = " "; + +/** Theme surface used by the questionnaire, compatible with the Pi TUI theme. */ +export interface QuestionnaireTheme { + fg(color: string, text: string): string; + bg?(color: string, text: string): string; + bold?(text: string): string; +} + +/** One committed answer for a question. */ +export interface AnswerRow { + questionIndex: number; + question: string; + kind: "option" | "custom" | "multi"; + answer: string | null; + selected?: string[]; + preview?: string; +} + +/** Final questionnaire outcome handed to the caller. */ +export interface QuestionnaireResult { + cancelled: boolean; + answers: AnswerRow[]; +} + +/** Construction options for {@link QuestionnaireView}. */ +export interface QuestionnaireViewOptions { + questions: QuestionData[]; + theme: QuestionnaireTheme; + keybindings?: KeybindingsManager; + onComplete?: (result: QuestionnaireResult) => void; +} + +interface QuestionState { + cursor: number; + toggled: Set; + answer: AnswerRow | undefined; + customDraft: string; +} + +interface LineOwner { + questionIndex: number; + rowIndex: number; +} + +/** Small inline text editor for the free-text row; owns an {@link Input}. */ +class CustomTextEditor extends Container { + private readonly input = new Input({ prompt: "> ", placeholder: "Type your response" }); + private readonly keybindings: KeybindingsManager | undefined; + private readonly onSubmit: (value: string) => void; + private readonly onCancel: () => void; + + constructor( + keybindings: KeybindingsManager | undefined, + onSubmit: (value: string) => void, + onCancel: () => void, + ) { + super(); + this.keybindings = keybindings; + this.onSubmit = onSubmit; + this.onCancel = onCancel; + this.input.focused = false; + this.addChild(new Text("Custom response", 1, 0)); + this.addChild(this.input); + this.addChild(new Text("Enter to submit • Esc to return to choices", 1, 0)); + } + + setFocused(focused: boolean): void { + this.input.focused = focused; + } + + getValue(): string { + return this.input.getValue(); + } + + setValue(value: string): void { + this.input.setValue(value); + // Place the caret at the end of a restored draft so typing appends to it. + this.input.handleInput("\x1b[F"); + this.invalidate(); + } + + handleInput(data: string): void { + if (isKeyRelease(data)) return; + if (this.matches(data, "tui.select.cancel")) { + this.onCancel(); + return; + } + if (this.matches(data, "tui.input.submit")) { + this.onSubmit(this.input.getValue()); + return; + } + this.input.handleInput(data); + this.invalidate(); + } + + private matches(data: string, binding: "tui.select.cancel" | "tui.input.submit"): boolean { + if (this.keybindings?.matches) return this.keybindings.matches(data, binding); + const key = binding === "tui.select.cancel" ? "escape" : "enter"; + return matchesKey(data, key); + } +} + +/** + * One-question-at-a-time questionnaire. + * + * The whole questionnaire is represented as a compact tab strip: exactly one + * question body is rendered at a time, and Tab/Shift-Tab switches the active + * question while each question keeps its own cursor, toggles, and custom-text + * draft. This keeps the component's height bounded for one to four questions + * so it fits the native dock area instead of overflowing the viewport. + * + * Native dock-swap component: it is a {@link Container}, never an overlay, so + * the transcript stays scrollable while it is focused. Keyboard handling uses + * the public Pi TUI input protocol (`matchesKey()` and the injected + * `KeybindingsManager`), matching how the shipped agent views read input. + */ +export class QuestionnaireView extends Container implements Focusable { + private readonly questions: QuestionData[]; + private readonly theme: QuestionnaireTheme; + private readonly keybindings: KeybindingsManager | undefined; + private readonly onComplete: ((result: QuestionnaireResult) => void) | undefined; + private readonly states: QuestionState[]; + private readonly editor: CustomTextEditor; + private focusedQuestion = 0; + private editingQuestion: number | undefined; + private completed = false; + private result: QuestionnaireResult | undefined; + private lineOwners: Array = []; + private _focused = false; + + constructor(options: QuestionnaireViewOptions) { + super(); + this.questions = options.questions; + this.theme = options.theme; + this.keybindings = options.keybindings; + this.onComplete = options.onComplete; + this.states = options.questions.map(() => ({ + cursor: 0, + toggled: new Set(), + answer: undefined, + customDraft: "", + })); + this.editor = new CustomTextEditor( + options.keybindings, + (value) => this.submitCustom(value), + () => this.closeEditor(), + ); + } + + /** Focusable: propagate focus so the free-text input gets the IME cursor. */ + get focused(): boolean { + return this._focused; + } + + set focused(value: boolean) { + this._focused = value; + this.editor.setFocused(value); + this.invalidate(); + } + + /** Current committed result. Safe to call before completion. */ + getResult(): QuestionnaireResult { + return this.result ?? { cancelled: false, answers: this.collectedAnswers() }; + } + + /** Active question index; exposed for tests and for callers that drive the view. */ + get activeQuestion(): number { + return this.focusedQuestion; + } + + handleInput(data: string): void { + if (this.completed || isKeyRelease(data)) return; + + if (this.editingQuestion !== undefined) { + // Tab still switches questions while editing; the draft is preserved. + if (this.matchesTab(data, false)) { + this.closeEditor(); + this.moveFocus(1); + return; + } + if (this.matchesTab(data, true)) { + this.closeEditor(); + this.moveFocus(-1); + return; + } + this.editor.handleInput(data); + return; + } + + if (this.matches(data, "tui.select.cancel")) { + this.finish({ cancelled: true, answers: this.collectedAnswers() }); + return; + } + + if (this.matchesTab(data, false)) { + this.moveFocus(1); + return; + } + + if (this.matchesTab(data, true)) { + this.moveFocus(-1); + return; + } + + if (this.matches(data, "tui.select.up")) { + this.moveCursor(-1); + return; + } + + if (this.matches(data, "tui.select.down")) { + this.moveCursor(1); + return; + } + + if (matchesKey(data, "space")) { + const question = this.questions[this.focusedQuestion]; + if (question?.multiSelect) { + this.toggleCursor(); + return; + } + } + + if (this.matches(data, "tui.select.confirm")) { + this.commit(); + } + } + + override handleMouse(event: TuiMouseEvent) { + if (this.completed) return undefined; + if (this.editingQuestion !== undefined) return this.editor.handleMouse(event); + + const owner = this.lineOwners[event.y]; + if (!owner || owner.rowIndex < 0 || event.button !== "left") return undefined; + + if (event.type === "press") { + const changed = this.focusRow(owner.questionIndex, owner.rowIndex); + return { handled: true as const, focus: true, render: changed, target: this.mouseTarget(event) }; + } + + if (event.type === "click") { + const question = this.questions[owner.questionIndex]; + this.focusRow(owner.questionIndex, owner.rowIndex); + // A multiSelect option toggles in place; only single-select (or the + // custom row, which opens the editor) commits on click. + if (question?.multiSelect && owner.rowIndex < question.options.length) { + this.toggleCursor(); + } + else { + this.commit(); + } + return { handled: true as const, render: true, target: this.mouseTarget(event) }; + } + + return undefined; + } + + override render(width: number): string[] { + const viewport = Math.max(1, width); + const lines: string[] = []; + const owners: Array = []; + const push = (text: string, owner?: LineOwner) => { + for (const line of this.wrap(text, viewport)) { + lines.push(line); + owners.push(owner); + } + }; + + if (this.questions.length === 0) { + this.lineOwners = []; + return []; + } + + push(this.renderTabs()); + push(""); + + const preview = this.currentPreview(); + if (preview !== undefined && viewport >= MIN_PREVIEW_WIDTH) { + const leftWidth = Math.max(1, Math.floor(viewport * PREVIEW_SPLIT)); + const rightWidth = Math.max(1, viewport - leftWidth - PREVIEW_GAP.length); + const left = this.renderBody(leftWidth, false); + const right = this.wrap(this.theme.fg("dim", preview), rightWidth); + const rows = Math.max(left.lines.length, right.length); + for (let index = 0; index < rows; index++) { + lines.push(`${padTo(left.lines[index] ?? "", leftWidth)}${PREVIEW_GAP}${right[index] ?? ""}`); + owners.push(left.owners[index]); + } + } + else { + const body = this.renderBody(viewport, preview !== undefined); + lines.push(...body.lines); + owners.push(...body.owners); + } + + push(""); + push(this.hint()); + + this.lineOwners = owners; + return lines; + } + + override invalidate(): void { + this.lineOwners = []; + super.invalidate(); + this.editor.setFocused(this._focused); + } + + /** Compact tab strip: every question is a chip, exactly one is active. */ + private renderTabs(): string { + const total = this.questions.length; + const progress = this.theme.fg("dim", `[${this.focusedQuestion + 1}/${total}]`); + const chips = this.questions.map((question, index) => { + const answered = this.states[index]?.answer !== undefined; + const label = `${answered ? "✓ " : ""}${question.header}`; + return index === this.focusedQuestion + ? this.accent(`▸ ${label}`) + : this.theme.fg("muted", ` ${label}`); + }); + return `${progress} ${chips.join(" ")}`; + } + + /** Body for the active question only. */ + private renderBody(width: number, inlinePreview: boolean): { lines: string[]; owners: Array } { + const lines: string[] = []; + const owners: Array = []; + const push = (text: string, owner?: LineOwner) => { + for (const line of this.wrap(text, width)) { + lines.push(line); + owners.push(owner); + } + }; + + const question = this.questions[this.focusedQuestion]; + const state = this.states[this.focusedQuestion]; + if (!question || !state) return { lines, owners }; + + const headerOwner: LineOwner = { questionIndex: this.focusedQuestion, rowIndex: -1 }; + push(this.accent(question.question), headerOwner); + + if (this.editingQuestion === this.focusedQuestion) { + for (const line of this.editor.render(width)) { + lines.push(line); + owners.push(headerOwner); + } + return { lines, owners }; + } + + const customIndex = question.options.length; + for (const [optionIndex, option] of question.options.entries()) { + const owner: LineOwner = { questionIndex: this.focusedQuestion, rowIndex: optionIndex }; + const cursor = state.cursor === optionIndex ? this.accent("❯ ") : " "; + const marker = question.multiSelect ? `${state.toggled.has(optionIndex) ? "[x]" : "[ ]"} ` : ""; + push(`${cursor}${marker}${option.label}`, owner); + push(` ${this.theme.fg("dim", option.description)}`, owner); + if (inlinePreview && state.cursor === optionIndex && option.preview !== undefined) { + for (const line of this.wrap(this.theme.fg("dim", option.preview), Math.max(1, width - 4))) { + push(` ${line}`, owner); + } + } + } + + const customOwner: LineOwner = { questionIndex: this.focusedQuestion, rowIndex: customIndex }; + const customCursor = state.cursor === customIndex ? this.accent("❯ ") : " "; + const customDone = state.answer?.kind === "custom" ? "✓ " : ""; + push(`${customCursor}${customDone}${CUSTOM_ROW_LABEL}`, customOwner); + + return { lines, owners }; + } + + /** Bottom hint for the active question's interaction model. */ + private hint(): string { + const question = this.questions[this.focusedQuestion]; + const parts = ["↑↓ move"]; + if (question?.multiSelect) parts.push("space toggle"); + parts.push("enter select", "tab switch", "esc cancel"); + return this.theme.fg("dim", parts.join(" · ")); + } + + private wrap(text: string, width: number): string[] { + return new Text(text, 0, 0).render(Math.max(1, width)); + } + + private accent(text: string): string { + const bold = this.theme.bold ? this.theme.bold(text) : text; + return this.theme.fg("accent", bold); + } + + private currentPreview(): string | undefined { + if (this.completed || this.editingQuestion !== undefined) return undefined; + const question = this.questions[this.focusedQuestion]; + const state = this.states[this.focusedQuestion]; + if (!question || !state) return undefined; + if (state.cursor < 0 || state.cursor >= question.options.length) return undefined; + return question.options[state.cursor]?.preview; + } + + private moveFocus(delta: number): void { + const total = this.questions.length; + if (total === 0) return; + this.focusedQuestion = (this.focusedQuestion + delta + total) % total; + this.invalidate(); + } + + private moveCursor(delta: number): void { + const question = this.questions[this.focusedQuestion]; + const state = this.states[this.focusedQuestion]; + if (!question || !state) return; + const total = question.options.length + 1; + state.cursor = Math.max(0, Math.min(total - 1, state.cursor + delta)); + this.invalidate(); + } + + private toggleCursor(): void { + const question = this.questions[this.focusedQuestion]; + const state = this.states[this.focusedQuestion]; + if (!question || !state) return; + if (state.cursor === question.options.length) { + this.openEditor(this.focusedQuestion); + return; + } + if (state.toggled.has(state.cursor)) state.toggled.delete(state.cursor); + else state.toggled.add(state.cursor); + this.invalidate(); + } + + private focusRow(questionIndex: number, rowIndex: number): boolean { + const question = this.questions[questionIndex]; + const state = this.states[questionIndex]; + if (!question || !state) return false; + const changed = this.focusedQuestion !== questionIndex || state.cursor !== rowIndex; + this.focusedQuestion = questionIndex; + state.cursor = Math.max(0, Math.min(question.options.length, rowIndex)); + this.invalidate(); + return changed; + } + + private commit(): void { + const question = this.questions[this.focusedQuestion]; + const state = this.states[this.focusedQuestion]; + if (!question || !state) return; + const customIndex = question.options.length; + + if (state.cursor === customIndex) { + this.openEditor(this.focusedQuestion); + return; + } + + if (question.multiSelect) { + const toggled = [...state.toggled] + .filter((index) => index < customIndex) + .sort((a, b) => a - b); + if (toggled.length === 0) return; + state.answer = { + questionIndex: this.focusedQuestion, + question: question.question, + kind: "multi", + answer: null, + selected: toggled.map((index) => question.options[index]!.label), + }; + this.afterCommit(this.focusedQuestion); + return; + } + + const option = question.options[state.cursor]; + if (!option) return; + state.answer = { + questionIndex: this.focusedQuestion, + question: question.question, + kind: "option", + answer: option.label, + ...(option.preview !== undefined ? { preview: option.preview } : {}), + }; + this.afterCommit(this.focusedQuestion); + } + + private openEditor(questionIndex: number): void { + const state = this.states[questionIndex]; + if (!state) return; + this.editingQuestion = questionIndex; + this.editor.setValue(state.customDraft); + this.editor.setFocused(this._focused); + this.invalidate(); + } + + private closeEditor(): void { + if (this.editingQuestion === undefined) return; + const state = this.states[this.editingQuestion]; + if (state) state.customDraft = this.editor.getValue(); + this.editingQuestion = undefined; + this.editor.setValue(""); + this.editor.setFocused(false); + this.invalidate(); + } + + private submitCustom(value: string): void { + const questionIndex = this.editingQuestion; + if (questionIndex === undefined) return; + const question = this.questions[questionIndex]; + const state = this.states[questionIndex]; + if (!question || !state) { + this.closeEditor(); + return; + } + if (value.trim().length === 0) { + // Whitespace-only is treated as empty: discard it so reopening is clean. + this.editor.setValue(""); + this.closeEditor(); + return; + } + const customIndex = question.options.length; + const selected = [...state.toggled] + .filter((index) => index < customIndex) + .sort((a, b) => a - b) + .map((index) => question.options[index]!.label); + state.customDraft = value; + state.answer = { + questionIndex, + question: question.question, + kind: "custom", + answer: value, + ...(question.multiSelect && selected.length > 0 ? { selected } : {}), + }; + this.closeEditor(); + this.afterCommit(questionIndex); + } + + private afterCommit(questionIndex: number): void { + if (this.states.every((state) => state.answer !== undefined)) { + this.finish({ cancelled: false, answers: this.collectedAnswers() }); + return; + } + // Advance to the first unanswered question so a commit is visible and the + // tab strip keeps moving; committed answers stay reachable with Tab. + const next = this.states.findIndex((state) => state.answer === undefined); + if (next !== -1 && next !== questionIndex) this.focusedQuestion = next; + this.invalidate(); + } + + private collectedAnswers(): AnswerRow[] { + return this.states + .map((state) => state.answer) + .filter((answer): answer is AnswerRow => answer !== undefined); + } + + private finish(result: QuestionnaireResult): void { + if (this.completed) return; + this.completed = true; + this.result = result; + this.onComplete?.(result); + this.invalidate(); + } + + private matches( + data: string, + binding: "tui.select.up" | "tui.select.down" | "tui.select.confirm" | "tui.select.cancel", + ): boolean { + if (this.keybindings?.matches) return this.keybindings.matches(data, binding); + const key = binding === "tui.select.up" ? "up" + : binding === "tui.select.down" ? "down" + : binding === "tui.select.confirm" ? "enter" : "escape"; + return matchesKey(data, key); + } + + private matchesTab(data: string, shift: boolean): boolean { + if (!shift && this.keybindings?.matches) return this.keybindings.matches(data, "tui.input.tab"); + return matchesKey(data, shift ? "shift+tab" : "tab"); + } + + private mouseTarget(event: TuiMouseEvent) { + return { + component: this, + originX: event.screenX - event.x, + originY: event.screenY - event.y, + width: event.width, + height: event.height, + }; + } +} + +function padTo(line: string, width: number): string { + const padding = width - visibleWidth(line); + return padding > 0 ? `${line}${" ".repeat(padding)}` : line; +} diff --git a/lib/questionnaire/schema.ts b/lib/questionnaire/schema.ts new file mode 100644 index 000000000..13913bd6d --- /dev/null +++ b/lib/questionnaire/schema.ts @@ -0,0 +1,82 @@ +import { type Static, Type } from "typebox"; + +/** Maximum number of questions accepted by one questionnaire. */ +export const MAX_QUESTIONS = 4; + +/** Minimum number of authored options per question. */ +export const MIN_OPTIONS = 2; + +/** Maximum number of authored options per question. */ +export const MAX_OPTIONS = 4; + +/** Maximum length of a question header chip. */ +export const MAX_HEADER_LENGTH = 16; + +/** Maximum length of an authored option label. */ +export const MAX_LABEL_LENGTH = 60; + +/** + * Label of the free-text row that the view always appends after the authored + * options. Authors must not create an option that collides with it. + */ +export const CUSTOM_ROW_LABEL = "Type something."; + +const OptionSchema = Type.Object( + { + label: Type.String({ + maxLength: MAX_LABEL_LENGTH, + description: "Short user-facing option label", + }), + description: Type.String({ + description: "One-line explanation shown under the label", + }), + preview: Type.Optional(Type.String({ + description: "Optional markdown-flavored preview shown beside the focused option", + })), + }, + { additionalProperties: false }, +); + +const QuestionSchema = Type.Object( + { + question: Type.String({ description: "The full question text" }), + header: Type.String({ + maxLength: MAX_HEADER_LENGTH, + description: "Short progress header, at most 16 characters", + }), + options: Type.Array(OptionSchema, { + minItems: MIN_OPTIONS, + maxItems: MAX_OPTIONS, + description: "Two to four ordered options", + }), + multiSelect: Type.Optional(Type.Boolean({ + default: false, + description: "Allow selecting more than one option for this question", + })), + }, + { additionalProperties: false }, +); + +/** + * Typebox parameters for the native `ask_user_question` tool: one to four + * questions rendered together as a single questionnaire. + */ +export const QuestionParamsSchema = Type.Object( + { + questions: Type.Array(QuestionSchema, { + minItems: 1, + maxItems: MAX_QUESTIONS, + description: "One to four questions rendered as a single questionnaire", + }), + }, + { additionalProperties: false }, +); + +/** One authored option as received from the tool call. */ +export type OptionData = Static; + +/** One authored question as received from the tool call. */ +export type QuestionData = Static; + +/** Validated tool parameters for the questionnaire. */ +export type QuestionParams = Static; diff --git a/lib/questionnaire/validate.ts b/lib/questionnaire/validate.ts new file mode 100644 index 000000000..5bf7d031b --- /dev/null +++ b/lib/questionnaire/validate.ts @@ -0,0 +1,141 @@ +import { + CUSTOM_ROW_LABEL, + MAX_HEADER_LENGTH, + MAX_LABEL_LENGTH, + MAX_OPTIONS, + MAX_QUESTIONS, + MIN_OPTIONS, + type QuestionParams, +} from "./schema.ts"; + +/** + * A single questionnaire validation violation, keyed by `code` so callers can + * branch without parsing the human-readable message. + */ +export type QuestionnaireError = + | { code: "no_questions"; message: string } + | { code: "empty_options"; message: string; questionIndex: number } + | { code: "option_count"; message: string; questionIndex: number; count: number } + | { code: "too_many_questions"; message: string; count: number } + | { code: "duplicate_question"; message: string; questionIndex: number } + | { code: "duplicate_option_label"; message: string; questionIndex: number; optionIndex: number; label: string } + | { code: "label_too_long"; message: string; questionIndex: number; optionIndex: number; length: number } + | { code: "header_too_long"; message: string; questionIndex: number; length: number } + | { code: "reserved_label"; message: string; questionIndex: number; optionIndex: number; label: string }; + +/** + * Validate questionnaire parameters and return the first violation in a fixed + * precedence order, or `undefined` when the parameters are valid. + * + * Precedence: no questions, empty options, option count, question count, + * duplicate question text, duplicate option label, label length, header + * length, then reserved custom-row labels. + */ +export function validateQuestionnaire(params: QuestionParams): QuestionnaireError | undefined { + const questions = Array.isArray(params?.questions) ? params.questions : []; + + if (questions.length === 0) { + return { code: "no_questions", message: "At least one question is required." }; + } + + for (const [questionIndex, question] of questions.entries()) { + if (!Array.isArray(question?.options) || question.options.length === 0) { + return { + code: "empty_options", + message: `Question ${questionIndex + 1} must declare at least one option.`, + questionIndex, + }; + } + } + + for (const [questionIndex, question] of questions.entries()) { + const count = question.options.length; + if (count < MIN_OPTIONS || count > MAX_OPTIONS) { + return { + code: "option_count", + message: `Question ${questionIndex + 1} must have between ${MIN_OPTIONS} and ${MAX_OPTIONS} options, received ${count}.`, + questionIndex, + count, + }; + } + } + + if (questions.length > MAX_QUESTIONS) { + return { + code: "too_many_questions", + message: `A questionnaire accepts at most ${MAX_QUESTIONS} questions, received ${questions.length}.`, + count: questions.length, + }; + } + + const seenQuestions = new Set(); + for (const [questionIndex, question] of questions.entries()) { + if (seenQuestions.has(question.question)) { + return { + code: "duplicate_question", + message: `Question ${questionIndex + 1} duplicates an earlier question text.`, + questionIndex, + }; + } + seenQuestions.add(question.question); + } + + // Checked after duplicate questions so a repeated question points at the + // duplicate itself rather than one of its option labels. + for (const [questionIndex, question] of questions.entries()) { + const seenLabels = new Set(); + for (const [optionIndex, option] of question.options.entries()) { + if (seenLabels.has(option.label)) { + return { + code: "duplicate_option_label", + message: `Question ${questionIndex + 1} option ${optionIndex + 1} duplicates the label "${option.label}".`, + questionIndex, + optionIndex, + label: option.label, + }; + } + seenLabels.add(option.label); + } + } + + for (const [questionIndex, question] of questions.entries()) { + for (const [optionIndex, option] of question.options.entries()) { + if (option.label.length > MAX_LABEL_LENGTH) { + return { + code: "label_too_long", + message: `Question ${questionIndex + 1} option ${optionIndex + 1} label exceeds ${MAX_LABEL_LENGTH} characters.`, + questionIndex, + optionIndex, + length: option.label.length, + }; + } + } + } + + for (const [questionIndex, question] of questions.entries()) { + if (question.header.length > MAX_HEADER_LENGTH) { + return { + code: "header_too_long", + message: `Question ${questionIndex + 1} header exceeds ${MAX_HEADER_LENGTH} characters.`, + questionIndex, + length: question.header.length, + }; + } + } + + for (const [questionIndex, question] of questions.entries()) { + for (const [optionIndex, option] of question.options.entries()) { + if (option.label === "Other" || option.label === CUSTOM_ROW_LABEL) { + return { + code: "reserved_label", + message: `Question ${questionIndex + 1} option ${optionIndex + 1} uses the reserved label "${option.label}".`, + questionIndex, + optionIndex, + label: option.label, + }; + } + } + } + + return undefined; +} diff --git a/tests/ask-user-question.test.ts b/tests/ask-user-question.test.ts new file mode 100644 index 000000000..cc439bdc0 --- /dev/null +++ b/tests/ask-user-question.test.ts @@ -0,0 +1,435 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import askUserQuestion from "../extensions/ask-user-question.ts"; + +/** Plain theme fake: identity styling keeps rendered assertions readable. */ +interface Theme { + fg(color: string, text: string): string; + bg?(color: string, text: string): string; + bold?(text: string): string; +} + +const theme: Theme = { + fg: (_color: string, text: string) => text, + bg: (_color: string, text: string) => text, + bold: (text: string) => text, +}; + +interface Renderable { + render(width: number): string[]; + handleInput?(data: string): void; +} + +type CustomFactory = ( + tui: { requestRender(): void }, + theme: Theme, + keybindings: unknown, + done: (value: unknown) => void, +) => Renderable; + +interface ToolResult { + content: Array<{ type: string; text: string }>; + details: Record; +} + +interface RegisteredTool { + name: string; + label: string; + description?: string; + renderShell?: string; + promptGuidelines?: string[]; + parameters: { + additionalProperties?: boolean; + properties?: { + questions?: { + minItems?: number; + maxItems?: number; + items?: { additionalProperties?: boolean; properties?: Record }; + }; + }; + }; + executionMode?: string; + execute(...args: unknown[]): Promise; + renderCall(args: unknown, theme: Theme): Renderable; + renderResult(result: unknown, options: unknown, theme: Theme): Renderable; +} + +interface LifecycleEvent { + channel: string; + data: { active: boolean }; +} + +/** One fake extension slot: Pi keys tools per extension by name (`loader.js:240`). */ +interface ExtensionSlot { + path: string; + tools: Map; +} + +const OURS_PATH = "gentle-pi/extensions/ask-user-question.ts"; + +function registerQuestionTool(slot?: ExtensionSlot): { tool: RegisteredTool; slot: ExtensionSlot; emitted: LifecycleEvent[] } { + const target: ExtensionSlot = slot ?? { path: OURS_PATH, tools: new Map() }; + const emitted: LifecycleEvent[] = []; + const pi = { + registerTool(tool: RegisteredTool) { + target.tools.set(tool.name, tool); + }, + events: { + emit(channel: string, data: { active: boolean }) { + emitted.push({ channel, data }); + }, + }, + }; + askUserQuestion(pi as never); + const tool = target.tools.get("ask_user_question"); + if (!tool) throw new Error("ask_user_question must register"); + return { tool, slot: target, emitted }; +} + +function tuiContext(inputs: readonly string[], rendered?: { value: string }) { + return { + mode: "tui", + ui: { + custom: async (factory: CustomFactory) => { + let result: unknown; + const component = factory({ requestRender() {} }, theme, {}, (value) => { + result = value; + }); + if (rendered) rendered.value = component.render(100).join("\n"); + for (const input of inputs) component.handleInput?.(input); + return result; + }, + }, + }; +} + +function run(tool: RegisteredTool, params: unknown, ctx: unknown): Promise { + return tool.execute("call", params, new AbortController().signal, undefined, ctx); +} + +const option = (label: string, description = `${label} description`, preview?: string) => + preview === undefined ? { label, description } : { label, description, preview }; + +const single = () => [ + { question: "Proceed?", header: "Proceed", options: [option("Alpha"), option("Beta")] }, +]; + +test("ask_user_question opts out of the painted shell and pins the schema limits", () => { + const { tool } = registerQuestionTool(); + const questions = tool.parameters.properties?.questions; + + assert.equal(tool.renderShell, "self"); + assert.equal(tool.name, "ask_user_question"); + assert.equal(tool.label, "Ask User Question"); + assert.equal(tool.executionMode, "sequential"); + assert.equal(tool.parameters.additionalProperties, false); + assert.equal(questions?.minItems, 1); + assert.equal(questions?.maxItems, 4); + assert.equal(questions?.items?.additionalProperties, false); + assert.deepEqual(Object.keys(questions?.items?.properties ?? {}).sort(), ["header", "multiSelect", "options", "question"]); +}); + +test("ask_user_question guides the model toward the supported contract", () => { + const { tool } = registerQuestionTool(); + const guidelines = (tool.promptGuidelines ?? []).join(" "); + + assert.match(tool.description ?? "", /one to four structured questions/i); + assert.match(guidelines, /at most 16 characters/); + assert.match(guidelines, /at most 60 characters/); + assert.match(guidelines, /preview/); + assert.match(guidelines, /multiSelect/); + assert.match(guidelines, /Type something\./); + assert.match(guidelines, /Never use this tool for decisions that must not be delegated to the user\./); +}); + +test("ask_user_question rejects invalid parameters before mounting any UI", async () => { + const { tool, emitted } = registerQuestionTool(); + let customCalls = 0; + const ctx = { + mode: "tui", + ui: { + custom: async () => { + customCalls++; + return undefined; + }, + }, + }; + + const result = await run(tool, { questions: [] }, ctx); + + assert.equal(result.content[0]?.text, "Invalid questionnaire: At least one question is required."); + assert.equal(result.details.errorKind, "no_questions"); + assert.deepEqual(result.details.error, { code: "no_questions", message: "At least one question is required." }); + assert.equal(customCalls, 0, "invalid input never reaches ctx.ui.custom"); + assert.deepEqual(emitted, [], "invalid input emits no lifecycle event"); +}); + +test("ask_user_question stays unavailable outside the interactive TUI", async () => { + const { tool, emitted } = registerQuestionTool(); + let customCalls = 0; + const ctx = { + mode: "print", + ui: { + custom: async () => { + customCalls++; + return undefined; + }, + }, + }; + + const result = await run(tool, { questions: single() }, ctx); + + assert.match(result.content[0]?.text ?? "", /unavailable outside the interactive TUI/); + assert.equal(result.details.errorKind, "unavailable_outside_tui"); + assert.equal(customCalls, 0); + assert.deepEqual(emitted, []); +}); + +test("ask_user_question commits a single-select answer end-to-end", async () => { + const { tool, emitted } = registerQuestionTool(); + const rendered = { value: "" }; + + const result = await run(tool, { questions: single() }, tuiContext(["\r"], rendered)); + + assert.match(rendered.value, /\[1\/1\]/); + assert.match(rendered.value, /▸ Proceed/); + assert.match(rendered.value, /Alpha/); + assert.equal(result.content[0]?.text, "1. Proceed? — Alpha"); + assert.deepEqual(result.details.answers, [ + { questionIndex: 0, question: "Proceed?", kind: "option", answer: "Alpha" }, + ]); + assert.deepEqual(emitted, [ + { channel: "gentle-pi:ask-user-question:blocked", data: { active: true } }, + { channel: "gentle-pi:ask-user-question:blocked", data: { active: false } }, + ]); +}); + +test("ask_user_question mounts through ctx.ui.custom without an overlay option", async () => { + const { tool } = registerQuestionTool(); + let customArgCount = -1; + const ctx = { + mode: "tui", + ui: { + custom: async (...args: unknown[]) => { + customArgCount = args.length; + const factory = args[0] as CustomFactory; + let result: unknown; + const component = factory({ requestRender() {} }, theme, {}, (value) => { + result = value; + }); + component.handleInput?.("\r"); + return result; + }, + }, + }; + + await run(tool, { questions: single() }, ctx); + + assert.equal(customArgCount, 1, "a dock swap passes the factory only; no overlay options"); +}); + +test("ask_user_question mounts exactly one active question and switches with Tab", async () => { + const { tool } = registerQuestionTool(); + const questions = [ + { question: "First?", header: "First", options: [option("Alpha"), option("Beta")] }, + { question: "Second?", header: "Second", options: [option("Gamma"), option("Delta")] }, + ]; + let component: Renderable | undefined; + const pending = run(tool, { questions }, { + mode: "tui", + ui: { + custom: (factory: CustomFactory) => new Promise((resolve) => { + component = factory({ requestRender() {} }, theme, {}, resolve); + }), + }, + }); + + assert.ok(component, "the tool mounts the questionnaire component"); + const first = component!.render(100).join("\n"); + assert.match(first, /\[1\/2\]/); + assert.match(first, /▸ First/); + assert.match(first, /❯ Alpha/); + assert.doesNotMatch(first, /Gamma/); + assert.doesNotMatch(first, /Second\?/); + + component!.handleInput?.("\t"); + const second = component!.render(100).join("\n"); + assert.match(second, /\[2\/2\]/); + assert.match(second, /▸ Second/); + assert.match(second, /❯ Gamma/); + assert.doesNotMatch(second, /First\?/); + + component!.handleInput?.("\x1b"); // cancel to settle the tool + await pending; +}); + +test("ask_user_question echoes an option preview beside the answer", async () => { + const { tool } = registerQuestionTool(); + const questions = [ + { question: "Proceed?", header: "Proceed", options: [option("Alpha", "First choice", "Preview A"), option("Beta")] }, + ]; + + const result = await run(tool, { questions }, tuiContext(["\r"])); + + assert.equal(result.content[0]?.text, "1. Proceed? — Alpha\n selected preview: Preview A"); + assert.deepEqual(result.details.answers, [ + { questionIndex: 0, question: "Proceed?", kind: "option", answer: "Alpha", preview: "Preview A" }, + ]); +}); + +test("ask_user_question commits a multiSelect answer with every toggled option", async () => { + const { tool } = registerQuestionTool(); + const questions = [ + { question: "Pick?", header: "Pick", options: [option("One"), option("Two")], multiSelect: true }, + ]; + + const result = await run(tool, { questions }, tuiContext([" ", "\r"])); + + assert.equal(result.content[0]?.text, "1. Pick? — selected: One"); + assert.deepEqual(result.details.answers, [ + { questionIndex: 0, question: "Pick?", kind: "multi", answer: null, selected: ["One"] }, + ]); +}); + +test("ask_user_question commits a free-text custom answer", async () => { + const { tool } = registerQuestionTool(); + + const result = await run( + tool, + { questions: single() }, + tuiContext(["\x1b[B", "\x1b[B", "\r", "custom text", "\r"]), + ); + + assert.equal(result.content[0]?.text, "1. Proceed? — (custom) custom text"); + assert.deepEqual(result.details.answers, [ + { questionIndex: 0, question: "Proceed?", kind: "custom", answer: "custom text" }, + ]); +}); + +test("ask_user_question keeps toggled options in a multiSelect custom answer", async () => { + const { tool } = registerQuestionTool(); + const questions = [ + { question: "Pick?", header: "Pick", options: [option("One"), option("Two")], multiSelect: true }, + ]; + + // Toggle One, move to the custom row, open the editor, type, and submit. + const result = await run( + tool, + { questions }, + tuiContext([" ", "\x1b[B", "\x1b[B", "\r", "free note", "\r"]), + ); + + assert.equal(result.content[0]?.text, "1. Pick? — (custom) free note — selected: One"); + assert.deepEqual(result.details.answers, [ + { questionIndex: 0, question: "Pick?", kind: "custom", answer: "free note", selected: ["One"] }, + ]); + + const rendered = tool.renderResult(result, { expanded: false }, theme).render(200).join("\n"); + assert.equal(rendered.trimEnd(), "✓ Pick? — (custom) free note — selected: One"); +}); + +test("ask_user_question reports cancellation without answers", async () => { + const { tool, emitted } = registerQuestionTool(); + + const result = await run(tool, { questions: single() }, tuiContext(["\x1b"])); + + assert.equal(result.content[0]?.text, "User cancelled the questionnaire"); + assert.deepEqual(result.details, { cancelled: true }); + assert.deepEqual(emitted, [ + { channel: "gentle-pi:ask-user-question:blocked", data: { active: true } }, + { channel: "gentle-pi:ask-user-question:blocked", data: { active: false } }, + ]); +}); + +test("ask_user_question settles its lifecycle after a custom UI error", async () => { + const { tool, emitted } = registerQuestionTool(); + const failure = new Error("custom UI failed"); + + await assert.rejects( + () => run(tool, { questions: single() }, { mode: "tui", ui: { custom: async () => { throw failure; } } }), + (error: unknown) => error === failure, + ); + + assert.deepEqual(emitted, [ + { channel: "gentle-pi:ask-user-question:blocked", data: { active: true } }, + { channel: "gentle-pi:ask-user-question:blocked", data: { active: false } }, + ]); +}); + +test("ask_user_question owns an exclusive tool name across extensions", () => { + // Live-verified against the installed Pi runtime: tool names are exclusive + // across extensions. Loading two extensions that register + // `ask_user_question` aborts the whole load with a hard error + // (`Tool "ask_user_question" conflicts with `; the runtime + // exits non-zero) -- there is no precedence, override, or silent shadowing. + // This fake registry is a per-extension Map and cannot reproduce Pi's + // cross-extension load error, so it pins the part it can: our single + // registration owns the name within its own extension, and the runtime, not + // resource order, enforces exclusivity outside it. The competing + // `@juicesharp/rpiv-ask-user-question` package must be removed from the + // user's settings before this extension can load. + const ours: ExtensionSlot = { path: OURS_PATH, tools: new Map() }; + const registration = registerQuestionTool(ours); + + assert.equal(ours.tools.get("ask_user_question"), registration.tool, "the first-party extension owns its name"); + assert.equal(registration.tool.name, "ask_user_question"); + assert.equal(registration.tool.label, "Ask User Question"); +}); + +test("re-registering inside one extension overwrites its own tool entry", () => { + const slot: ExtensionSlot = { path: OURS_PATH, tools: new Map() }; + registerQuestionTool(slot); + const first = slot.tools.get("ask_user_question"); + registerQuestionTool(slot); + const second = slot.tools.get("ask_user_question"); + + assert.equal(slot.tools.size, 1, "the extension map is keyed by tool name"); + assert.notEqual(first, second, "a later registration replaces the same-name entry"); +}); + +test("ask_user_question renderCall summarizes the questions and option labels", () => { + const { tool } = registerQuestionTool(); + const rendered = tool.renderCall({ questions: single() }, theme).render(100).join("\n"); + + assert.match(rendered, /ask_user_question/); + assert.match(rendered, /1\. Proceed \(Alpha, Beta\)/); +}); + +test("ask_user_question renderCall truncates an oversized summary", () => { + const { tool } = registerQuestionTool(); + const labels = [option("a".repeat(60)), option("b".repeat(60)), option("c".repeat(60))]; + const rendered = tool.renderCall( + { questions: [{ question: "Long?", header: "Long", options: labels }] }, + theme, + ).render(200).join("\n"); + + assert.match(rendered, /…\s*$/); +}); + +test("ask_user_question renderResult renders answered and cancelled rows", () => { + const { tool } = registerQuestionTool(); + const answered = tool.renderResult( + { + content: [], + details: { + answers: [ + { questionIndex: 0, question: "Proceed?", kind: "option", answer: "Beta" }, + { questionIndex: 1, question: "Pick?", kind: "multi", answer: null, selected: ["One", "Two"] }, + { questionIndex: 2, question: "Explain?", kind: "custom", answer: "because" }, + ], + }, + }, + { expanded: false }, + theme, + ).render(200).join("\n"); + assert.match(answered, /✓ Proceed\? — Beta/); + assert.match(answered, /✓ Pick\? — One, Two/); + assert.match(answered, /✓ Explain\? — \(custom\) because/); + + const cancelled = tool.renderResult( + { content: [], details: { cancelled: true } }, + { expanded: false }, + theme, + ).render(200).join("\n"); + assert.match(cancelled, /Cancelled/); +}); diff --git a/tests/questionnaire-schema.test.ts b/tests/questionnaire-schema.test.ts new file mode 100644 index 000000000..2645a7b84 --- /dev/null +++ b/tests/questionnaire-schema.test.ts @@ -0,0 +1,274 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { Value } from "typebox/value"; +import { + CUSTOM_ROW_LABEL, + MAX_HEADER_LENGTH, + MAX_LABEL_LENGTH, + MAX_OPTIONS, + MAX_QUESTIONS, + MIN_OPTIONS, + QuestionParamsSchema, + type OptionData, + type QuestionParams, +} from "../lib/questionnaire/schema.ts"; +import { validateQuestionnaire, type QuestionnaireError } from "../lib/questionnaire/validate.ts"; + +const H = "Header"; +const option = (label: string, preview?: string): OptionData => + preview === undefined ? { label, description: `${label} description` } : { label, description: `${label} description`, preview }; + +const question = ( + text: string, + options: OptionData[], + overrides: { header?: string; multiSelect?: boolean } = {}, +) => ({ + question: text, + header: overrides.header ?? H, + options, + ...(overrides.multiSelect === undefined ? {} : { multiSelect: overrides.multiSelect }), +}); + +const params = (questions: ReturnType[]): QuestionParams => ({ questions }); +const valid = (): ReturnType[] => [question("First?", [option("Alpha"), option("Beta")])]; + +interface Case { + name: string; + input: ReturnType; + expected: QuestionnaireError | undefined; +} + +function makeCases(): Case[] { + return [ + { name: "accepts a minimal valid questionnaire", input: params(valid()), expected: undefined }, + { + name: "accepts exactly one question with two options", + input: params([question("Solo?", [option("A"), option("B")])]), + expected: undefined, + }, + { + name: "accepts exactly four questions and four options", + input: params([ + question("Q1", [option("A1"), option("B1"), option("C1"), option("D1")]), + question("Q2", [option("A2"), option("B2")]), + question("Q3", [option("A3"), option("B3")]), + question("Q4", [option("A4"), option("B4")]), + ]), + expected: undefined, + }, + { + name: "accepts a header at the 16 character limit", + input: params([question("Long header?", [option("A"), option("B")], { header: "x".repeat(MAX_HEADER_LENGTH) })]), + expected: undefined, + }, + { + name: "accepts a label at the 60 character limit", + input: params([question("Long label?", [option("y".repeat(MAX_LABEL_LENGTH)), option("B")])]), + expected: undefined, + }, + { + name: "rejects an empty question list", + input: params([]), + expected: { code: "no_questions", message: "At least one question is required." }, + }, + { + name: "rejects a question with empty options", + input: params([question("Empty?", [])]), + expected: { + code: "empty_options", + message: "Question 1 must declare at least one option.", + questionIndex: 0, + }, + }, + { + name: "rejects a single option below the minimum", + input: params([question("One?", [option("Only")])]), + expected: { + code: "option_count", + message: "Question 1 must have between 2 and 4 options, received 1.", + questionIndex: 0, + count: 1, + }, + }, + { + name: "rejects five options above the maximum", + input: params([question("Five?", [option("A"), option("B"), option("C"), option("D"), option("E")])]), + expected: { + code: "option_count", + message: "Question 1 must have between 2 and 4 options, received 5.", + questionIndex: 0, + count: 5, + }, + }, + { + name: "rejects more than four questions", + input: params([ + question("Q1", [option("A1"), option("B1")]), + question("Q2", [option("A2"), option("B2")]), + question("Q3", [option("A3"), option("B3")]), + question("Q4", [option("A4"), option("B4")]), + question("Q5", [option("A5"), option("B5")]), + ]), + expected: { + code: "too_many_questions", + message: "A questionnaire accepts at most 4 questions, received 5.", + count: 5, + }, + }, + { + name: "rejects duplicate question text", + input: params([ + question("Same?", [option("A1"), option("B1")]), + question("Same?", [option("A2"), option("B2")]), + ]), + expected: { + code: "duplicate_question", + message: "Question 2 duplicates an earlier question text.", + questionIndex: 1, + }, + }, + { + name: "rejects a duplicate option label within one question", + input: params([question("Dupe?", [option("Alpha"), option("Alpha")])]), + expected: { + code: "duplicate_option_label", + message: 'Question 1 option 2 duplicates the label "Alpha".', + questionIndex: 0, + optionIndex: 1, + label: "Alpha", + }, + }, + { + name: "rejects a label over the 60 character limit", + input: params([question("Too long?", [option("z".repeat(MAX_LABEL_LENGTH + 1)), option("B")])]), + expected: { + code: "label_too_long", + message: "Question 1 option 1 label exceeds 60 characters.", + questionIndex: 0, + optionIndex: 0, + length: MAX_LABEL_LENGTH + 1, + }, + }, + { + name: "rejects a header over the 16 character limit", + input: params([question("Header too long?", [option("A"), option("B")], { header: "x".repeat(MAX_HEADER_LENGTH + 1) })]), + expected: { + code: "header_too_long", + message: "Question 1 header exceeds 16 characters.", + questionIndex: 0, + length: MAX_HEADER_LENGTH + 1, + }, + }, + { + name: "rejects the reserved Other label", + input: params([question("Reserved?", [option("Other"), option("B")])]), + expected: { + code: "reserved_label", + message: 'Question 1 option 1 uses the reserved label "Other".', + questionIndex: 0, + optionIndex: 0, + label: "Other", + }, + }, + { + name: "rejects the reserved custom-row label", + input: params([question("Reserved?", [option(CUSTOM_ROW_LABEL), option("B")])]), + expected: { + code: "reserved_label", + message: `Question 1 option 1 uses the reserved label "${CUSTOM_ROW_LABEL}".`, + questionIndex: 0, + optionIndex: 0, + label: CUSTOM_ROW_LABEL, + }, + }, + { + name: "does not treat a reserved label with different casing as reserved", + input: params([question("Case?", [option("other"), option("type something.")])]), + expected: undefined, + }, + { + name: "reports a duplicated question before its duplicated option label", + input: params([ + question("Same?", [option("A"), option("B")]), + question("Same?", [option("A"), option("B")]), + ]), + expected: { + code: "duplicate_question", + message: "Question 2 duplicates an earlier question text.", + questionIndex: 1, + }, + }, + ]; +} + +test("validateQuestionnaire reports the first violation for each guard", () => { + for (const testCase of makeCases()) { + const actual = validateQuestionnaire(testCase.input); + assert.deepEqual(actual, testCase.expected, testCase.name); + } +}); + +test("schema constants expose the documented limits", () => { + assert.equal(MAX_QUESTIONS, 4); + assert.equal(MIN_OPTIONS, 2); + assert.equal(MAX_OPTIONS, 4); + assert.equal(MAX_HEADER_LENGTH, 16); + assert.equal(MAX_LABEL_LENGTH, 60); + assert.equal(CUSTOM_ROW_LABEL, "Type something."); +}); + +test("QuestionParamsSchema pins the questionnaire parameter shape", () => { + const root = QuestionParamsSchema as unknown as { + additionalProperties?: boolean; + properties?: { + questions?: { + minItems?: number; + maxItems?: number; + items?: { + additionalProperties?: boolean; + properties?: Record; + required?: string[]; + }; + }; + }; + }; + const questions = root.properties?.questions; + const questionSchema = questions?.items; + const questionProperties = questionSchema?.properties ?? {}; + + assert.equal(root.additionalProperties, false); + assert.equal(questions?.minItems, 1); + assert.equal(questions?.maxItems, MAX_QUESTIONS); + assert.equal(questionSchema?.additionalProperties, false); + assert.deepEqual([...(questionSchema?.required ?? [])].sort(), ["header", "options", "question"]); + assert.deepEqual(Object.keys(questionProperties).sort(), ["header", "multiSelect", "options", "question"]); +}); + +test("schema rejects malformed questionnaire parameters", () => { + const validParams = params(valid()); + assert.equal(Value.Check(QuestionParamsSchema, validParams), true); + + const invalidCases: Array<[string, unknown]> = [ + ["missing questions", {}], + ["empty questions", { questions: [] }], + ["five questions", params([ + question("Q1", [option("A1"), option("B1")]), + question("Q2", [option("A2"), option("B2")]), + question("Q3", [option("A3"), option("B3")]), + question("Q4", [option("A4"), option("B4")]), + question("Q5", [option("A5"), option("B5")]), + ])], + ["one option", params([question("One?", [option("Only")])])], + ["label over the limit", params([question("Long?", [option("z".repeat(MAX_LABEL_LENGTH + 1)), option("B")])])], + ["header over the limit", params([question("Long?", [option("A"), option("B")], { header: "x".repeat(MAX_HEADER_LENGTH + 1) })])], + ["unknown root property", { questions: valid(), extra: true }], + ]; + for (const [name, subject] of invalidCases) { + assert.equal(Value.Check(QuestionParamsSchema, subject), false, name); + } +}); + +test("schema accepts multiSelect and preview as optional fields", () => { + const withOptionals = params([question("Options?", [option("A", "Preview A"), option("B")], { multiSelect: true })]); + assert.equal(Value.Check(QuestionParamsSchema, withOptionals), true); +}); diff --git a/tests/questionnaire-view.test.ts b/tests/questionnaire-view.test.ts new file mode 100644 index 000000000..9ea1ebae3 --- /dev/null +++ b/tests/questionnaire-view.test.ts @@ -0,0 +1,446 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + CURSOR_MARKER, + KeybindingsManager, + TUI_KEYBINDINGS, + visibleWidth, + type TuiMouseEvent, +} from "@earendil-works/pi-tui"; +import { + MIN_PREVIEW_WIDTH, + QuestionnaireView, + type QuestionnaireResult, + type QuestionnaireTheme, +} from "../lib/questionnaire/questionnaire-view.ts"; +import { CUSTOM_ROW_LABEL, type OptionData, type QuestionData } from "../lib/questionnaire/schema.ts"; + +const theme: QuestionnaireTheme = { + fg: (_color: string, text: string) => text, + bold: (text: string) => text, +}; + +const option = (label: string, description = `${label} description`, preview?: string): OptionData => + preview === undefined ? { label, description } : { label, description, preview }; + +const question = ( + text: string, + options: OptionData[], + overrides: { header?: string; multiSelect?: boolean } = {}, +): QuestionData => ({ + question: text, + header: overrides.header ?? "Header", + options, + ...(overrides.multiSelect === undefined ? {} : { multiSelect: overrides.multiSelect }), +}); + +const single = (preview?: string): QuestionData[] => [ + question("Proceed?", [option("Alpha", "First choice", preview), option("Beta", "Second choice")]), +]; + +const two = (): QuestionData[] => [ + question("First?", [option("Alpha"), option("Beta")], { header: "First" }), + question("Second?", [option("Gamma"), option("Delta")], { header: "Second" }), +]; + +function createView( + questions: QuestionData[], + options: { onComplete?: (result: QuestionnaireResult) => void; keybindings?: KeybindingsManager } = {}, +): QuestionnaireView { + return new QuestionnaireView({ + questions, + theme, + onComplete: options.onComplete, + ...(options.keybindings === undefined ? {} : { keybindings: options.keybindings }), + }); +} + +function viewWithResult(questions: QuestionData[], keybindings?: KeybindingsManager) { + const completed: QuestionnaireResult[] = []; + const view = createView(questions, { + onComplete: (result) => completed.push(result), + ...(keybindings === undefined ? {} : { keybindings }), + }); + return { view, completed }; +} + +function render(view: QuestionnaireView, width = 100): string { + return view.render(width).join("\n"); +} + +/** Strip SGR styling so assertions see the visible text, not cursor escape codes. */ +function plain(text: string): string { + return text.replace(/\x1b\[[0-9;]*m/g, ""); +} + +function assertFits(view: QuestionnaireView, width: number): void { + for (const line of view.render(width)) { + assert.ok( + visibleWidth(line) <= width, + `rendered line exceeds width ${width}: ${JSON.stringify(line)}`, + ); + } +} + +function mouseEvent(lines: string[], row: number, type: TuiMouseEvent["type"]): TuiMouseEvent { + return { + type, + button: "left", + x: 1, + y: row, + screenX: 1, + screenY: row, + width: 100, + height: lines.length, + shift: false, + alt: false, + ctrl: false, + }; +} + +// Real terminal key sequences: legacy (ESC [) and application-cursor (ESC O). +const KEY = { + up: ["\x1b[A", "\x1bOA"], + down: ["\x1b[B", "\x1bOB"], + enter: "\r", + space: " ", + tab: "\t", + shiftTab: "\x1b[Z", + escape: "\x1b", +} as const; + +test("renders exactly one active question plus a tab strip for all questions", () => { + const { view } = viewWithResult(two()); + const rendered = render(view); + + // Tab strip: progress plus every header, active one marked. + assert.match(rendered, /\[1\/2\]/); + assert.match(rendered, /▸ First/); + assert.match(rendered, /Second/); + + // Only the first question body renders; the second body is absent. + assert.match(rendered, /First\?/); + assert.match(rendered, /❯ Alpha/); + assert.doesNotMatch(rendered, /Gamma/); + assert.doesNotMatch(rendered, /Delta/); + assert.doesNotMatch(rendered, /Second\?/); +}); + +test("Tab and Shift-Tab switch the active question and preserve cursor and toggles", () => { + const { view, completed } = viewWithResult([ + question("First?", [option("Alpha"), option("Beta")], { header: "First" }), + question("Second?", [option("Gamma"), option("Delta")], { header: "Second", multiSelect: true }), + ]); + + view.handleInput(KEY.down[0]); // First: cursor -> Beta + view.handleInput(KEY.tab); + let rendered = render(view); + assert.match(rendered, /\[2\/2\]/); + assert.match(rendered, /▸ Second/); + assert.match(rendered, /❯ \[ \] Gamma/); + assert.doesNotMatch(rendered, /❯ Beta/); + assert.doesNotMatch(rendered, /First\?/); + + view.handleInput(KEY.space); // Second: toggle Gamma + assert.match(render(view), /❯ \[x\] Gamma/); + + view.handleInput(KEY.shiftTab); + rendered = render(view); + assert.match(rendered, /\[1\/2\]/); + assert.match(rendered, /❯ Beta/); // cursor preserved + assert.doesNotMatch(rendered, /Gamma/); // second body hidden again + + view.handleInput(KEY.tab); + assert.match(render(view), /❯ \[x\] Gamma/); // toggle preserved + assert.equal(completed.length, 0); +}); + +test("real key sequences drive the cursor and commit in legacy and application-cursor form", () => { + for (const down of KEY.down) { + const { view, completed } = viewWithResult(single()); + view.handleInput(down); + assert.match(render(view), /❯ Beta/, `down sequence ${JSON.stringify(down)} should move the cursor`); + view.handleInput(KEY.enter); + assert.equal(completed.length, 1, `enter after ${JSON.stringify(down)} should commit`); + assert.deepEqual(view.getResult().answers, [ + { questionIndex: 0, question: "Proceed?", kind: "option", answer: "Beta" }, + ]); + } + + for (const up of KEY.up) { + const { view } = viewWithResult(single()); + view.handleInput(KEY.down[0]); + view.handleInput(KEY.down[0]); + view.handleInput(up); + assert.match(render(view), /❯ Beta/, `up sequence ${JSON.stringify(up)} should move the cursor back`); + } +}); + +test("the injected KeybindingsManager drives the same navigation and commit", () => { + const keybindings = new KeybindingsManager(TUI_KEYBINDINGS); + const { view, completed } = viewWithResult(single(), keybindings); + + assert.ok(keybindings.matches(KEY.down[0], "tui.select.down")); + view.handleInput(KEY.down[0]); + assert.match(render(view), /❯ Beta/); + view.handleInput(KEY.enter); + assert.equal(completed.length, 1); + assert.equal(view.getResult().answers[0]?.answer, "Beta"); +}); + +test("single-select commits on Enter and advances to the next unanswered question", () => { + const { view, completed } = viewWithResult(two()); + + view.handleInput(KEY.enter); + assert.equal(completed.length, 0, "the questionnaire is not done while a question is unanswered"); + assert.equal(view.getResult().answers.length, 1); + assert.equal(view.activeQuestion, 1, "a commit advances to the next unanswered question"); + assert.match(render(view), /\[2\/2\]/); + assert.match(render(view), /❯ Gamma/); + + view.handleInput(KEY.enter); + assert.equal(completed.length, 1); + assert.deepEqual(view.getResult().answers, [ + { questionIndex: 0, question: "First?", kind: "option", answer: "Alpha" }, + { questionIndex: 1, question: "Second?", kind: "option", answer: "Gamma" }, + ]); +}); + +test("multi-select toggles with space and requires a non-empty selection to commit", () => { + const { view, completed } = viewWithResult([ + question("Pick?", [option("One"), option("Two")], { multiSelect: true }), + ]); + assert.match(render(view), /\[ \] One/); + + view.handleInput(KEY.enter); + assert.equal(completed.length, 0, "an empty multiSelect commit is a no-op"); + assert.equal(view.getResult().answers.length, 0); + + view.handleInput(KEY.space); + assert.match(render(view), /\[x\] One/); + + view.handleInput(KEY.down[0]); + view.handleInput(KEY.space); + assert.match(render(view), /\[x\] Two/); + + view.handleInput(KEY.space); + assert.match(render(view), /\[ \] Two/); + + view.handleInput(KEY.enter); + assert.equal(completed.length, 1); + assert.deepEqual(view.getResult().answers, [ + { questionIndex: 0, question: "Pick?", kind: "multi", answer: null, selected: ["One"] }, + ]); +}); + +test("custom row opens the editor, empty submit returns, and the draft survives a tab switch", () => { + const { view, completed } = viewWithResult(two()); + + // Move to the custom row on question one and open the editor. + view.handleInput(KEY.down[0]); + view.handleInput(KEY.down[0]); + view.handleInput(KEY.enter); + assert.match(render(view), /Custom response/); + assert.match(render(view), /> /); + + view.handleInput(" "); + view.handleInput(KEY.enter); + assert.equal(completed.length, 0, "a whitespace-only submission stays uncommitted"); + assert.doesNotMatch(render(view), /Custom response/); + + // Reopen, type a draft, switch away, and come back: the draft is preserved. + view.handleInput(KEY.enter); + view.handleInput("draft text"); + view.handleInput(KEY.tab); + assert.equal(view.activeQuestion, 1, "tab switches question even while editing"); + view.handleInput(KEY.shiftTab); + assert.equal(view.activeQuestion, 0); + view.handleInput(KEY.enter); // still on the custom row + assert.match(plain(render(view)), /draft text/, "the custom draft is restored on reopen"); + + view.handleInput(KEY.enter); + assert.equal(completed.length, 0, "one of two questions answered is not completion"); + assert.deepEqual(view.getResult().answers, [ + { questionIndex: 0, question: "First?", kind: "custom", answer: "draft text" }, + ]); +}); + +test("multi-select can commit a custom answer with the toggled options", () => { + const { view } = viewWithResult([question("Pick?", [option("One"), option("Two")], { multiSelect: true })]); + view.handleInput(KEY.space); + view.handleInput(KEY.down[0]); + view.handleInput(KEY.down[0]); + view.handleInput(KEY.enter); + + assert.match(render(view), /Custom response/); + view.handleInput("free note"); + view.handleInput(KEY.enter); + + assert.deepEqual(view.getResult().answers, [ + { questionIndex: 0, question: "Pick?", kind: "custom", answer: "free note", selected: ["One"] }, + ]); +}); + +test("preview renders beside the list only while the focused option has one", () => { + const { view } = viewWithResult(single("Preview body line")); + const wide = render(view, 100); + assert.match(wide, /Preview body line/); + assert.match(wide, /❯ Alpha/); + + view.handleInput(KEY.down[0]); // Beta has no preview + const collapsed = render(view, 100); + assert.doesNotMatch(collapsed, /Preview body line/); + assert.match(collapsed, /❯ Beta/); + assertFits(view, 100); +}); + +test("the preview pane wraps cleanly and never exceeds the terminal width", () => { + const body = "lorem ipsum dolor sit amet ".repeat(12); + const { view } = viewWithResult([ + question("Proceed?", [option("Alpha", "First choice", body), option("Beta")]), + ]); + for (const width of [MIN_PREVIEW_WIDTH, 100, 120]) { + assertFits(view, width); + } + assert.match(render(view, 100), /lorem ipsum/); +}); + +test("narrow widths render the preview inline without corrupting the body", () => { + const { view } = viewWithResult(single("Inline preview body")); + const narrow = render(view, 60); + assert.match(narrow, /Inline preview body/); + assert.match(narrow, /❯ Alpha/); + assert.match(narrow, /Type something\./); + assertFits(view, 60); +}); + +test("height stays bounded as the question count grows", () => { + const one = viewWithResult([question("Only?", [option("A"), option("B")], { header: "Only" })]); + const many = viewWithResult([ + question("One?", [option("A"), option("B")], { header: "One" }), + question("Two?", [option("C"), option("D")], { header: "Two" }), + question("Three?", [option("E"), option("F")], { header: "Three" }), + question("Four?", [option("G"), option("H")], { header: "Four" }), + ]); + + const oneLines = one.view.render(100).length; + const manyLines = many.view.render(100).length; + assert.ok( + manyLines <= oneLines + 2, + `four questions must not stack bodies (one=${oneLines}, many=${manyLines})`, + ); + // Only the first body renders, so later bodies stay absent. + const rendered = render(many.view, 100); + assert.doesNotMatch(rendered, /Two\?/); + assert.doesNotMatch(rendered, /Four\?/); + assert.match(rendered, /❯ A/); +}); + +test("Escape cancels the questionnaire with a cancelled result", () => { + const { view, completed } = viewWithResult(single()); + view.handleInput(KEY.escape); + assert.equal(completed.length, 1); + assert.deepEqual(view.getResult(), { cancelled: true, answers: [] }); +}); + +test("focus propagates to the free-text input for IME cursor positioning", () => { + const { view } = viewWithResult(single()); + view.handleInput(KEY.down[0]); + view.handleInput(KEY.down[0]); + view.handleInput(KEY.enter); + + view.focused = true; + assert.ok(render(view).includes(CURSOR_MARKER), "a focused view positions the input cursor"); + + view.focused = false; + assert.ok(!render(view).includes(CURSOR_MARKER), "an unfocused view hides the input cursor"); +}); + +test("a committed option carries its preview on the answer row", () => { + const { view } = viewWithResult(single("Preview body line")); + view.handleInput(KEY.enter); + assert.deepEqual(view.getResult().answers, [ + { questionIndex: 0, question: "Proceed?", kind: "option", answer: "Alpha", preview: "Preview body line" }, + ]); +}); + +test("pointer press focuses a row and click commits it", () => { + const { view, completed } = viewWithResult(single()); + const lines = view.render(100); + const betaRow = lines.findIndex((line) => line.includes("Beta")); + assert.ok(betaRow >= 0); + + assert.equal(view.handleMouse(mouseEvent(lines, betaRow, "press"))?.handled, true); + assert.equal(completed.length, 0, "press focuses but never answers"); + const afterPress = view.render(100); + const betaRowAfterPress = afterPress.findIndex((line) => line.includes("Beta")); + assert.equal(view.handleMouse(mouseEvent(afterPress, betaRowAfterPress, "click"))?.handled, true); + assert.equal(completed.length, 1); + assert.equal(view.getResult().answers[0]?.answer, "Beta"); +}); + +test("pointer click toggles a multi-select option instead of committing", () => { + const { view, completed } = viewWithResult([ + question("Pick?", [option("One"), option("Two")], { multiSelect: true }), + ]); + + let lines = view.render(100); + assert.equal(view.handleMouse(mouseEvent(lines, lines.findIndex((line) => line.includes("One")), "click"))?.handled, true); + assert.equal(completed.length, 0, "a multi-select click toggles without committing"); + assert.match(render(view), /\[x\] One/); + + // Clicking the same option again un-toggles it; still no commit. + lines = view.render(100); + view.handleMouse(mouseEvent(lines, lines.findIndex((line) => line.includes("One")), "click")); + assert.match(render(view), /\[ \] One/); + assert.equal(completed.length, 0); + + // Toggle two options with the mouse, then commit with the keyboard. + lines = view.render(100); + view.handleMouse(mouseEvent(lines, lines.findIndex((line) => line.includes("One")), "click")); + lines = view.render(100); + view.handleMouse(mouseEvent(lines, lines.findIndex((line) => line.includes("Two")), "click")); + assert.match(render(view), /\[x\] One/); + assert.match(render(view), /\[x\] Two/); + + view.handleInput(KEY.enter); + assert.equal(completed.length, 1); + assert.deepEqual(view.getResult().answers, [ + { questionIndex: 0, question: "Pick?", kind: "multi", answer: null, selected: ["One", "Two"] }, + ]); +}); + +test("pointer click on the custom row opens the editor without committing", () => { + for (const questions of [single(), [question("Pick?", [option("One"), option("Two")], { multiSelect: true })]]) { + const { view, completed } = viewWithResult(questions); + const lines = view.render(100); + const customRow = lines.findIndex((line) => line.includes(CUSTOM_ROW_LABEL)); + assert.ok(customRow >= 0); + + assert.equal(view.handleMouse(mouseEvent(lines, customRow, "click"))?.handled, true); + assert.match(render(view), /Custom response/); + assert.equal(completed.length, 0, "opening the custom editor never commits"); + } +}); + +test("the custom row is always appended after the authored options", () => { + const { view } = viewWithResult(single()); + const lines = view.render(100); + const betaRow = lines.findIndex((line) => line.includes("Beta")); + const customRow = lines.findIndex((line) => line.includes(CUSTOM_ROW_LABEL)); + assert.ok(betaRow >= 0 && customRow >= 0); + assert.ok(customRow > betaRow); +}); + +test("every rendered line fits the width across 1-4 questions", () => { + const questions = [ + question("One?", [option("A"), option("B", "B description", "preview ".repeat(20))], { header: "One" }), + question("Two?", [option("C"), option("D")], { header: "Two", multiSelect: true }), + question("Three?", [option("E"), option("F")], { header: "Three" }), + question("Four?", [option("G"), option("H")], { header: "Four" }), + ]; + const { view } = viewWithResult(questions); + for (const width of [40, 60, 80, 100, 140]) { + assertFits(view, width); + } +}); diff --git a/tests/review-controller-native-routing.test.ts b/tests/review-controller-native-routing.test.ts index 718603bc4..e17969e42 100644 --- a/tests/review-controller-native-routing.test.ts +++ b/tests/review-controller-native-routing.test.ts @@ -986,6 +986,65 @@ test("ordinary START materializes an excluded-untracked candidate exactly once b assert.equal(startCalls, 1); }); +test("ordinary START adopts the offered committed-range base while an untracked selection is in play", async (t) => { + // Live-reproduced blocker (gentle-pi#874): the committed-range adoption was + // skipped whenever an untracked selection was in play, so the provider's + // base-diff projection was compared against a base-less candidate view and + // assertNativeStartCandidateBinding failed with + // candidate-target-projection-drift before native START ever ran. An in-play + // selection must still pay the second read-only STATUS and materialize the + // candidate view WITH the offered base. + const cwd = repository(t); + // Commit the candidate change so a real committed range exists: the offered + // base commit's tree is the projection baseTree and the committed changed + // path is the candidate scope. + execFileSync("git", ["add", "tracked.txt"], { cwd }); + execFileSync("git", ["-c", "user.name=Routing Test", "-c", "user.email=routing@example.invalid", "commit", "-m", "candidate"], { cwd }); + const baseRef = execFileSync("git", ["rev-parse", "HEAD~1"], { cwd, encoding: "utf8" }).trim(); + const selection = { untrackedScope: "exclude", expectedUntrackedInventory: "inventory-sha256", intendedUntracked: [] as string[] }; + const offered = startStatus(cwd); + offered.nextTransition = { + kind: "execute", + reasonCode: "start_required", + execute: { + operation: "review.start", + arguments: [{ name: "base-ref", value: baseRef }, { name: "committed-only", value: "true" }], + preconditions: [], + binding: {}, + }, + } as unknown as ReviewStatusV3["nextTransition"]; + const adopted = startStatus(cwd, baseRef, []); + const requests: Array> = []; + const candidateViews = new CandidateViewRegistry(); + t.after(() => candidateViews.cleanupAll()); + const creation = t.mock.method(candidateViews, "createOrReuse"); + let startCalls = 0; + const native = { + targetStatus: async (request: Record) => { + requests.push(request); + return requests.length === 1 ? offered : adopted; + }, + start: async () => { + startCalls += 1; + return { lineageId: "adopted-base-untracked", state: "reviewing", riskLevel: "low", selectedLenses: [], changedFiles: 1, changedLines: 1, correctionBudget: 1, action: "created", lensesRequired: false, riskReasons: [], raw: {} }; + }, + } as unknown as NativeReviewCli; + + const result = await __testing.executeReviewControllerOperation( + { operation: "start", input: JSON.stringify({ mode: "ordinary", ...selection }) }, + cwd, native, undefined, candidateViews, + ); + + assert.equal(result.operation, "start"); + assert.deepEqual(requests, [ + { cwd, agent: "pi", ...selection }, + { cwd, agent: "pi", baseRef, committedOnly: true, ...selection }, + ]); + assert.equal(creation.mock.callCount(), 1); + assert.equal(creation.mock.calls[0]!.arguments[0].baseRef, baseRef, "the candidate view adopts the offered base"); + assert.equal(startCalls, 1, "native START is reached exactly once without drift"); +}); + test("ordinary START preserves sanitized foreign diagnostics through one ambiguous reconciliation", async (t) => { const cwd = repository(t); const target = startStatus(cwd);