refactor(ask): reorganize ask flow internals

This commit is contained in:
Erik Kotsiuba
2026-04-17 19:58:58 +02:00
parent c1ac241655
commit 87cbfa546e
25 changed files with 2448 additions and 1639 deletions
+28
View File
@@ -0,0 +1,28 @@
export const UI_DIMENSIONS = {
boxMinWidth: 10,
callLabelTruncateWidth: 50,
editorContentPadding: 5,
editorIndentedPadding: 7,
editorMinWidth: 8,
previewWideMinWidth: 90,
previewMinRightWidth: 24,
previewLeftMinWidth: 22,
previewLeftMaxWidth: 34,
previewLeftRatio: 0.34,
} as const;
export const UI_TEXT = {
questionNoteTitle: "Question note",
reviewTitle: "Review your answers",
readyToSubmit: "Ready to submit your answers?",
unanswered: "→ (unanswered)",
footerInput: " Enter submit · Esc save and close · Tab/←→ save and navigate",
footerNote: " Enter save · Esc save and close · Tab/←→ save and navigate",
footerSubmit: " ↑↓ select · Enter confirm · Esc cancel",
footerMulti:
" ⇆ tab · ↑↓ select · Space toggle · Enter continue · N option note · Ctrl+N question note · Esc dismiss · 1-9 quick toggle",
footerDefault:
" ⇆ tab · ↑↓ select · Enter confirm · N option note · Ctrl+N question note · Esc dismiss",
hintSave: "{newLineHint} newline · Enter save · Esc save and close",
hintSubmit: "{newLineHint} newline · Enter submit · Esc save and close",
} as const;
+350
View File
@@ -0,0 +1,350 @@
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
import { Editor, type EditorTheme } from "@mariozechner/pi-tui";
import {
applyNumberShortcut,
cancelFlow,
confirmCurrentSelection,
createInitialState,
enterInputMode,
enterOptionNoteMode,
enterQuestionNoteMode,
getAnswer,
getCurrentOption,
getCurrentQuestion,
getOptionNote,
getQuestionNote,
isEditingView,
isSubmitTab,
moveOption,
moveTab,
saveCustomAnswer,
saveNote,
submitCustomAnswer,
toAskResult,
toggleCurrentMultiOption,
} from "../state.ts";
import type { AskParams, AskResult, AskState } from "../types.ts";
import type { AskInputCommand } from "./input.ts";
import { formatKeybindingLabel, getInputCommand } from "./input.ts";
import { renderAskScreen } from "./render.ts";
type CustomCallback = Parameters<ExtensionContext["ui"]["custom"]>[0];
type CustomCallbackArgs = CustomCallback extends (...args: infer T) => unknown
? T
: never;
type Tui = CustomCallbackArgs[0];
type Theme = CustomCallbackArgs[1];
type Keybindings = CustomCallbackArgs[2];
type Done = (result: AskResult) => void;
interface AskFlowController {
state: AskState;
cachedLines: string[] | undefined;
suppressAutoInputForSelection: boolean;
newLineHint: string;
tui: Tui;
theme: Theme;
editor: Editor;
done: Done;
}
export async function runAskFlow(
ctx: ExtensionContext,
params: AskParams,
): Promise<AskResult> {
return ctx.ui.custom<AskResult>((...args) =>
createAskFlowController(args, params),
);
}
function createAskFlowController(
[tui, theme, keybindings, done]: [
Tui,
Theme,
Keybindings,
(result: AskResult) => void,
],
params: AskParams,
) {
const controller: AskFlowController = {
state: createInitialState(params),
cachedLines: undefined,
suppressAutoInputForSelection: false,
newLineHint: formatKeybindingLabel(
keybindings.getKeys("tui.input.newLine")[0] ?? "shift+enter",
),
tui,
theme,
editor: createEditor(tui, theme),
done,
};
controller.editor.onSubmit = (value) => submitEditor(controller, value);
syncInputModeWithSelection(controller);
return {
render: (width: number) => renderController(controller, width),
invalidate() {
controller.cachedLines = undefined;
},
handleInput(data: string) {
handleControllerInput(controller, data);
},
};
}
function renderController(
controller: AskFlowController,
width: number,
): string[] {
if (!controller.cachedLines) {
controller.cachedLines = renderAskScreen({
state: controller.state,
theme: controller.theme,
width,
editor: controller.editor,
newLineHint: controller.newLineHint,
});
}
return controller.cachedLines;
}
function handleControllerInput(controller: AskFlowController, data: string) {
const command = getInputCommand(controller.state, data);
if (isEditingView(controller.state)) {
handleEditingCommand(controller, command, data);
return;
}
handleNavigationCommand(controller, command);
}
function handleEditingCommand(
controller: AskFlowController,
command: AskInputCommand,
data: string,
) {
if (command.kind === "editMoveTab") {
controller.state = saveEditorAndMoveTab(controller, command.delta);
syncInputModeWithSelection(controller);
refresh(controller);
return;
}
if (command.kind === "editClose") {
controller.state = saveEditorWithoutAdvancing(controller);
controller.suppressAutoInputForSelection =
controller.state.view.kind !== "input";
refresh(controller);
return;
}
if (command.kind === "delegateToEditor") {
controller.editor.handleInput(data);
refresh(controller);
}
}
function handleNavigationCommand(
controller: AskFlowController,
command: AskInputCommand,
) {
switch (command.kind) {
case "moveTab":
navigateTabs(controller, command.delta);
return;
case "moveOption":
navigateOptions(controller, command.delta);
return;
case "toggleMulti":
handleToggleMulti(controller);
return;
case "openQuestionNote":
openQuestionNote(controller);
return;
case "openOptionNote":
openOptionNote(controller);
return;
case "confirm":
commitNavigation(controller, confirmCurrentSelection(controller.state));
maybeFinish(controller);
return;
case "cancel":
commitNavigation(controller, cancelFlow(controller.state));
maybeFinish(controller);
return;
case "numberShortcut":
commitNavigation(
controller,
applyNumberShortcut(controller.state, command.digit),
);
return;
case "ignore":
case "editMoveTab":
case "editClose":
case "delegateToEditor":
return;
default:
return;
}
}
function navigateTabs(controller: AskFlowController, delta: 1 | -1) {
controller.suppressAutoInputForSelection = false;
controller.state = moveTab(controller.state, delta);
syncInputModeWithSelection(controller);
refresh(controller);
}
function navigateOptions(controller: AskFlowController, delta: 1 | -1) {
controller.suppressAutoInputForSelection = false;
controller.state = moveOption(controller.state, delta);
syncInputModeWithSelection(controller);
refresh(controller);
}
function handleToggleMulti(controller: AskFlowController) {
const question = getCurrentQuestion(controller.state);
if (question?.type !== "multi") {
return;
}
controller.suppressAutoInputForSelection = false;
commitNavigation(controller, toggleCurrentMultiOption(controller.state));
}
function openQuestionNote(controller: AskFlowController) {
const question = getCurrentQuestion(controller.state);
if (!question || isSubmitTab(controller.state)) {
return;
}
controller.state = enterQuestionNoteMode(controller.state, question.id);
hydrateEditorForCurrentView(controller);
refresh(controller);
}
function openOptionNote(controller: AskFlowController) {
const question = getCurrentQuestion(controller.state);
const option = getCurrentOption(controller.state);
if (
!question ||
!option ||
option.isCustomOption ||
isSubmitTab(controller.state)
) {
return;
}
controller.state = enterOptionNoteMode(
controller.state,
question.id,
option.value,
);
hydrateEditorForCurrentView(controller);
refresh(controller);
}
function commitNavigation(controller: AskFlowController, nextState: AskState) {
controller.suppressAutoInputForSelection = false;
controller.state = nextState;
hydrateEditorForCurrentView(controller);
refresh(controller);
}
function submitEditor(controller: AskFlowController, value: string) {
controller.suppressAutoInputForSelection = false;
controller.state = submitEditorValue(controller.state, value);
controller.editor.setText("");
refresh(controller);
maybeFinish(controller);
}
function submitEditorValue(state: AskState, value: string): AskState {
if (state.view.kind === "input") {
return submitCustomAnswer(state, value);
}
if (state.view.kind === "note") {
return saveNote(state, value);
}
return state;
}
function refresh(controller: AskFlowController) {
controller.cachedLines = undefined;
controller.tui.requestRender();
}
function maybeFinish(controller: AskFlowController) {
if (controller.state.completed) {
controller.done(toAskResult(controller.state));
}
}
function hydrateEditorForCurrentView(controller: AskFlowController) {
controller.editor.setText(getEditorTextForCurrentView(controller.state));
}
function syncInputModeWithSelection(controller: AskFlowController) {
if (
isEditingView(controller.state) ||
isSubmitTab(controller.state) ||
controller.suppressAutoInputForSelection
) {
return;
}
const question = getCurrentQuestion(controller.state);
const option = getCurrentOption(controller.state);
if (!question || !option?.isCustomOption) {
return;
}
controller.state = enterInputMode(controller.state, question.id);
hydrateEditorForCurrentView(controller);
}
function saveEditorWithoutAdvancing(controller: AskFlowController): AskState {
const text = controller.editor.getText();
controller.editor.setText("");
if (controller.state.view.kind === "input") {
return saveCustomAnswer(controller.state, text);
}
if (controller.state.view.kind === "note") {
return saveNote(controller.state, text);
}
return controller.state;
}
function saveEditorAndMoveTab(
controller: AskFlowController,
delta: 1 | -1,
): AskState {
const nextState = saveEditorWithoutAdvancing(controller);
controller.suppressAutoInputForSelection = false;
return moveTab(nextState, delta);
}
function createEditor(tui: Tui, theme: Theme) {
const editorTheme: EditorTheme = {
borderColor: (text) => theme.fg("accent", text),
selectList: {
selectedPrefix: (text) => theme.fg("accent", text),
selectedText: (text) => theme.fg("accent", text),
description: (text) => theme.fg("muted", text),
scrollInfo: (text) => theme.fg("dim", text),
noMatch: (text) => theme.fg("warning", text),
},
};
return new Editor(tui, editorTheme);
}
function getEditorTextForCurrentView(state: AskState): string {
if (state.view.kind === "input") {
return getAnswer(state, state.view.questionId)?.customText ?? "";
}
if (state.view.kind === "note") {
if (state.view.optionValue) {
return (
getOptionNote(state, state.view.questionId, state.view.optionValue) ??
""
);
}
return getQuestionNote(state, state.view.questionId) ?? "";
}
return "";
}
+99
View File
@@ -0,0 +1,99 @@
import { Key, matchesKey } from "@mariozechner/pi-tui";
import type { AskState } from "../types.ts";
export type AskInputCommand =
| { kind: "moveTab"; delta: 1 | -1 }
| { kind: "moveOption"; delta: 1 | -1 }
| { kind: "toggleMulti" }
| { kind: "openQuestionNote" }
| { kind: "openOptionNote" }
| { kind: "confirm" }
| { kind: "cancel" }
| { kind: "numberShortcut"; digit: number }
| { kind: "editMoveTab"; delta: 1 | -1 }
| { kind: "editClose" }
| { kind: "delegateToEditor" }
| { kind: "ignore" };
export function getInputCommand(
state: AskState,
data: string,
): AskInputCommand {
if (state.view.kind === "input" || state.view.kind === "note") {
return getEditingInputCommand(data);
}
return getNavigationInputCommand(data);
}
export function formatKeybindingLabel(key: string): string {
return key
.split("+")
.map((part) => {
if (part.length <= 1) {
return part.toUpperCase();
}
return part.charAt(0).toUpperCase() + part.slice(1);
})
.join("+");
}
function getEditingInputCommand(data: string): AskInputCommand {
if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) {
return { kind: "editMoveTab", delta: 1 };
}
if (matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left)) {
return { kind: "editMoveTab", delta: -1 };
}
if (matchesKey(data, Key.escape)) {
return { kind: "editClose" };
}
return { kind: "delegateToEditor" };
}
function getNavigationInputCommand(data: string): AskInputCommand {
if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) {
return { kind: "moveTab", delta: 1 };
}
if (matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left)) {
return { kind: "moveTab", delta: -1 };
}
if (matchesKey(data, Key.up)) {
return { kind: "moveOption", delta: -1 };
}
if (matchesKey(data, Key.down)) {
return { kind: "moveOption", delta: 1 };
}
if (matchesKey(data, Key.space)) {
return { kind: "toggleMulti" };
}
if (matchesKey(data, Key.enter)) {
return { kind: "confirm" };
}
if (matchesKey(data, Key.escape)) {
return { kind: "cancel" };
}
if (isQuestionNoteShortcut(data)) {
return { kind: "openQuestionNote" };
}
if (isOptionNoteShortcut(data)) {
return { kind: "openOptionNote" };
}
const digit = parseDigit(data);
return digit === null
? { kind: "ignore" }
: { kind: "numberShortcut", digit };
}
function isOptionNoteShortcut(data: string): boolean {
return data === "n" || data === "N";
}
function isQuestionNoteShortcut(data: string): boolean {
return matchesKey(data, Key.ctrl("n"));
}
function parseDigit(data: string): number | null {
return /^[1-9]$/.test(data) ? Number(data) : null;
}
+85
View File
@@ -0,0 +1,85 @@
import { truncateToWidth } from "@mariozechner/pi-tui";
import {
getCurrentQuestion,
isQuestionAnswered,
isSubmitTab,
} from "../state.ts";
import type { AskState } from "../types.ts";
import { renderFooterText } from "./render-helpers.ts";
import type { Theme } from "./render-types.ts";
export function renderFrameHeader(args: {
lines: string[];
state: AskState;
theme: Theme;
width: number;
}) {
const { lines, state, theme, width } = args;
const add = (text = "") => lines.push(truncateToWidth(text, width));
add(theme.fg("accent", "─".repeat(Math.max(1, width))));
if (state.title) {
add(` ${theme.fg("accent", theme.bold(state.title))}`);
add();
}
add(renderTabs(state, theme, width));
add();
}
export function renderFrameFooter(args: {
lines: string[];
state: AskState;
theme: Theme;
width: number;
}) {
const { lines, state, theme, width } = args;
const add = (text = "") => lines.push(truncateToWidth(text, width));
const footer = renderFooter(state, theme);
if (footer) {
add();
add(footer);
}
add(theme.fg("accent", "─".repeat(Math.max(1, width))));
}
function renderTabs(state: AskState, theme: Theme, width: number): string {
const segments: string[] = [theme.fg("dim", "← ")];
for (let index = 0; index < state.questions.length; index++) {
const question = state.questions[index];
const active = state.activeTabIndex === index;
const answered = isQuestionAnswered(state, question.id);
const marker = answered ? "☒" : "☐";
const text = ` ${marker} ${question.label} `;
segments.push(
active
? theme.bg("selectedBg", theme.fg("text", text))
: theme.fg(answered ? "success" : "muted", text),
);
}
const submitText = " ✔ Submit ";
segments.push(
isSubmitTab(state)
? theme.bg("selectedBg", theme.fg("text", submitText))
: theme.fg("success", submitText),
);
segments.push(theme.fg("dim", " →"));
return truncateToWidth(` ${segments.join(" ")}`, width);
}
function renderFooter(state: AskState, theme: Theme): string {
if (state.view.kind === "input") {
return theme.fg("dim", renderFooterText("input"));
}
if (state.view.kind === "note") {
return theme.fg("dim", renderFooterText("note"));
}
if (isSubmitTab(state)) {
return theme.fg("dim", renderFooterText("submit"));
}
const question = getCurrentQuestion(state);
return theme.fg(
"dim",
renderFooterText(question?.type === "multi" ? "multi" : "default"),
);
}
+188
View File
@@ -0,0 +1,188 @@
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
import { NO_PREVIEW_TEXT } from "../constants.ts";
import { wrapText } from "../text.ts";
import { UI_DIMENSIONS, UI_TEXT } from "./constants.ts";
type Theme = ExtensionContext["ui"]["theme"];
type ThemeColor = "accent" | "muted" | "text" | "dim" | "success" | "warning";
export function pushWrappedText(
lines: string[],
text: string,
width: number,
theme: Theme,
color: ThemeColor,
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));
}
}
export function renderInputLine(
line: string,
availableWidth: number,
theme: Theme,
): string {
const innerWidth = Math.max(4, availableWidth - 2);
const truncated = truncateToWidth(line, innerWidth);
const padding = " ".repeat(Math.max(0, innerWidth - visibleWidth(truncated)));
return theme.bg("toolPendingBg", ` ${truncated}${padding} `);
}
export function renderEditorBlock(args: {
lines: string[];
editorLines: string[];
width: number;
theme: Theme;
indent: string;
availableWidth: number;
hint: string;
}) {
const { lines, editorLines, width, theme, indent, availableWidth, hint } =
args;
for (const editorLine of editorLines) {
lines.push(
truncateToWidth(
`${indent}${renderInputLine(editorLine, availableWidth, theme)}`,
width,
),
);
}
lines.push(truncateToWidth(`${indent}${theme.fg("dim", hint)}`, width));
}
export function renderHint(template: string, newLineHint: string): string {
return template.replace("{newLineHint}", newLineHint);
}
export function renderBox(
content: Array<{ text: string; color: ThemeColor }>,
width: number,
theme: Theme,
): string[] {
const boxWidth = Math.max(UI_DIMENSIONS.boxMinWidth, width);
const innerWidth = Math.max(4, boxWidth - 2);
const top = theme.fg("accent", `┌${"─".repeat(innerWidth)}┐`);
const bottom = theme.fg("accent", `└${"─".repeat(innerWidth)}┘`);
const lines = [top];
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;
}
export function renderPreviewPaneContent(
selectedOption:
| { label: string; description?: string; preview?: string }
| undefined,
theme: Theme,
width: number,
): string[] {
if (!selectedOption) {
return renderBox([{ text: NO_PREVIEW_TEXT, color: "dim" }], width, theme);
}
const content: Array<{ text: string; color: ThemeColor }> = [
{ text: selectedOption.label, color: "accent" },
];
if (selectedOption.description) {
content.push({ text: selectedOption.description, color: "muted" });
}
content.push({ text: "", color: "dim" });
for (const previewLine of (selectedOption.preview ?? NO_PREVIEW_TEXT).split(
"\n",
)) {
content.push({
text: previewLine,
color: selectedOption.preview ? "text" : "dim",
});
}
return renderBox(content, width, theme);
}
export function mergeColumns(
left: string[],
right: string[],
leftWidth: number,
width: number,
): string[] {
const lines: string[] = [];
const rowCount = Math.max(left.length, right.length);
for (let index = 0; index < rowCount; index++) {
const leftLine = left[index] ?? "";
const rightLine = right[index] ?? "";
const paddedLeft = padToVisibleWidth(leftLine, leftWidth);
lines.push(truncateToWidth(`${paddedLeft} ${rightLine}`, width));
}
return lines;
}
export function measurePreviewLeftWidth(
options: Array<{ label: string; description?: string }>,
width: number,
): number {
let widest = 0;
for (let index = 0; index < options.length; index++) {
const option = options[index];
widest = Math.max(
widest,
visibleWidth(`${index + 1}. ${option.label}`),
option.description ? visibleWidth(option.description) : 0,
);
}
const preferred = widest + 4;
const maxWidth = Math.min(
UI_DIMENSIONS.previewLeftMaxWidth,
Math.floor(width * UI_DIMENSIONS.previewLeftRatio),
);
return clamp(
preferred,
UI_DIMENSIONS.previewLeftMinWidth,
Math.max(UI_DIMENSIONS.previewLeftMinWidth, maxWidth),
);
}
export function renderFooterText(
mode: "input" | "note" | "submit" | "multi" | "default",
) {
switch (mode) {
case "input":
return UI_TEXT.footerInput;
case "note":
return UI_TEXT.footerNote;
case "submit":
return UI_TEXT.footerSubmit;
case "multi":
return UI_TEXT.footerMulti;
default:
return UI_TEXT.footerDefault;
}
}
function padToVisibleWidth(text: string, width: number): string {
return text + " ".repeat(Math.max(0, width - visibleWidth(text)));
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
+429
View File
@@ -0,0 +1,429 @@
import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
import {
getAnswer,
getOptionNote,
getQuestionNote,
isInputOpenForQuestion,
isOptionNoteOpen,
isOptionSelected,
isQuestionNoteOpen,
} from "../state.ts";
import type { AskDisplayOption } from "../types.ts";
import { UI_DIMENSIONS, UI_TEXT } from "./constants.ts";
import {
measurePreviewLeftWidth,
mergeColumns,
pushWrappedText,
renderEditorBlock,
renderHint,
renderPreviewPaneContent,
} from "./render-helpers.ts";
import type {
OptionDetailRenderContext,
QuestionRenderContext,
Theme,
} from "./render-types.ts";
export function renderQuestionScreen(context: QuestionRenderContext) {
const { lines, question, theme, width } = context;
pushWrappedText(lines, question.prompt, width, theme, "text", " ", " ");
lines.push("");
renderQuestionNote(context);
if (question.type === "preview") {
renderPreviewQuestion(context);
return;
}
renderStandardQuestion(context);
}
function renderQuestionNote(context: QuestionRenderContext) {
const { lines, state, question, theme, width, editor, newLineHint } = context;
if (isQuestionNoteOpen(state, question.id)) {
pushWrappedText(
lines,
UI_TEXT.questionNoteTitle,
width,
theme,
"accent",
" ",
" ",
);
renderEditorBlock({
lines,
editorLines: editor.render(
Math.max(
UI_DIMENSIONS.editorMinWidth,
width - UI_DIMENSIONS.editorContentPadding,
),
),
width,
theme,
indent: " ",
availableWidth: width - 3,
hint: renderHint(UI_TEXT.hintSave, newLineHint),
});
lines.push("");
return;
}
const existingNote = getQuestionNote(state, question.id);
if (!existingNote) {
return;
}
pushWrappedText(
lines,
`Question note: ${existingNote}`,
width,
theme,
"muted",
" ",
" ",
);
lines.push("");
}
function renderStandardQuestion(context: QuestionRenderContext) {
const { options } = context;
for (let index = 0; index < options.length; index++) {
renderStandardOption(context, options[index], index);
}
}
function renderStandardOption(
context: QuestionRenderContext,
option: AskDisplayOption,
index: number,
) {
const { lines, state, question, theme, width } = context;
const answer = getAnswer(state, question.id);
const selected = index === state.activeOptionIndex;
const isAnsweredOption = option.isCustomOption
? !!answer?.customText
: isOptionSelected(answer, option.value);
const pointer = selected ? "❯ " : " ";
const prefix = getOptionPrefix(question.type, option, isAnsweredOption);
const optionColor = getOptionColor(isAnsweredOption, selected);
pushWrappedText(
lines,
`${index + 1}. ${prefix}${option.label}`,
width,
theme,
optionColor,
pointer,
" ".repeat(visibleWidth(pointer)),
);
renderOptionMeta({
...context,
questionId: question.id,
answer,
option,
selected,
});
}
function renderPreviewQuestion(context: QuestionRenderContext) {
const { lines, state, question, options, theme, width } = context;
const add = (text = "") => lines.push(truncateToWidth(text, width));
const selectedOption = options[state.activeOptionIndex];
const answer = getAnswer(state, question.id);
if (width >= UI_DIMENSIONS.previewWideMinWidth && options.length > 0) {
renderWidePreviewLayout(
add,
state,
options,
theme,
width,
answer,
selectedOption,
);
} else {
renderStackedPreviewLayout(
add,
state,
options,
theme,
width,
answer,
selectedOption,
);
}
renderOptionEditorOrNote({
...context,
questionId: question.id,
answer,
option: selectedOption,
withGap: true,
});
}
function renderWidePreviewLayout(
add: (text?: string) => void,
state: QuestionRenderContext["state"],
options: QuestionRenderContext["options"],
theme: Theme,
width: number,
answer: ReturnType<typeof getAnswer>,
selectedOption: AskDisplayOption | undefined,
) {
const leftWidth = measurePreviewLeftWidth(options, width);
const rightWidth = Math.max(
UI_DIMENSIONS.previewMinRightWidth,
width - leftWidth - 2,
);
const leftPane = renderPreviewOptionList(
state,
options,
theme,
leftWidth,
answer,
);
const rightPane = renderPreviewPaneContent(selectedOption, theme, rightWidth);
for (const line of mergeColumns(leftPane, rightPane, leftWidth, width)) {
add(line);
}
}
function renderStackedPreviewLayout(
add: (text?: string) => void,
state: QuestionRenderContext["state"],
options: QuestionRenderContext["options"],
theme: Theme,
width: number,
answer: ReturnType<typeof getAnswer>,
selectedOption: AskDisplayOption | undefined,
) {
renderPreviewOptionList(state, options, theme, width, answer).forEach(add);
add("");
renderPreviewPaneContent(selectedOption, theme, width).forEach(add);
}
function renderPreviewOptionList(
state: QuestionRenderContext["state"],
options: QuestionRenderContext["options"],
theme: Theme,
width: number,
answer: ReturnType<typeof getAnswer>,
): string[] {
const lines: string[] = [];
for (let index = 0; index < options.length; index++) {
const option = options[index];
const selected = index === state.activeOptionIndex;
const isAnsweredOption = option.isCustomOption
? !!answer?.customText
: isOptionSelected(answer, option.value);
pushWrappedText(
lines,
`${index + 1}. ${option.label}`,
width,
theme,
getOptionColor(isAnsweredOption, selected),
selected ? "❯ " : " ",
" ",
);
if (option.description) {
pushWrappedText(
lines,
option.description,
width,
theme,
"muted",
" ",
" ",
);
}
}
return lines;
}
function renderOptionMeta(
context: QuestionRenderContext & {
questionId: string;
answer: ReturnType<typeof getAnswer>;
option: AskDisplayOption;
selected: boolean;
},
) {
const { lines, option, theme, width } = context;
if (option.description) {
pushWrappedText(
lines,
option.description,
width,
theme,
"muted",
" ",
" ",
);
}
renderOptionEditorOrNote(context);
}
function renderOptionEditorOrNote(context: OptionDetailRenderContext) {
const { lines, option, withGap } = context;
if (!option) {
return;
}
const renderState = getOptionDetailRenderState(context);
if (withGap && hasOptionDetailContent(renderState)) {
lines.push("");
}
if (renderOptionEditor(context, renderState)) {
return;
}
if (renderOptionContent(context, renderState)) {
return;
}
renderSavedOptionNote(context, renderState.optionNote);
}
function getOptionDetailRenderState(context: OptionDetailRenderContext) {
const { state, questionId, answer, option, selected } = context;
const inputOpen =
option?.isCustomOption &&
selected &&
isInputOpenForQuestion(state, questionId);
const noteOpen =
!!option &&
!option.isCustomOption &&
isOptionNoteOpen(state, questionId, option.value);
const optionNote =
option && !option.isCustomOption
? getOptionNote(state, questionId, option.value)
: undefined;
const customText = option?.isCustomOption ? answer?.customText : undefined;
return {
inputOpen,
noteOpen,
optionNote,
customText,
};
}
function hasOptionDetailContent(
renderState: ReturnType<typeof getOptionDetailRenderState>,
) {
return !!(
renderState.noteOpen ||
renderState.inputOpen ||
renderState.customText ||
renderState.optionNote
);
}
function renderOptionEditor(
context: OptionDetailRenderContext,
renderState: ReturnType<typeof getOptionDetailRenderState>,
): boolean {
const { lines, editor, width, theme, newLineHint } = context;
if (renderState.noteOpen) {
renderIndentedEditor(
lines,
editor,
width,
theme,
renderHint(UI_TEXT.hintSave, newLineHint),
);
return true;
}
if (renderState.inputOpen) {
renderIndentedEditor(
lines,
editor,
width,
theme,
renderHint(UI_TEXT.hintSubmit, newLineHint),
);
return true;
}
return false;
}
function renderOptionContent(
context: OptionDetailRenderContext,
renderState: ReturnType<typeof getOptionDetailRenderState>,
): boolean {
const { lines, option, theme, width } = context;
if (!option?.isCustomOption || !renderState.customText) {
return false;
}
pushWrappedText(
lines,
renderState.customText,
width,
theme,
"muted",
" ",
" ",
);
return true;
}
function renderSavedOptionNote(
context: OptionDetailRenderContext,
optionNote: string | undefined,
) {
if (!optionNote) {
return;
}
const { lines, theme, width } = context;
pushWrappedText(
lines,
`Note: ${optionNote}`,
width,
theme,
"muted",
" ",
" ",
);
}
function renderIndentedEditor(
lines: string[],
editor: QuestionRenderContext["editor"],
width: number,
theme: Theme,
hint: string,
) {
renderEditorBlock({
lines,
editorLines: editor.render(
Math.max(
UI_DIMENSIONS.editorMinWidth,
width - UI_DIMENSIONS.editorIndentedPadding,
),
),
width,
theme,
indent: " ",
availableWidth: width - 5,
hint,
});
}
function getOptionPrefix(
questionType: QuestionRenderContext["question"]["type"],
option: AskDisplayOption,
isAnsweredOption: boolean,
): string {
if (questionType !== "multi" || option.isCustomOption) {
return "";
}
return `[${isAnsweredOption ? "x" : " "}] `;
}
function getOptionColor(isAnsweredOption: boolean, selected: boolean) {
if (isAnsweredOption) {
return "success";
}
if (selected) {
return "accent";
}
return "text";
}
+128
View File
@@ -0,0 +1,128 @@
import { truncateToWidth } from "@mariozechner/pi-tui";
import { SUBMIT_CHOICES } from "../constants.ts";
import { toAskResult } from "../state.ts";
import type { AskResult, AskState } from "../types.ts";
import { UI_TEXT } from "./constants.ts";
import { pushWrappedText } from "./render-helpers.ts";
import type { Theme } from "./render-types.ts";
export function renderSubmitScreen(
lines: string[],
state: AskState,
theme: Theme,
width: number,
) {
pushWrappedText(lines, UI_TEXT.reviewTitle, width, theme, "accent", " ", " ");
lines.push("");
const submittedAnswers = toAskResult(state).answers;
for (const question of state.questions) {
renderSubmittedQuestion(
lines,
question,
submittedAnswers[question.id],
theme,
width,
);
}
lines.push("");
pushWrappedText(
lines,
UI_TEXT.readyToSubmit,
width,
theme,
"success",
" ",
" ",
);
lines.push("");
renderSubmitActions(lines, state, theme, width);
}
function renderSubmittedQuestion(
lines: string[],
question: AskState["questions"][number],
answer: AskResult["answers"][string] | undefined,
theme: Theme,
width: number,
) {
pushWrappedText(lines, question.prompt, width, theme, "muted", " ● ", " ");
if (!answer) {
lines.push(
truncateToWidth(` ${theme.fg("warning", UI_TEXT.unanswered)}`, width),
);
return;
}
renderSubmittedAnswer(lines, answer, theme, width);
}
function renderSubmittedAnswer(
lines: string[],
answer: AskResult["answers"][string],
theme: Theme,
width: number,
) {
const answerText = answer.customText ?? answer.labels.join(", ");
if (answerText) {
pushWrappedText(
lines,
`→ ${answerText}`,
width,
theme,
answer.customText ? "text" : "success",
" ",
" ",
);
}
if (answer.note) {
pushWrappedText(
lines,
`Question note: ${answer.note}`,
width,
theme,
"muted",
" ",
" ",
);
}
for (let index = 0; index < answer.values.length; index++) {
const value = answer.values[index];
const label = answer.labels[index] ?? value;
const note = answer.optionNotes?.[value];
if (!note) {
continue;
}
pushWrappedText(
lines,
`${label} note: ${note}`,
width,
theme,
"muted",
" ",
" ",
);
}
}
function renderSubmitActions(
lines: string[],
state: AskState,
theme: Theme,
width: number,
) {
for (let index = 0; index < SUBMIT_CHOICES.length; index++) {
const selected = index === state.activeSubmitActionIndex;
const prefix = selected ? "❯ " : " ";
pushWrappedText(
lines,
`${index + 1}. ${SUBMIT_CHOICES[index]}`,
width,
theme,
selected ? "accent" : "text",
prefix,
prefix,
);
}
}
+35
View File
@@ -0,0 +1,35 @@
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
import type { Editor } from "@mariozechner/pi-tui";
import type {
getAnswer,
getCurrentQuestion,
getRenderableOptions,
} from "../state.ts";
import type { AskDisplayOption, AskState } from "../types.ts";
export type Theme = ExtensionContext["ui"]["theme"];
export interface QuestionRenderContext {
lines: string[];
state: AskState;
question: NonNullable<ReturnType<typeof getCurrentQuestion>>;
options: ReturnType<typeof getRenderableOptions>;
theme: Theme;
width: number;
editor: Editor;
newLineHint: string;
}
export interface OptionDetailRenderContext {
lines: string[];
state: AskState;
questionId: string;
answer: ReturnType<typeof getAnswer>;
option: AskDisplayOption | undefined;
theme: Theme;
width: number;
editor: Editor;
newLineHint: string;
selected?: boolean;
withGap?: boolean;
}
+43
View File
@@ -0,0 +1,43 @@
import {
getCurrentQuestion,
getRenderableOptions,
isSubmitTab,
} from "../state.ts";
import type { AskState } from "../types.ts";
import { renderFrameFooter, renderFrameHeader } from "./render-frame.ts";
import { renderQuestionScreen } from "./render-question.ts";
import { renderSubmitScreen } from "./render-submit.ts";
import type { QuestionRenderContext, Theme } from "./render-types.ts";
export function renderAskScreen(args: {
state: AskState;
theme: Theme;
width: number;
editor: QuestionRenderContext["editor"];
newLineHint: string;
}): string[] {
const { state, theme, width, editor, newLineHint } = args;
const lines: string[] = [];
const question = getCurrentQuestion(state);
const options = getRenderableOptions(question);
renderFrameHeader({ lines, state, theme, width });
if (isSubmitTab(state)) {
renderSubmitScreen(lines, state, theme, width);
} else if (question) {
renderQuestionScreen({
lines,
state,
question,
options,
theme,
width,
editor,
newLineHint,
});
}
renderFrameFooter({ lines, state, theme, width });
return lines;
}