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`.
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.
```
+156 -45
View File
@@ -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"],
+1 -1
View File
@@ -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) {
+2 -4
View File
@@ -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",
+1 -2
View File
@@ -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",
+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;
prompt: string;
type?: AskQuestionType;
allowOther?: boolean;
required?: boolean;
options: AskOption[];
}
@@ -23,10 +22,9 @@ export interface AskParams {
}
export interface AskQuestion
extends Omit<AskQuestionInput, "type" | "allowOther" | "required" | "label"> {
extends Omit<AskQuestionInput, "type" | "required" | "label"> {
label: string;
type: AskQuestionType;
allowOther: 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].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", () => {
+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",
]);
});