feat(ask): add preview pane and wrapped content

This commit is contained in:
Erik Kotsiuba
2026-04-17 15:31:16 +02:00
parent eadd09dae6
commit 52dfc4f9a6
9 changed files with 294 additions and 60 deletions
+3 -4
View File
@@ -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`. The extension registers a tool named `ask_user`.
Use it when the agent needs structured clarification before proceeding. 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 ### Input shape
@@ -84,7 +85,6 @@ Use it when the agent needs structured clarification before proceeding.
label?: string, label?: string,
prompt: string, prompt: string,
type?: "single" | "multi" | "preview", type?: "single" | "multi" | "preview",
allowOther?: boolean,
required?: boolean, // metadata only; submit is never blocked required?: boolean, // metadata only; submit is never blocked
options: [ options: [
{ {
@@ -150,8 +150,7 @@ The current prototype supports:
- tabbed multi-question flow - tabbed multi-question flow
- single-select questions - single-select questions
- multi-select questions - multi-select questions
- preview questions with a dedicated preview pane and no `Type your own` fallback - preview questions with a dedicated preview pane
- fixed `Type your own` fallback option that preserves typed text when dismissed
- number-key quick selection - number-key quick selection
- final submit/review page - final submit/review page
- transcript rendering for call/result rows - transcript rendering for call/result rows
@@ -168,9 +167,9 @@ Ask 1-3 concise questions.
Use short tab labels. Use short tab labels.
Prefer 2-4 options per question. Prefer 2-4 options per question.
Include descriptions for each option. 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: "single"` unless multiple options can genuinely apply.
Use `type: "multi"` only when the user may need to select several answers. 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. After answers are returned, continue the task using those answers explicitly.
``` ```
+156 -45
View File
@@ -7,6 +7,7 @@ import {
truncateToWidth, truncateToWidth,
visibleWidth, visibleWidth,
} from "@mariozechner/pi-tui"; } from "@mariozechner/pi-tui";
import { wrapText } from "./text.ts";
import { import {
applyNumberShortcut, applyNumberShortcut,
cancelFlow, cancelFlow,
@@ -293,33 +294,70 @@ function renderSubmit(
theme: ExtensionContext["ui"]["theme"], theme: ExtensionContext["ui"]["theme"],
width: number, width: number,
) { ) {
const add = (text = "") => lines.push(truncateToWidth(text, width)); pushWrappedText(
add(` ${theme.fg("accent", theme.bold("Review your answers"))}`); lines,
add(); "Review your answers",
width,
theme,
"accent",
" ",
" ",
);
lines.push("");
for (const question of state.questions) { for (const question of state.questions) {
const answer = getAnswer(state, question.id); const answer = getAnswer(state, question.id);
add(` ${theme.fg("muted", `● ${question.prompt}`)}`); pushWrappedText(
lines,
question.prompt,
width,
theme,
"muted",
" ● ",
" ",
);
if (!answer) { if (!answer) {
add(` ${theme.fg("warning", "→ (unanswered)")}`); lines.push(
truncateToWidth(` ${theme.fg("warning", "→ (unanswered)")}`, width),
);
continue; continue;
} }
const answerText = answer.customText ?? answer.labels.join(", "); const answerText = answer.customText ?? answer.labels.join(", ");
add( pushWrappedText(
` ${theme.fg(answer.customText ? "text" : "success", `→ ${answerText}`)}`, lines,
`→ ${answerText}`,
width,
theme,
answer.customText ? "text" : "success",
" ",
" ",
); );
} }
add(); lines.push("");
add(` ${theme.fg("success", "Ready to submit your answers?")}`); pushWrappedText(
add(); lines,
"Ready to submit your answers?",
width,
theme,
"success",
" ",
" ",
);
lines.push("");
const options = ["Submit answers", "Cancel"]; const options = ["Submit answers", "Cancel"];
for (let i = 0; i < options.length; i++) { for (let i = 0; i < options.length; i++) {
const selected = i === state.submitIndex; const selected = i === state.submitIndex;
const prefix = selected ? theme.fg("accent", "❯ ") : " "; const prefix = selected ? "❯ " : " ";
add( pushWrappedText(
`${prefix}${theme.fg(selected ? "accent" : "text", `${i + 1}. ${options[i]}`)}`, lines,
`${i + 1}. ${options[i]}`,
width,
theme,
selected ? "accent" : "text",
prefix,
prefix,
); );
} }
} }
@@ -371,7 +409,7 @@ function renderQuestionPrompt(
theme: ExtensionContext["ui"]["theme"], theme: ExtensionContext["ui"]["theme"],
width: number, width: number,
) { ) {
lines.push(truncateToWidth(` ${theme.fg("text", prompt)}`, width)); pushWrappedText(lines, prompt, width, theme, "text", " ", " ");
lines.push(""); lines.push("");
} }
@@ -385,7 +423,6 @@ function renderStandardQuestion(
editor: Editor, editor: Editor,
newLineHint: string, newLineHint: string,
) { ) {
const add = (text = "") => lines.push(truncateToWidth(text, width));
const isMulti = question.type === "multi"; const isMulti = question.type === "multi";
const answer = getAnswer(state, question.id); const answer = getAnswer(state, question.id);
for (let i = 0; i < options.length; i++) { for (let i = 0; i < options.length; i++) {
@@ -394,7 +431,7 @@ function renderStandardQuestion(
const isAnsweredOption = option.isOther const isAnsweredOption = option.isOther
? !!answer?.customText ? !!answer?.customText
: !!answer?.values.includes(option.value); : !!answer?.values.includes(option.value);
const pointer = selected ? theme.fg("accent", "❯ ") : " "; const pointer = selected ? "❯ " : " ";
const prefix = const prefix =
isMulti && !option.isOther ? `[${isAnsweredOption ? "x" : " "}] ` : ""; isMulti && !option.isOther ? `[${isAnsweredOption ? "x" : " "}] ` : "";
const optionColor = isAnsweredOption const optionColor = isAnsweredOption
@@ -402,26 +439,54 @@ function renderStandardQuestion(
: selected : selected
? "accent" ? "accent"
: "text"; : "text";
add( pushWrappedText(
`${pointer}${theme.fg(optionColor, `${i + 1}. ${prefix}${option.label}`)}`, lines,
`${i + 1}. ${prefix}${option.label}`,
width,
theme,
optionColor,
pointer,
" ".repeat(visibleWidth(pointer)),
); );
if (option.description) { if (option.description) {
add(` ${theme.fg("muted", option.description)}`); pushWrappedText(
lines,
option.description,
width,
theme,
"muted",
" ",
" ",
);
} }
if (option.isOther && selected && state.mode === "input") { if (option.isOther && selected && state.mode === "input") {
for (const editorLine of editor.render(Math.max(8, width - 7))) { 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( lines.push(
` ${theme.fg("dim", `${newLineHint} newline · Enter submit · Esc save and close`)}`, truncateToWidth(
` ${theme.fg("dim", `${newLineHint} newline · Enter submit · Esc save and close`)}`,
width,
),
); );
} }
if (option.isOther && state.mode !== "input") { if (option.isOther && state.mode !== "input") {
const customText = answer?.customText; const customText = answer?.customText;
if (customText) { if (customText) {
for (const customLine of customText.split("\n")) { pushWrappedText(
add(` ${theme.fg("muted", customLine)}`); lines,
} customText,
width,
theme,
"muted",
" ",
" ",
);
} }
} }
} }
@@ -476,7 +541,10 @@ function renderPreviewQuestion(
if (selectedOption?.isOther && state.mode !== "input" && answer?.customText) { if (selectedOption?.isOther && state.mode !== "input" && answer?.customText) {
add(""); 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)}`); add(` ${theme.fg("muted", customLine)}`);
} }
} }
@@ -490,7 +558,6 @@ function renderPreviewOptionList(
width: number, width: number,
): string[] { ): string[] {
const lines: string[] = []; const lines: string[] = [];
const add = (text = "") => lines.push(truncateToWidth(text, width));
const answer = getAnswer(state, question.id); const answer = getAnswer(state, question.id);
for (let i = 0; i < options.length; i++) { for (let i = 0; i < options.length; i++) {
const option = options[i]; const option = options[i];
@@ -498,15 +565,31 @@ function renderPreviewOptionList(
const isAnsweredOption = option.isOther const isAnsweredOption = option.isOther
? !!answer?.customText ? !!answer?.customText
: !!answer?.values.includes(option.value); : !!answer?.values.includes(option.value);
const pointer = selected ? theme.fg("accent", "❯ ") : " "; const pointer = selected ? "❯ " : " ";
const optionColor = isAnsweredOption const optionColor = isAnsweredOption
? "success" ? "success"
: selected : selected
? "accent" ? "accent"
: "text"; : "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) { if (option.description) {
add(` ${theme.fg("muted", option.description)}`); pushWrappedText(
lines,
option.description,
width,
theme,
"muted",
" ",
" ",
);
} }
} }
return lines; return lines;
@@ -520,36 +603,39 @@ function renderPreviewPane(
const paneWidth = Math.max(10, width); const paneWidth = Math.max(10, width);
if (!selectedOption) { if (!selectedOption) {
return renderBox( return renderBox(
[theme.fg("dim", "No preview available")], [{ text: "No preview available", color: "dim" }],
paneWidth, paneWidth,
theme, theme,
); );
} }
const content: string[] = []; const content: Array<{
content.push(theme.fg("accent", theme.bold(selectedOption.label))); text: string;
color: "accent" | "muted" | "text" | "dim";
}> = [];
content.push({ text: selectedOption.label, color: "accent" });
if (selectedOption.description) { if (selectedOption.description) {
content.push(theme.fg("muted", selectedOption.description)); content.push({ text: selectedOption.description, color: "muted" });
} }
if (selectedOption.preview) { if (selectedOption.preview) {
if (content.length) { if (content.length) {
content.push(""); content.push({ text: "", color: "dim" });
} }
for (const previewLine of selectedOption.preview.split("\n")) { for (const previewLine of selectedOption.preview.split("\n")) {
content.push(theme.fg("text", previewLine)); content.push({ text: previewLine, color: "text" });
} }
} else { } else {
if (content.length) { 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); return renderBox(content, paneWidth, theme);
} }
function renderBox( function renderBox(
content: string[], content: Array<{ text: string; color: "accent" | "muted" | "text" | "dim" }>,
width: number, width: number,
theme: ExtensionContext["ui"]["theme"], theme: ExtensionContext["ui"]["theme"],
): string[] { ): string[] {
@@ -558,12 +644,14 @@ function renderBox(
const top = theme.fg("accent", `┌${"─".repeat(innerWidth)}┐`); const top = theme.fg("accent", `┌${"─".repeat(innerWidth)}┐`);
const bottom = theme.fg("accent", `└${"─".repeat(innerWidth)}┘`); const bottom = theme.fg("accent", `└${"─".repeat(innerWidth)}┘`);
const lines = [top]; const lines = [top];
for (const rawLine of content) { for (const item of content) {
const line = truncateToWidth(rawLine, innerWidth); for (const rawLine of wrapText(item.text, innerWidth)) {
const padding = " ".repeat(Math.max(0, innerWidth - visibleWidth(line))); const line = theme.fg(item.color, rawLine);
lines.push( const padding = " ".repeat(Math.max(0, innerWidth - visibleWidth(line)));
theme.fg("accent", "│") + line + padding + theme.fg("accent", "│"), lines.push(
); theme.fg("accent", "│") + line + padding + theme.fg("accent", "│"),
);
}
} }
lines.push(bottom); lines.push(bottom);
return lines; return lines;
@@ -613,6 +701,29 @@ function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value)); 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( function renderFooter(
state: AskState, state: AskState,
theme: ExtensionContext["ui"]["theme"], theme: ExtensionContext["ui"]["theme"],
+1 -1
View File
@@ -11,7 +11,7 @@ export function registerAskTool(pi: ExtensionAPI) {
name: "ask_user", name: "ask_user",
label: "Ask User", label: "Ask User",
description: 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, parameters: AskParamsSchema,
async execute(_toolCallId, params: AskParams, _signal, _onUpdate, ctx) { async execute(_toolCallId, params: AskParams, _signal, _onUpdate, ctx) {
+2 -4
View File
@@ -10,7 +10,8 @@ export const AskOptionSchema = Type.Object({
), ),
preview: Type.Optional( preview: Type.Optional(
Type.String({ 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" }, { description: "Question type" },
), ),
), ),
allowOther: Type.Optional(
Type.Boolean({ description: "Include a fixed 'Type something.' option" }),
),
required: Type.Optional( required: Type.Optional(
Type.Boolean({ Type.Boolean({
description: "Optional metadata flag; submit is never blocked", description: "Optional metadata flag; submit is never blocked",
+1 -2
View File
@@ -24,7 +24,6 @@ function normalizeQuestion(
...question, ...question,
label: question.label?.trim() || `Q${index + 1}`, label: question.label?.trim() || `Q${index + 1}`,
type: question.type ?? "single", type: question.type ?? "single",
allowOther: question.allowOther !== false,
required: question.required !== false, required: question.required !== false,
}; };
} }
@@ -57,7 +56,7 @@ export function getRenderableOptions(question?: AskQuestion): RenderOption[] {
return []; return [];
} }
const options: RenderOption[] = [...question.options]; const options: RenderOption[] = [...question.options];
if (question.type !== "preview" && question.allowOther) { if (question.type !== "preview") {
options.push({ options.push({
value: OTHER_VALUE, value: OTHER_VALUE,
label: "Type your own", label: "Type your own",
+100
View File
@@ -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;
}
+1 -3
View File
@@ -12,7 +12,6 @@ export interface AskQuestionInput {
label?: string; label?: string;
prompt: string; prompt: string;
type?: AskQuestionType; type?: AskQuestionType;
allowOther?: boolean;
required?: boolean; required?: boolean;
options: AskOption[]; options: AskOption[];
} }
@@ -23,10 +22,9 @@ export interface AskParams {
} }
export interface AskQuestion export interface AskQuestion
extends Omit<AskQuestionInput, "type" | "allowOther" | "required" | "label"> { extends Omit<AskQuestionInput, "type" | "required" | "label"> {
label: string; label: string;
type: AskQuestionType; type: AskQuestionType;
allowOther: boolean;
required: boolean; required: boolean;
} }
+1 -1
View File
@@ -54,12 +54,12 @@ test("normalize defaults via initial state", () => {
assert.equal(state.questions[0].label, "Q1"); assert.equal(state.questions[0].label, "Q1");
assert.equal(state.questions[0].type, "single"); assert.equal(state.questions[0].type, "single");
assert.equal(state.questions[0].allowOther, true);
assert.equal(state.questions[0].required, true); assert.equal(state.questions[0].required, true);
assert.equal( assert.equal(
getRenderableOptions(state.questions[0]).at(-1)?.label, getRenderableOptions(state.questions[0]).at(-1)?.label,
"Type your own", "Type your own",
); );
assert.equal(getRenderableOptions(state.questions[0]).at(-1)?.isOther, true);
}); });
test("preview questions keep their type and option previews", () => { test("preview questions keep their type and option previews", () => {
+29
View File
@@ -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",
]);
});