feat(ask): add preview pane and wrapped content
This commit is contained in:
+156
-45
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user