import { describe, expect, test } from "bun:test"; import { setTimeout as delay } from "node:timers/promises"; import { type AutocompleteProvider, visibleWidth, } from "@earendil-works/pi-tui"; import { StructuredPromptForm, type StructuredPromptFormResult, } from "./form.ts"; import { PROMPT_SECTIONS } from "./formatter.ts"; const ENTER = "\r"; const ESCAPE = "\x1b"; const ARROW_DOWN = "\x1b[B"; const PAGE_DOWN = "\x1b[6~"; const HOME = "\x1b[H"; const CTRL_T = "\x14"; const CTRL_Y = "\x19"; const BRACKETED_PASTE_START = "\x1b[200~"; const BRACKETED_PASTE_END = "\x1b[201~"; describe("structured-prompt form", () => { test("submits entered section values from the review screen", () => { // Purpose: users must review the generated prompt before it can be submitted. // Input and expected output: text entered in Goal is submitted only after the review confirmation. // Edge case: empty later sections are allowed and are returned for formatter filtering. // Dependencies: this test uses the form component with fake TUI and theme dependencies. const observedResults: StructuredPromptFormResult[] = []; const form = createForm((result) => observedResults.push(result)); typeText(form, "Create structured requests"); for (const _section of PROMPT_SECTIONS) { form.handleInput(ENTER); } expect(observedResults).toEqual([]); form.handleInput(ENTER); expect(observedResults).toEqual([ { kind: "submitted", values: [ { sectionId: "goal", value: "Create structured requests" }, { sectionId: "task", value: "" }, { sectionId: "context", value: "" }, { sectionId: "criteria", value: "" }, { sectionId: "constraints", value: "" }, { sectionId: "work-order", value: "" }, ], }, ]); }); test("preserves pasted multi-line text in a section", () => { // Purpose: request sections must support multi-line content. // Input and expected output: one pasted chunk with a newline is submitted unchanged for the active section. // Edge case: the newline is part of section content and does not advance the wizard. // Dependencies: this test uses the form component with fake TUI and theme dependencies. const observedResults: StructuredPromptFormResult[] = []; const form = createForm((result) => observedResults.push(result)); typeText(form, "Line one\nLine two"); for (const _section of PROMPT_SECTIONS) { form.handleInput(ENTER); } form.handleInput(ENTER); expect(observedResults[0]).toEqual({ kind: "submitted", values: expect.arrayContaining([ { sectionId: "goal", value: "Line one\nLine two" }, ]), }); }); test("selects @ file autocomplete before advancing to the next section", async () => { // Purpose: file references selected from the section dropdown must become section text. // Input and expected output: @READ opens a README.md suggestion, Enter selects it, and later Enter advances the form. // Edge case: Enter must go to the editor while autocomplete is open instead of saving a partial prefix. // Dependencies: this test uses the Pi Editor autocomplete contract with a fake provider. const observedResults: StructuredPromptFormResult[] = []; const provider = createAutocompleteProviderFake(); const form = createForm((result) => observedResults.push(result), { autocompleteProvider: provider, }); typeCharacters(form, "@READ"); const suggestionRender = await waitForRenderedText(form, "README.md"); expect(suggestionRender.join("\n")).toContain("README.md"); form.handleInput(ENTER); for (const _section of PROMPT_SECTIONS) { form.handleInput(ENTER); } expect(observedResults).toEqual([]); form.handleInput(ENTER); expect(observedResults[0]).toEqual({ kind: "submitted", values: expect.arrayContaining([ { sectionId: "goal", value: "@README.md " }, ]), }); }); test("keeps bracketed pasted filename and backslash render-safe", () => { // Purpose: pasted filename text must not leak terminal paste control bytes into the editor. // Input and expected output: bracketed paste plus backslash renders within width and submits plain text. // Edge case: the cursor is after a trailing backslash. // Dependencies: this test uses fake TUI rendering and visible width measurement. const observedResults: StructuredPromptFormResult[] = []; const form = createForm((result) => observedResults.push(result)); const filename = "pi-package/README.md"; form.handleInput( `${BRACKETED_PASTE_START}${filename}${BRACKETED_PASTE_END}`, ); form.handleInput("\\"); const rows = form.render(120); for (const _section of PROMPT_SECTIONS) { form.handleInput(ENTER); } form.handleInput(ENTER); const renderedText = rows.join("\n"); expect(renderedText).toContain(`${filename}\\`); expect(renderedText).not.toContain(BRACKETED_PASTE_START); expect(renderedText).not.toContain(BRACKETED_PASTE_END); expect(rows.every((row) => visibleWidth(row) <= 120)).toBe(true); expect(observedResults[0]).toEqual({ kind: "submitted", values: expect.arrayContaining([ { sectionId: "goal", value: `${filename}\\` }, ]), }); }); test("keeps fragmented bracketed pasted filename render-safe", () => { // Purpose: paste control handling must survive terminals that split paste into chunks. // Input and expected output: fragmented bracketed paste plus backslash submits plain text. // Edge case: the paste start and paste end markers arrive in different input chunks. // Dependencies: this test uses fake TUI rendering and visible width measurement. const observedResults: StructuredPromptFormResult[] = []; const form = createForm((result) => observedResults.push(result)); const filename = "README.md"; form.handleInput(`${BRACKETED_PASTE_START}READ`); form.handleInput(`ME.md${BRACKETED_PASTE_END}`); form.handleInput("\\"); const rows = form.render(80); for (const _section of PROMPT_SECTIONS) { form.handleInput(ENTER); } form.handleInput(ENTER); const renderedText = rows.join("\n"); expect(renderedText).toContain(`${filename}\\`); expect(renderedText).not.toContain(BRACKETED_PASTE_START); expect(renderedText).not.toContain(BRACKETED_PASTE_END); expect(rows.every((row) => visibleWidth(row) <= 80)).toBe(true); expect(observedResults[0]).toEqual({ kind: "submitted", values: expect.arrayContaining([ { sectionId: "goal", value: `${filename}\\` }, ]), }); }); test("submits expanded content from large bracketed paste", () => { // Purpose: large pasted text must not be submitted as an internal paste marker. // Input and expected output: bracketed paste with many lines submits the original text. // Edge case: Pi Editor may render a compact paste marker for large paste. // Dependencies: this test checks the form result, not the editor display string. const observedResults: StructuredPromptFormResult[] = []; const form = createForm((result) => observedResults.push(result)); const pastedText = numberedLines(12); form.handleInput( `${BRACKETED_PASTE_START}${pastedText}${BRACKETED_PASTE_END}`, ); for (const _section of PROMPT_SECTIONS) { form.handleInput(ENTER); } form.handleInput(ENTER); expect(observedResults[0]).toEqual({ kind: "submitted", values: expect.arrayContaining([ { sectionId: "goal", value: pastedText }, ]), }); }); test("does not insert navigation escape sequences into section text", () => { // Purpose: terminal navigation keys must remain editor controls, not prompt content. // Input and expected output: arrow-up after typed text is not submitted as section text. // Edge case: the arrow key is a multi-byte terminal sequence. // Dependencies: this test uses the form component with fake TUI and theme dependencies. const observedResults: StructuredPromptFormResult[] = []; const form = createForm((result) => observedResults.push(result)); typeText(form, "Goal text"); form.handleInput("\x1b[A"); for (const _section of PROMPT_SECTIONS) { form.handleInput(ENTER); } form.handleInput(ENTER); expect(observedResults[0]).toEqual({ kind: "submitted", values: expect.arrayContaining([ { sectionId: "goal", value: "Goal text" }, ]), }); }); test("cancels without submitting values", () => { // Purpose: users need an explicit no-send exit path. // Input and expected output: Escape reports cancellation and no submitted values. // Edge case: cancellation works after editing has started. // Dependencies: this test uses the form component with fake TUI and theme dependencies. const observedResults: StructuredPromptFormResult[] = []; const form = createForm((result) => observedResults.push(result)); typeText(form, "Draft text"); form.handleInput(ESCAPE); expect(observedResults).toEqual([{ kind: "cancelled" }]); }); test("renders the form inside a bright outer border", () => { // Purpose: the prompt dialog must be visually separated from the chat background. // Input and expected output: rendered rows include a full outer border. // Edge case: the frame is present before any section text is entered. // Dependencies: this test uses structural border checks and visible width measurement. const form = createForm(() => {}); const rows = form.render(48); expect(rows.length).toBeGreaterThan(2); expect(rows[0]).toContain("┏"); expect(rows[0]).toContain("┓"); expect(rows.at(-1)).toContain("┗"); expect(rows.at(-1)).toContain("┛"); expect(rows.every((row) => visibleWidth(row) <= 48)).toBe(true); }); test("keeps bordered rows within width when the theme emits ANSI colors", () => { // Purpose: real Pi themes add ANSI color sequences that must not break layout width. // Input and expected output: styled border and styled labels still fit within the requested width. // Edge case: border and content styling are both present. // Dependencies: this test uses public visible width measurement. const form = new StructuredPromptForm({ tui: { terminal: { rows: 40 }, requestRender(): void {}, } as never, theme: { fg: (_color, value) => `\u001b[96m${value}\u001b[39m`, bold: (value) => `\u001b[1m${value}\u001b[22m`, }, sections: PROMPT_SECTIONS, onDone: () => {}, }); const rows = form.render(48); expect(rows.every((row) => visibleWidth(row) <= 48)).toBe(true); }); test("bounds long review output to the terminal height", () => { // Purpose: long generated prompts must not overflow the terminal during review. // Input and expected output: a long review renders no more rows than the terminal height. // Edge case: wrapped frame, header, help, and preview rows all count against the limit. // Dependencies: this test uses the form component with a small fake terminal height. const form = createForm(() => {}, { rows: 12 }); openReviewWithGoal(form, numberedLines(80)); const rows = form.render(64); expect(rows.length).toBeLessThanOrEqual(12); expect(rows.join("\n")).toContain("line-001"); expect(rows.join("\n")).not.toContain("line-080"); }); test("scrolls the long review preview without moving the frame", () => { // Purpose: users must be able to inspect long generated prompts before sending. // Input and expected output: Down and PageDown change visible preview lines, Home returns to the start. // Edge case: scrolling affects only the review preview area. // Dependencies: this test uses terminal escape sequences for navigation keys. const form = createForm(() => {}, { rows: 12 }); openReviewWithGoal(form, numberedLines(80)); const initialRows = form.render(64).join("\n"); form.handleInput(ARROW_DOWN); const downRows = form.render(64).join("\n"); form.handleInput(PAGE_DOWN); const pageRows = form.render(64).join("\n"); form.handleInput(HOME); const homeRows = form.render(64).join("\n"); expect(initialRows).toContain("line-001"); expect(downRows).not.toBe(initialRows); expect(pageRows).not.toBe(downRows); expect(homeRows).toBe(initialRows); }); test("keeps unicode review rendering height and width stable while scrolling", () => { // Purpose: scrolling a long Unicode review must not leave stale overlay rows behind. // Input and expected output: long mixed-script text renders with a stable bounded row count before and after scrolling. // Edge case: lines include Cyrillic, emoji, CJK, accents, RTL text, symbols, and wrapped rows. // Dependencies: this test uses the form component with fake TUI and plain theme. const terminalRows = 18; const width = 72; const form = createForm(() => {}, { rows: terminalRows }); openReviewWithGoal(form, unicodeLines(50)); const firstRender = form.render(width); form.handleInput(PAGE_DOWN); const secondRender = form.render(width); form.handleInput(HOME); const thirdRender = form.render(width); expect(firstRender).toHaveLength(terminalRows - 2); expect(secondRender).toHaveLength(firstRender.length); expect(thirdRender).toHaveLength(firstRender.length); for (const render of [firstRender, secondRender, thirdRender]) { for (const row of render) { expect(visibleWidth(row)).toBeLessThanOrEqual(width); } } expect(firstRender.join("\n")).toContain("line-001"); expect(secondRender.join("\n")).not.toBe(firstRender.join("\n")); expect(thirdRender.join("\n")).toBe(firstRender.join("\n")); }); test("can scroll to the bottom of a wrapped narrow review", () => { // Purpose: narrow terminals wrap preview lines and must still allow reaching the end. // Input and expected output: repeated PageDown reaches the final visual content. // Edge case: scroll limits use the current rendered width, not a fixed width. // Dependencies: this test renders before scrolling so the component can measure the viewport. const form = createForm(() => {}, { rows: 12 }); const longGoal = Array.from( { length: 30 }, (_value, index) => `line-${String(index + 1).padStart(3, "0")} has a long wrapped suffix`, ).join("\n"); openReviewWithGoal(form, longGoal); form.render(32); for (let index = 0; index < 20; index += 1) { form.handleInput(PAGE_DOWN); } const rows = form.render(32).join("\n"); expect(rows).toContain("line-030"); }); test("submits the full generated prompt after review scrolling", () => { // Purpose: review scrolling must not truncate the message sent to the agent. // Input and expected output: after scrolling, submit returns the complete section value. // Edge case: the submitted value contains lines that were not visible in the review viewport. // Dependencies: this test uses the form component result instead of Pi delivery. const observedResults: StructuredPromptFormResult[] = []; const form = createForm((result) => observedResults.push(result), { rows: 12, }); const longGoal = numberedLines(80); openReviewWithGoal(form, longGoal); form.handleInput(PAGE_DOWN); form.handleInput(ENTER); expect(observedResults).toEqual([ { kind: "submitted", values: expect.arrayContaining([ { sectionId: "goal", value: longGoal }, ]), }, ]); }); test("copies the full generated prompt from review without closing it", async () => { // Purpose: users must be able to copy the generated prompt before deciding whether to send. // Input and expected output: Ctrl+Y reports the full formatted prompt and Enter can still submit. // Edge case: the copied text contains rows that are outside the current review viewport. // Dependencies: this test uses a callback fake for the clipboard action. const observedResults: StructuredPromptFormResult[] = []; const copiedPrompts: string[] = []; const form = createForm((result) => observedResults.push(result), { onCopyPrompt: (promptText) => copiedPrompts.push(promptText), rows: 12, }); const longGoal = numberedLines(80); openReviewWithGoal(form, longGoal); form.handleInput(PAGE_DOWN); form.handleInput(CTRL_Y); await waitForCopySettlement(); form.handleInput(ENTER); expect(copiedPrompts).toEqual([["## Goal", longGoal].join("\n")]); expect(observedResults).toEqual([ { kind: "submitted", values: expect.arrayContaining([ { sectionId: "goal", value: longGoal }, ]), }, ]); }); test("waits for review copy before allowing submit", async () => { // Purpose: copying before sending must not race with an immediate Enter press. // Input and expected output: Enter is ignored while Ctrl+Y copy is still pending, then works after copy finishes. // Edge case: the clipboard callback is asynchronous. // Dependencies: this test uses a deferred callback fake for the clipboard action. const observedResults: StructuredPromptFormResult[] = []; const copy = createDeferred(); const form = createForm((result) => observedResults.push(result), { onCopyPrompt: () => copy.promise, rows: 12, }); const longGoal = numberedLines(80); openReviewWithGoal(form, longGoal); form.handleInput(CTRL_Y); form.handleInput(ENTER); expect(observedResults).toEqual([]); copy.resolve(); await copy.promise; await waitForCopySettlement(); form.handleInput(ENTER); expect(observedResults).toEqual([ { kind: "submitted", values: expect.arrayContaining([ { sectionId: "goal", value: longGoal }, ]), }, ]); }); test("unblocks submit after a synchronous review copy failure", async () => { // Purpose: a clipboard callback defect must not leave review actions blocked. // Input and expected output: a synchronous copy failure is swallowed and a later Enter submits. // Edge case: the callback throws before returning a Promise. // Dependencies: this test uses a throwing callback fake for the clipboard action. const observedResults: StructuredPromptFormResult[] = []; const form = createForm((result) => observedResults.push(result), { onCopyPrompt: () => { throw new Error("copy callback failed"); }, rows: 12, }); const longGoal = numberedLines(80); openReviewWithGoal(form, longGoal); form.handleInput(CTRL_Y); await waitForCopySettlement(); form.handleInput(ENTER); expect(observedResults).toEqual([ { kind: "submitted", values: expect.arrayContaining([ { sectionId: "goal", value: longGoal }, ]), }, ]); }); test("returns the full generated prompt for input placement without submitting", () => { // Purpose: users must be able to place the generated prompt in the main input instead of sending it. // Input and expected output: Ctrl+T returns an inserted result with all section values. // Edge case: Enter after Ctrl+T does not create a later submit result because the form is closed. // Dependencies: this test uses the form component result instead of Pi editor integration. const observedResults: StructuredPromptFormResult[] = []; const form = createForm((result) => observedResults.push(result), { rows: 12, }); const longGoal = numberedLines(80); openReviewWithGoal(form, longGoal); form.handleInput(PAGE_DOWN); form.handleInput(CTRL_T); form.handleInput(ENTER); expect(observedResults).toEqual([ { kind: "inserted", values: expect.arrayContaining([ { sectionId: "goal", value: longGoal }, ]), }, ]); }); test("keeps rendered rows within the requested width", () => { // Purpose: the custom overlay must honor the TUI render width contract. // Input and expected output: narrow and normal widths produce rows within those widths. // Edge case: long entered text is present before rendering. // Dependencies: this test uses public visible width measurement. const form = createForm(() => {}); typeText( form, "A very long goal that must be wrapped or clipped inside the structured prompt form overlay", ); for (const width of [32, 80]) { const rows = form.render(width); expect(rows.length).toBeGreaterThan(0); expect(rows.every((row) => visibleWidth(row) <= width)).toBe(true); } }); }); function createForm( onDone: (result: StructuredPromptFormResult) => void, options: { readonly autocompleteProvider?: AutocompleteProvider; readonly onCopyPrompt?: (promptText: string) => void; readonly rows?: number; } = {}, ): StructuredPromptForm { return new StructuredPromptForm({ tui: { terminal: { rows: options.rows ?? 40 }, requestRender(): void {}, } as never, theme: { fg: (_color, value) => value, bold: (value) => value, }, sections: PROMPT_SECTIONS, ...(options.autocompleteProvider === undefined ? {} : { autocompleteProvider: options.autocompleteProvider }), onCopyPrompt: options.onCopyPrompt, onDone, }); } function openReviewWithGoal(form: StructuredPromptForm, goal: string): void { typeText(form, goal); for (const _section of PROMPT_SECTIONS) { form.handleInput(ENTER); } } function numberedLines(count: number): string { return Array.from( { length: count }, (_value, index) => `line-${String(index + 1).padStart(3, "0")}`, ).join("\n"); } function unicodeLines(count: number): string { return Array.from( { length: count }, (_value, index) => `line-${String(index + 1).padStart(3, "0")} кириллица, emoji 🧊🚀, CJK 漢字, kana かな, accents café naïve, RTL مثال, symbols ∞ Ω Σ and wrapped suffix.`, ).join("\n"); } function typeText(form: StructuredPromptForm, value: string): void { form.handleInput(value); } function typeCharacters(form: StructuredPromptForm, value: string): void { for (const char of value) { form.handleInput(char); } } async function waitForRenderedText( form: StructuredPromptForm, text: string, ): Promise { let latestRows = form.render(80); for (let attempt = 0; attempt < 50; attempt += 1) { if (latestRows.join("\n").includes(text)) { return latestRows; } await delay(10); latestRows = form.render(80); } return latestRows; } function createAutocompleteProviderFake(): AutocompleteProvider { return { async getSuggestions(lines, cursorLine, cursorCol) { const currentLine = lines[cursorLine] ?? ""; const prefix = currentLine.slice(0, cursorCol); if (!prefix.startsWith("@")) { return null; } return { prefix, items: [{ value: "@README.md", label: "README.md" }], }; }, applyCompletion(lines, cursorLine, cursorCol, item, prefix) { const currentLine = lines[cursorLine] ?? ""; const beforePrefix = currentLine.slice(0, cursorCol - prefix.length); const afterCursor = currentLine.slice(cursorCol); const newLines = [...lines]; newLines[cursorLine] = `${beforePrefix}${item.value} ${afterCursor}`; return { lines: newLines, cursorLine, cursorCol: beforePrefix.length + item.value.length + 1, }; }, }; } async function waitForCopySettlement(): Promise { for (let index = 0; index < 5; index += 1) { await Promise.resolve(); } } function createDeferred(): { readonly promise: Promise; readonly resolve: () => void; } { let resolvePromise: (() => void) | undefined; const promise = new Promise((resolve) => { resolvePromise = resolve; }); if (resolvePromise === undefined) { throw new Error("deferred promise resolver was not initialized"); } return { promise, resolve: resolvePromise }; }