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