From 52dfc4f9a657d8b6a3e876d6407465d4eab67dfc Mon Sep 17 00:00:00 2001 From: Erik Kotsiuba Date: Fri, 17 Apr 2026 15:31:16 +0200 Subject: [PATCH] feat(ask): add preview pane and wrapped content --- README.md | 7 +- src/ask-component.ts | 201 +++++++++++++++++++++++++++++++++---------- src/ask-tool.ts | 2 +- src/schema.ts | 6 +- src/state.ts | 3 +- src/text.ts | 100 +++++++++++++++++++++ src/types.ts | 4 +- tests/state.test.ts | 2 +- tests/text.test.ts | 29 +++++++ 9 files changed, 294 insertions(+), 60 deletions(-) create mode 100644 src/text.ts create mode 100644 tests/text.test.ts diff --git a/README.md b/README.md index f4adcdc..a8261fe 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,7 @@ Note: some Zed settings such as global extension auto-install and autosave are n The extension registers a tool named `ask_user`. Use it when the agent needs structured clarification before proceeding. +Use `type: "preview"` when the choice needs a dedicated preview pane (for example, code snippets, layouts, configuration examples, or other rich previews). ### Input shape @@ -84,7 +85,6 @@ Use it when the agent needs structured clarification before proceeding. label?: string, prompt: string, type?: "single" | "multi" | "preview", - allowOther?: boolean, required?: boolean, // metadata only; submit is never blocked options: [ { @@ -150,8 +150,7 @@ The current prototype supports: - tabbed multi-question flow - single-select questions - multi-select questions -- preview questions with a dedicated preview pane and no `Type your own` fallback -- fixed `Type your own` fallback option that preserves typed text when dismissed +- preview questions with a dedicated preview pane - number-key quick selection - final submit/review page - transcript rendering for call/result rows @@ -168,9 +167,9 @@ Ask 1-3 concise questions. Use short tab labels. Prefer 2-4 options per question. Include descriptions for each option. -Allow custom input when the fixed choices may be insufficient. Use `type: "single"` unless multiple options can genuinely apply. Use `type: "multi"` only when the user may need to select several answers. +Use `type: "preview"` when an option needs a preview panel. After answers are returned, continue the task using those answers explicitly. ``` diff --git a/src/ask-component.ts b/src/ask-component.ts index 3af14b8..a622392 100644 --- a/src/ask-component.ts +++ b/src/ask-component.ts @@ -7,6 +7,7 @@ import { truncateToWidth, visibleWidth, } from "@mariozechner/pi-tui"; +import { wrapText } from "./text.ts"; import { applyNumberShortcut, cancelFlow, @@ -293,33 +294,70 @@ function renderSubmit( theme: ExtensionContext["ui"]["theme"], width: number, ) { - const add = (text = "") => lines.push(truncateToWidth(text, width)); - add(` ${theme.fg("accent", theme.bold("Review your answers"))}`); - add(); + pushWrappedText( + lines, + "Review your answers", + width, + theme, + "accent", + " ", + " ", + ); + lines.push(""); for (const question of state.questions) { const answer = getAnswer(state, question.id); - add(` ${theme.fg("muted", `● ${question.prompt}`)}`); + pushWrappedText( + lines, + question.prompt, + width, + theme, + "muted", + " ● ", + " ", + ); if (!answer) { - add(` ${theme.fg("warning", "→ (unanswered)")}`); + lines.push( + truncateToWidth(` ${theme.fg("warning", "→ (unanswered)")}`, width), + ); continue; } const answerText = answer.customText ?? answer.labels.join(", "); - add( - ` ${theme.fg(answer.customText ? "text" : "success", `→ ${answerText}`)}`, + pushWrappedText( + lines, + `→ ${answerText}`, + width, + theme, + answer.customText ? "text" : "success", + " ", + " ", ); } - add(); - add(` ${theme.fg("success", "Ready to submit your answers?")}`); - add(); + lines.push(""); + pushWrappedText( + lines, + "Ready to submit your answers?", + width, + theme, + "success", + " ", + " ", + ); + lines.push(""); const options = ["Submit answers", "Cancel"]; for (let i = 0; i < options.length; i++) { const selected = i === state.submitIndex; - const prefix = selected ? theme.fg("accent", "❯ ") : " "; - add( - `${prefix}${theme.fg(selected ? "accent" : "text", `${i + 1}. ${options[i]}`)}`, + const prefix = selected ? "❯ " : " "; + pushWrappedText( + lines, + `${i + 1}. ${options[i]}`, + width, + theme, + selected ? "accent" : "text", + prefix, + prefix, ); } } @@ -371,7 +409,7 @@ function renderQuestionPrompt( theme: ExtensionContext["ui"]["theme"], width: number, ) { - lines.push(truncateToWidth(` ${theme.fg("text", prompt)}`, width)); + pushWrappedText(lines, prompt, width, theme, "text", " ", " "); lines.push(""); } @@ -385,7 +423,6 @@ function renderStandardQuestion( editor: Editor, newLineHint: string, ) { - const add = (text = "") => lines.push(truncateToWidth(text, width)); const isMulti = question.type === "multi"; const answer = getAnswer(state, question.id); for (let i = 0; i < options.length; i++) { @@ -394,7 +431,7 @@ function renderStandardQuestion( const isAnsweredOption = option.isOther ? !!answer?.customText : !!answer?.values.includes(option.value); - const pointer = selected ? theme.fg("accent", "❯ ") : " "; + const pointer = selected ? "❯ " : " "; const prefix = isMulti && !option.isOther ? `[${isAnsweredOption ? "x" : " "}] ` : ""; const optionColor = isAnsweredOption @@ -402,26 +439,54 @@ function renderStandardQuestion( : selected ? "accent" : "text"; - add( - `${pointer}${theme.fg(optionColor, `${i + 1}. ${prefix}${option.label}`)}`, + pushWrappedText( + lines, + `${i + 1}. ${prefix}${option.label}`, + width, + theme, + optionColor, + pointer, + " ".repeat(visibleWidth(pointer)), ); if (option.description) { - add(` ${theme.fg("muted", option.description)}`); + pushWrappedText( + lines, + option.description, + width, + theme, + "muted", + " ", + " ", + ); } if (option.isOther && selected && state.mode === "input") { for (const editorLine of editor.render(Math.max(8, width - 7))) { - add(` ${renderInputLine(editorLine, width - 5, theme)}`); + lines.push( + truncateToWidth( + ` ${renderInputLine(editorLine, width - 5, theme)}`, + width, + ), + ); } - add( - ` ${theme.fg("dim", `${newLineHint} newline · Enter submit · Esc save and close`)}`, + lines.push( + truncateToWidth( + ` ${theme.fg("dim", `${newLineHint} newline · Enter submit · Esc save and close`)}`, + width, + ), ); } if (option.isOther && state.mode !== "input") { const customText = answer?.customText; if (customText) { - for (const customLine of customText.split("\n")) { - add(` ${theme.fg("muted", customLine)}`); - } + pushWrappedText( + lines, + customText, + width, + theme, + "muted", + " ", + " ", + ); } } } @@ -476,7 +541,10 @@ function renderPreviewQuestion( if (selectedOption?.isOther && state.mode !== "input" && answer?.customText) { add(""); - for (const customLine of answer.customText.split("\n")) { + for (const customLine of wrapText( + answer.customText, + Math.max(1, width - 5), + )) { add(` ${theme.fg("muted", customLine)}`); } } @@ -490,7 +558,6 @@ function renderPreviewOptionList( width: number, ): string[] { const lines: string[] = []; - const add = (text = "") => lines.push(truncateToWidth(text, width)); const answer = getAnswer(state, question.id); for (let i = 0; i < options.length; i++) { const option = options[i]; @@ -498,15 +565,31 @@ function renderPreviewOptionList( const isAnsweredOption = option.isOther ? !!answer?.customText : !!answer?.values.includes(option.value); - const pointer = selected ? theme.fg("accent", "❯ ") : " "; + const pointer = selected ? "❯ " : " "; const optionColor = isAnsweredOption ? "success" : selected ? "accent" : "text"; - add(`${pointer}${theme.fg(optionColor, `${i + 1}. ${option.label}`)}`); + pushWrappedText( + lines, + `${i + 1}. ${option.label}`, + width, + theme, + optionColor, + pointer, + " ".repeat(visibleWidth(pointer)), + ); if (option.description) { - add(` ${theme.fg("muted", option.description)}`); + pushWrappedText( + lines, + option.description, + width, + theme, + "muted", + " ", + " ", + ); } } return lines; @@ -520,36 +603,39 @@ function renderPreviewPane( const paneWidth = Math.max(10, width); if (!selectedOption) { return renderBox( - [theme.fg("dim", "No preview available")], + [{ text: "No preview available", color: "dim" }], paneWidth, theme, ); } - const content: string[] = []; - content.push(theme.fg("accent", theme.bold(selectedOption.label))); + const content: Array<{ + text: string; + color: "accent" | "muted" | "text" | "dim"; + }> = []; + content.push({ text: selectedOption.label, color: "accent" }); if (selectedOption.description) { - content.push(theme.fg("muted", selectedOption.description)); + content.push({ text: selectedOption.description, color: "muted" }); } if (selectedOption.preview) { if (content.length) { - content.push(""); + content.push({ text: "", color: "dim" }); } for (const previewLine of selectedOption.preview.split("\n")) { - content.push(theme.fg("text", previewLine)); + content.push({ text: previewLine, color: "text" }); } } else { if (content.length) { - content.push(""); + content.push({ text: "", color: "dim" }); } - content.push(theme.fg("dim", "No preview available")); + content.push({ text: "No preview available", color: "dim" }); } return renderBox(content, paneWidth, theme); } function renderBox( - content: string[], + content: Array<{ text: string; color: "accent" | "muted" | "text" | "dim" }>, width: number, theme: ExtensionContext["ui"]["theme"], ): string[] { @@ -558,12 +644,14 @@ function renderBox( const top = theme.fg("accent", `┌${"─".repeat(innerWidth)}┐`); const bottom = theme.fg("accent", `└${"─".repeat(innerWidth)}┘`); const lines = [top]; - for (const rawLine of content) { - const line = truncateToWidth(rawLine, innerWidth); - const padding = " ".repeat(Math.max(0, innerWidth - visibleWidth(line))); - lines.push( - theme.fg("accent", "│") + line + padding + theme.fg("accent", "│"), - ); + for (const item of content) { + for (const rawLine of wrapText(item.text, innerWidth)) { + const line = theme.fg(item.color, rawLine); + const padding = " ".repeat(Math.max(0, innerWidth - visibleWidth(line))); + lines.push( + theme.fg("accent", "│") + line + padding + theme.fg("accent", "│"), + ); + } } lines.push(bottom); return lines; @@ -613,6 +701,29 @@ function clamp(value: number, min: number, max: number): number { return Math.min(max, Math.max(min, value)); } +function pushWrappedText( + lines: string[], + text: string, + width: number, + theme: ExtensionContext["ui"]["theme"], + color: "accent" | "muted" | "text" | "dim" | "success" | "warning", + prefix = "", + continuationPrefix = prefix, +) { + const availableWidth = Math.max(1, width - visibleWidth(prefix)); + const wrapped = wrapText(text, availableWidth); + for (let index = 0; index < wrapped.length; index++) { + const line = wrapped[index]; + const currentPrefix = index === 0 ? prefix : continuationPrefix; + lines.push( + truncateToWidth(`${currentPrefix}${theme.fg(color, line)}`, width), + ); + } + if (wrapped.length === 0) { + lines.push(truncateToWidth(prefix, width)); + } +} + function renderFooter( state: AskState, theme: ExtensionContext["ui"]["theme"], diff --git a/src/ask-tool.ts b/src/ask-tool.ts index 5f28c44..f8a27cf 100644 --- a/src/ask-tool.ts +++ b/src/ask-tool.ts @@ -11,7 +11,7 @@ export function registerAskTool(pi: ExtensionAPI) { name: "ask_user", label: "Ask User", description: - "Ask the user one or more structured questions. Supports single-select, multi-select, and typed custom answers. Use this when clarification is required before proceeding.", + "Ask the user one or more structured questions when clarification is required. Supports single-select, multi-select, and preview questions. Use `preview` when an option needs a dedicated preview pane (for example, code, layout, or configuration previews). Return the selected answers as structured data instead of guessing.", parameters: AskParamsSchema, async execute(_toolCallId, params: AskParams, _signal, _onUpdate, ctx) { diff --git a/src/schema.ts b/src/schema.ts index 757fb25..391606f 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -10,7 +10,8 @@ export const AskOptionSchema = Type.Object({ ), preview: Type.Optional( Type.String({ - description: "Optional preview text, used by richer question variants", + description: + "Optional preview text shown in the dedicated preview pane for preview questions", }), ), }); @@ -27,9 +28,6 @@ export const AskQuestionSchema = Type.Object({ { description: "Question type" }, ), ), - allowOther: Type.Optional( - Type.Boolean({ description: "Include a fixed 'Type something.' option" }), - ), required: Type.Optional( Type.Boolean({ description: "Optional metadata flag; submit is never blocked", diff --git a/src/state.ts b/src/state.ts index 9e8e234..d080052 100644 --- a/src/state.ts +++ b/src/state.ts @@ -24,7 +24,6 @@ function normalizeQuestion( ...question, label: question.label?.trim() || `Q${index + 1}`, type: question.type ?? "single", - allowOther: question.allowOther !== false, required: question.required !== false, }; } @@ -57,7 +56,7 @@ export function getRenderableOptions(question?: AskQuestion): RenderOption[] { return []; } const options: RenderOption[] = [...question.options]; - if (question.type !== "preview" && question.allowOther) { + if (question.type !== "preview") { options.push({ value: OTHER_VALUE, label: "Type your own", diff --git a/src/text.ts b/src/text.ts new file mode 100644 index 0000000..34a2f60 --- /dev/null +++ b/src/text.ts @@ -0,0 +1,100 @@ +import { visibleWidth } from "@mariozechner/pi-tui"; + +export function wrapText(text: string, width: number): string[] { + const effectiveWidth = Math.max(1, width); + const lines: string[] = []; + const paragraphs = text.split("\n"); + + for (const paragraph of paragraphs) { + if (paragraph.length === 0) { + lines.push(""); + continue; + } + + const leadingWhitespace = paragraph.match(/^\s*/)?.[0] ?? ""; + const body = paragraph.slice(leadingWhitespace.length).trim(); + if (!body) { + lines.push(leadingWhitespace); + continue; + } + + const wrapped = wrapParagraph( + body, + effectiveWidth - visibleWidth(leadingWhitespace), + ); + if (wrapped.length === 0) { + lines.push(leadingWhitespace); + continue; + } + + for (const [index, part] of wrapped.entries()) { + lines.push( + index === 0 + ? `${leadingWhitespace}${part}` + : `${leadingWhitespace}${part}`, + ); + } + } + + return lines.length > 0 ? lines : [""]; +} + +function wrapParagraph(text: string, width: number): string[] { + const effectiveWidth = Math.max(1, width); + const words = text.split(/\s+/).filter(Boolean); + const lines: string[] = []; + let current = ""; + + for (const word of words) { + if (!current) { + if (visibleWidth(word) <= effectiveWidth) { + current = word; + } else { + lines.push(...breakLongWord(word, effectiveWidth)); + } + continue; + } + + const candidate = `${current} ${word}`; + if (visibleWidth(candidate) <= effectiveWidth) { + current = candidate; + continue; + } + + lines.push(current); + if (visibleWidth(word) <= effectiveWidth) { + current = word; + } else { + lines.push(...breakLongWord(word, effectiveWidth)); + current = ""; + } + } + + if (current) { + lines.push(current); + } + + return lines; +} + +function breakLongWord(word: string, width: number): string[] { + const effectiveWidth = Math.max(1, width); + const chunks: string[] = []; + let current = ""; + + for (const char of Array.from(word)) { + const candidate = `${current}${char}`; + if (visibleWidth(candidate) > effectiveWidth && current) { + chunks.push(current); + current = char; + continue; + } + current = candidate; + } + + if (current) { + chunks.push(current); + } + + return chunks; +} diff --git a/src/types.ts b/src/types.ts index 176a7e3..fae8b74 100644 --- a/src/types.ts +++ b/src/types.ts @@ -12,7 +12,6 @@ export interface AskQuestionInput { label?: string; prompt: string; type?: AskQuestionType; - allowOther?: boolean; required?: boolean; options: AskOption[]; } @@ -23,10 +22,9 @@ export interface AskParams { } export interface AskQuestion - extends Omit { + extends Omit { label: string; type: AskQuestionType; - allowOther: boolean; required: boolean; } diff --git a/tests/state.test.ts b/tests/state.test.ts index 8d6e275..ec43d8b 100644 --- a/tests/state.test.ts +++ b/tests/state.test.ts @@ -54,12 +54,12 @@ test("normalize defaults via initial state", () => { assert.equal(state.questions[0].label, "Q1"); assert.equal(state.questions[0].type, "single"); - assert.equal(state.questions[0].allowOther, true); assert.equal(state.questions[0].required, true); assert.equal( getRenderableOptions(state.questions[0]).at(-1)?.label, "Type your own", ); + assert.equal(getRenderableOptions(state.questions[0]).at(-1)?.isOther, true); }); test("preview questions keep their type and option previews", () => { diff --git a/tests/text.test.ts b/tests/text.test.ts new file mode 100644 index 0000000..9d9439c --- /dev/null +++ b/tests/text.test.ts @@ -0,0 +1,29 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { wrapText } from "../src/text.ts"; + +test("wrapText wraps prose", () => { + assert.deepEqual(wrapText("hello world from pi", 8), [ + "hello", + "world", + "from pi", + ]); +}); + +test("wrapText preserves blank lines and indentation", () => { + assert.deepEqual(wrapText("Title\n\n indented text", 12), [ + "Title", + "", + " indented", + " text", + ]); +}); + +test("wrapText breaks long words", () => { + assert.deepEqual(wrapText("supercalifragilistic", 5), [ + "super", + "calif", + "ragil", + "istic", + ]); +});