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
+56 -1
View File
@@ -15,7 +15,40 @@
"linter": {
"enabled": true,
"rules": {
"recommended": true
"recommended": true,
"complexity": {
"noExcessiveCognitiveComplexity": {
"level": "error",
"options": {
"maxAllowedComplexity": 12
}
},
"noExcessiveLinesPerFunction": {
"level": "warn",
"options": {
"maxLines": 70,
"skipBlankLines": true,
"skipIifes": true
}
}
},
"correctness": {
"noUnusedFunctionParameters": "error"
},
"performance": {
"noAccumulatingSpread": "error"
},
"style": {
"noDefaultExport": "error",
"noNestedTernary": "error",
"noNonNullAssertion": "error",
"noParameterAssign": "error",
"useAsConstAssertion": "error",
"useConst": "error",
"useImportType": "error",
"useNamingConvention": "error",
"useNodejsImportProtocol": "error"
}
}
},
"javascript": {
@@ -23,6 +56,28 @@
"quoteStyle": "double"
}
},
"overrides": [
{
"includes": ["tests/**/*.ts"],
"linter": {
"rules": {
"complexity": {
"noExcessiveLinesPerFunction": "off"
}
}
}
},
{
"includes": ["src/index.ts"],
"linter": {
"rules": {
"style": {
"noDefaultExport": "off"
}
}
}
}
],
"assist": {
"enabled": true,
"actions": {
+1 -995
View File
@@ -1,995 +1 @@
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
import {
Editor,
type EditorTheme,
Key,
matchesKey,
truncateToWidth,
visibleWidth,
} from "@mariozechner/pi-tui";
import { wrapText } from "./text.ts";
import {
applyNumberShortcut,
cancelFlow,
confirmCurrentSelection,
createInitialState,
enterInputMode,
enterOptionNoteMode,
enterQuestionNoteMode,
getAnswer,
getCurrentQuestion,
getOptionNote,
getQuestionNote,
getRenderableOptions,
isQuestionAnswered,
isSubmitTab,
moveOption,
moveTab,
saveCustomAnswer,
saveNote,
submitCustomAnswer,
toAskResult,
toggleCurrentMultiOption,
} from "./state.ts";
import type { AskParams, AskResult, AskState } from "./types.ts";
export async function runAskFlow(
ctx: ExtensionContext,
params: AskParams,
): Promise<AskResult> {
return ctx.ui.custom<AskResult>((tui, theme, keybindings, done) => {
let state: AskState = createInitialState(params);
let cachedLines: string[] | undefined;
let suppressAutoInputForSelection = false;
const newLineHint = formatKeybindingLabel(
keybindings.getKeys("tui.input.newLine")[0] ?? "shift+enter",
);
const editorTheme: EditorTheme = {
borderColor: (s) => theme.fg("accent", s),
selectList: {
selectedPrefix: (t) => theme.fg("accent", t),
selectedText: (t) => theme.fg("accent", t),
description: (t) => theme.fg("muted", t),
scrollInfo: (t) => theme.fg("dim", t),
noMatch: (t) => theme.fg("warning", t),
},
};
const editor = new Editor(tui, editorTheme);
editor.onSubmit = (value) => {
suppressAutoInputForSelection = false;
if (state.mode === "input") {
state = submitCustomAnswer(state, value);
} else if (state.mode === "note") {
state = saveNote(state, value);
}
editor.setText("");
refresh();
maybeFinish();
};
function refresh() {
cachedLines = undefined;
tui.requestRender();
}
function maybeFinish() {
if (state.completed) {
done(toAskResult(state));
}
}
function hydrateEditorForCurrentMode() {
if (state.mode === "input" && state.inputQuestionId) {
const existingText =
getAnswer(state, state.inputQuestionId)?.customText ?? "";
editor.setText(existingText);
return;
}
if (state.mode !== "note" || !state.noteQuestionId) {
return;
}
const existingText = state.noteOptionValue
? (getOptionNote(state, state.noteQuestionId, state.noteOptionValue) ??
"")
: (getQuestionNote(state, state.noteQuestionId) ?? "");
editor.setText(existingText);
}
function syncInputModeWithSelection() {
if (state.mode === "note" || isSubmitTab(state)) {
return;
}
const question = getCurrentQuestion(state);
if (!question) {
return;
}
const option = getRenderableOptions(question)[state.optionIndex];
if (
!option?.isOther ||
suppressAutoInputForSelection ||
state.mode === "input"
) {
return;
}
state = enterInputMode(state, question.id);
hydrateEditorForCurrentMode();
}
syncInputModeWithSelection();
function handleInput(data: string) {
if (state.mode === "input" || state.mode === "note") {
if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) {
state = saveEditorAndMoveTab(1);
syncInputModeWithSelection();
refresh();
return;
}
if (matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left)) {
state = saveEditorAndMoveTab(-1);
syncInputModeWithSelection();
refresh();
return;
}
if (matchesKey(data, Key.escape)) {
state = saveEditorWithoutAdvancing();
suppressAutoInputForSelection = state.mode !== "input";
refresh();
return;
}
editor.handleInput(data);
refresh();
return;
}
if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) {
suppressAutoInputForSelection = false;
state = moveTab(state, 1);
syncInputModeWithSelection();
refresh();
return;
}
if (matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left)) {
suppressAutoInputForSelection = false;
state = moveTab(state, -1);
syncInputModeWithSelection();
refresh();
return;
}
if (matchesKey(data, Key.up)) {
suppressAutoInputForSelection = false;
state = moveOption(state, -1);
syncInputModeWithSelection();
refresh();
return;
}
if (matchesKey(data, Key.down)) {
suppressAutoInputForSelection = false;
state = moveOption(state, 1);
syncInputModeWithSelection();
refresh();
return;
}
if (matchesKey(data, Key.space)) {
const question = getCurrentQuestion(state);
if (question?.type === "multi") {
suppressAutoInputForSelection = false;
state = toggleCurrentMultiOption(state);
if (state.mode === "input") {
hydrateEditorForCurrentMode();
}
refresh();
return;
}
}
if (isQuestionNoteShortcut(data)) {
const question = getCurrentQuestion(state);
if (question && !isSubmitTab(state)) {
state = enterQuestionNoteMode(state, question.id);
hydrateEditorForCurrentMode();
refresh();
}
return;
}
if (isOptionNoteShortcut(data)) {
const question = getCurrentQuestion(state);
const option = question
? getRenderableOptions(question)[state.optionIndex]
: undefined;
if (question && option && !option.isOther && !isSubmitTab(state)) {
state = enterOptionNoteMode(state, question.id, option.value);
hydrateEditorForCurrentMode();
refresh();
}
return;
}
if (matchesKey(data, Key.enter)) {
suppressAutoInputForSelection = false;
state = confirmCurrentSelection(state);
if (state.mode === "input") {
hydrateEditorForCurrentMode();
}
refresh();
maybeFinish();
return;
}
if (matchesKey(data, Key.escape)) {
state = cancelFlow(state);
refresh();
maybeFinish();
return;
}
const digit = parseDigit(data);
if (digit !== null) {
suppressAutoInputForSelection = false;
state = applyNumberShortcut(state, digit);
if (state.mode === "input") {
hydrateEditorForCurrentMode();
}
refresh();
}
}
function saveEditorWithoutAdvancing(): AskState {
const text = editor.getText();
editor.setText("");
if (state.mode === "input") {
suppressAutoInputForSelection = true;
return saveCustomAnswer(state, text);
}
return saveNote(state, text);
}
function saveEditorAndMoveTab(delta: number): AskState {
const nextState = saveEditorWithoutAdvancing();
suppressAutoInputForSelection = false;
return moveTab(nextState, delta);
}
function render(width: number): string[] {
if (cachedLines) return cachedLines;
const lines: string[] = [];
const add = (text = "") => lines.push(truncateToWidth(text, width));
const question = getCurrentQuestion(state);
const options = getRenderableOptions(question);
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();
if (isSubmitTab(state)) {
renderSubmit(lines, state, theme, width);
} else if (question) {
renderQuestionContent({
lines,
state,
question,
options,
theme,
width,
editor,
newLineHint,
});
}
const footer = renderFooter(state, theme);
if (footer) {
add();
add(footer);
}
add(theme.fg("accent", "─".repeat(Math.max(1, width))));
cachedLines = lines;
return lines;
}
return {
render,
invalidate() {
cachedLines = undefined;
},
handleInput,
};
});
}
function renderTabs(
state: AskState,
theme: ExtensionContext["ui"]["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.currentTab === 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 submitActive = isSubmitTab(state);
const submitText = " ✔ Submit ";
segments.push(
submitActive
? theme.bg("selectedBg", theme.fg("text", submitText))
: theme.fg("success", submitText),
);
segments.push(theme.fg("dim", " →"));
return truncateToWidth(` ${segments.join(" ")}`, width);
}
function renderSubmit(
lines: string[],
state: AskState,
theme: ExtensionContext["ui"]["theme"],
width: number,
) {
pushWrappedText(
lines,
"Review your answers",
width,
theme,
"accent",
" ",
" ",
);
lines.push("");
const submittedAnswers = toAskResult(state).answers;
for (const question of state.questions) {
const answer = submittedAnswers[question.id];
pushWrappedText(
lines,
question.prompt,
width,
theme,
"muted",
" ● ",
" ",
);
if (!answer) {
lines.push(
truncateToWidth(` ${theme.fg("warning", "→ (unanswered)")}`, width),
);
continue;
}
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",
" ",
" ",
);
}
}
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 ? "❯ " : " ";
pushWrappedText(
lines,
`${i + 1}. ${options[i]}`,
width,
theme,
selected ? "accent" : "text",
prefix,
prefix,
);
}
}
function renderQuestionContent(args: {
lines: string[];
state: AskState;
question: NonNullable<ReturnType<typeof getCurrentQuestion>>;
options: ReturnType<typeof getRenderableOptions>;
theme: ExtensionContext["ui"]["theme"];
width: number;
editor: Editor;
newLineHint: string;
}) {
const { lines, state, question, options, theme, width, editor, newLineHint } =
args;
renderQuestionPrompt(lines, question.prompt, theme, width);
renderQuestionNote(
lines,
state,
question.id,
theme,
width,
editor,
newLineHint,
);
if (question.type === "preview") {
renderPreviewQuestion(
lines,
state,
question,
options,
theme,
width,
editor,
newLineHint,
);
return;
}
renderStandardQuestion(
lines,
state,
question,
options,
theme,
width,
editor,
newLineHint,
);
}
function renderQuestionPrompt(
lines: string[],
prompt: string,
theme: ExtensionContext["ui"]["theme"],
width: number,
) {
pushWrappedText(lines, prompt, width, theme, "text", " ", " ");
lines.push("");
}
function renderQuestionNote(
lines: string[],
state: AskState,
questionId: string,
theme: ExtensionContext["ui"]["theme"],
width: number,
editor: Editor,
newLineHint: string,
) {
const existingNote = getQuestionNote(state, questionId);
if (
state.mode === "note" &&
state.noteQuestionId === questionId &&
!state.noteOptionValue
) {
pushWrappedText(lines, "Question note", width, theme, "accent", " ", " ");
for (const editorLine of editor.render(Math.max(8, width - 5))) {
lines.push(
truncateToWidth(
` ${renderInputLine(editorLine, width - 3, theme)}`,
width,
),
);
}
lines.push(
truncateToWidth(
` ${theme.fg("dim", `${newLineHint} newline · Enter save · Esc save and close`)}`,
width,
),
);
lines.push("");
return;
}
if (!existingNote) {
return;
}
pushWrappedText(
lines,
`Question note: ${existingNote}`,
width,
theme,
"muted",
" ",
" ",
);
lines.push("");
}
function renderStandardQuestion(
lines: string[],
state: AskState,
question: NonNullable<ReturnType<typeof getCurrentQuestion>>,
options: ReturnType<typeof getRenderableOptions>,
theme: ExtensionContext["ui"]["theme"],
width: number,
editor: Editor,
newLineHint: string,
) {
const isMulti = question.type === "multi";
const answer = getAnswer(state, question.id);
for (let i = 0; i < options.length; i++) {
const option = options[i];
const selected = i === state.optionIndex;
const optionNote = option.isOther
? undefined
: answer?.optionNotes?.[option.value];
const isAnsweredOption = option.isOther
? !!answer?.customText
: !!answer?.values.includes(option.value);
const pointer = selected ? "❯ " : " ";
const prefix =
isMulti && !option.isOther ? `[${isAnsweredOption ? "x" : " "}] ` : "";
const optionColor = isAnsweredOption
? "success"
: selected
? "accent"
: "text";
pushWrappedText(
lines,
`${i + 1}. ${prefix}${option.label}`,
width,
theme,
optionColor,
pointer,
" ".repeat(visibleWidth(pointer)),
);
if (option.description) {
pushWrappedText(
lines,
option.description,
width,
theme,
"muted",
" ",
" ",
);
}
if (
!option.isOther &&
state.mode === "note" &&
state.noteQuestionId === question.id &&
state.noteOptionValue === option.value
) {
for (const editorLine of editor.render(Math.max(8, width - 7))) {
lines.push(
truncateToWidth(
` ${renderInputLine(editorLine, width - 5, theme)}`,
width,
),
);
}
lines.push(
truncateToWidth(
` ${theme.fg("dim", `${newLineHint} newline · Enter save · Esc save and close`)}`,
width,
),
);
} else if (option.isOther && selected && state.mode === "input") {
for (const editorLine of editor.render(Math.max(8, width - 7))) {
lines.push(
truncateToWidth(
` ${renderInputLine(editorLine, width - 5, theme)}`,
width,
),
);
}
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) {
pushWrappedText(
lines,
customText,
width,
theme,
"muted",
" ",
" ",
);
}
}
if (
optionNote &&
!(state.mode === "note" && state.noteOptionValue === option.value)
) {
pushWrappedText(
lines,
`Note: ${optionNote}`,
width,
theme,
"muted",
" ",
" ",
);
}
}
}
function renderPreviewQuestion(
lines: string[],
state: AskState,
question: NonNullable<ReturnType<typeof getCurrentQuestion>>,
options: ReturnType<typeof getRenderableOptions>,
theme: ExtensionContext["ui"]["theme"],
width: number,
editor: Editor,
newLineHint: string,
) {
const add = (text = "") => lines.push(truncateToWidth(text, width));
const selectedOption = options[state.optionIndex];
const wideLayout = width >= 90 && options.length > 0;
const answer = getAnswer(state, question.id);
if (wideLayout) {
const leftWidth = measurePreviewLeftWidth(options, width);
const rightWidth = Math.max(24, width - leftWidth - 2);
const leftPane = renderPreviewOptionList(
state,
question,
options,
theme,
leftWidth,
);
const rightPane = renderPreviewPane(selectedOption, theme, rightWidth);
for (const line of mergeColumns(leftPane, rightPane, leftWidth, width)) {
add(line);
}
} else {
renderPreviewOptionList(state, question, options, theme, width).forEach(
add,
);
add("");
renderPreviewPane(selectedOption, theme, width).forEach(add);
}
if (
selectedOption &&
!selectedOption.isOther &&
state.mode === "note" &&
state.noteQuestionId === question.id &&
state.noteOptionValue === selectedOption.value
) {
add("");
for (const editorLine of editor.render(Math.max(8, width - 7))) {
add(` ${renderInputLine(editorLine, width - 5, theme)}`);
}
add(
` ${theme.fg("dim", `${newLineHint} newline · Enter save · Esc save and close`)}`,
);
} else if (selectedOption && !selectedOption.isOther) {
const optionNote = answer?.optionNotes?.[selectedOption.value];
if (optionNote) {
add("");
for (const line of wrapText(
`Note: ${optionNote}`,
Math.max(1, width - 5),
)) {
add(` ${theme.fg("muted", line)}`);
}
}
}
if (selectedOption?.isOther && state.mode === "input") {
add("");
for (const editorLine of editor.render(Math.max(8, width - 7))) {
add(` ${renderInputLine(editorLine, width - 5, theme)}`);
}
add(
` ${theme.fg("dim", `${newLineHint} newline · Enter submit · Esc save and close`)}`,
);
}
if (selectedOption?.isOther && state.mode !== "input" && answer?.customText) {
add("");
for (const customLine of wrapText(
answer.customText,
Math.max(1, width - 5),
)) {
add(` ${theme.fg("muted", customLine)}`);
}
}
}
function renderPreviewOptionList(
state: AskState,
question: NonNullable<ReturnType<typeof getCurrentQuestion>>,
options: ReturnType<typeof getRenderableOptions>,
theme: ExtensionContext["ui"]["theme"],
width: number,
): string[] {
const lines: string[] = [];
const answer = getAnswer(state, question.id);
for (let i = 0; i < options.length; i++) {
const option = options[i];
const selected = i === state.optionIndex;
const isAnsweredOption = option.isOther
? !!answer?.customText
: !!answer?.values.includes(option.value);
const pointer = selected ? "❯ " : " ";
const optionColor = isAnsweredOption
? "success"
: selected
? "accent"
: "text";
pushWrappedText(
lines,
`${i + 1}. ${option.label}`,
width,
theme,
optionColor,
pointer,
" ".repeat(visibleWidth(pointer)),
);
if (option.description) {
pushWrappedText(
lines,
option.description,
width,
theme,
"muted",
" ",
" ",
);
}
}
return lines;
}
function renderPreviewPane(
selectedOption: ReturnType<typeof getRenderableOptions>[number] | undefined,
theme: ExtensionContext["ui"]["theme"],
width: number,
): string[] {
const paneWidth = Math.max(10, width);
if (!selectedOption) {
return renderBox(
[{ text: "No preview available", color: "dim" }],
paneWidth,
theme,
);
}
const content: Array<{
text: string;
color: "accent" | "muted" | "text" | "dim";
}> = [];
content.push({ text: selectedOption.label, color: "accent" });
if (selectedOption.description) {
content.push({ text: selectedOption.description, color: "muted" });
}
if (selectedOption.preview) {
if (content.length) {
content.push({ text: "", color: "dim" });
}
for (const previewLine of selectedOption.preview.split("\n")) {
content.push({ text: previewLine, color: "text" });
}
} else {
if (content.length) {
content.push({ text: "", color: "dim" });
}
content.push({ text: "No preview available", color: "dim" });
}
return renderBox(content, paneWidth, theme);
}
function renderBox(
content: Array<{ text: string; color: "accent" | "muted" | "text" | "dim" }>,
width: number,
theme: ExtensionContext["ui"]["theme"],
): string[] {
const boxWidth = Math.max(10, 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;
}
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;
}
function padToVisibleWidth(text: string, width: number): string {
return text + " ".repeat(Math.max(0, width - visibleWidth(text)));
}
function measurePreviewLeftWidth(
options: ReturnType<typeof getRenderableOptions>,
width: number,
): number {
let widest = 0;
for (let i = 0; i < options.length; i++) {
const option = options[i];
const labelWidth = visibleWidth(`${i + 1}. ${option.label}`);
const descriptionWidth = option.description
? visibleWidth(option.description)
: 0;
widest = Math.max(widest, labelWidth, descriptionWidth);
}
const preferred = widest + 4;
const maxWidth = Math.min(34, Math.floor(width * 0.34));
return clamp(preferred, 22, Math.max(22, maxWidth));
}
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"],
): string {
if (state.mode === "input") {
return theme.fg(
"dim",
" Enter submit · Esc save and close · Tab/←→ save and navigate",
);
}
if (state.mode === "note") {
return theme.fg(
"dim",
" Enter save · Esc save and close · Tab/←→ save and navigate",
);
}
const question = getCurrentQuestion(state);
if (isSubmitTab(state)) {
return theme.fg("dim", " ↑↓ select · Enter confirm · Esc cancel");
}
if (question?.type === "multi") {
return theme.fg(
"dim",
" ⇆ tab · ↑↓ select · Space toggle · Enter continue · N option note · Ctrl+N question note · Esc dismiss · 1-9 quick toggle",
);
}
return theme.fg(
"dim",
" ⇆ tab · ↑↓ select · Enter confirm · N option note · Ctrl+N question note · Esc dismiss",
);
}
function parseDigit(data: string): number | null {
return /^[1-9]$/.test(data) ? Number(data) : null;
}
function renderInputLine(
line: string,
availableWidth: number,
theme: ExtensionContext["ui"]["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} `);
}
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 isOptionNoteShortcut(data: string): boolean {
return data === "n" || data === "N";
}
function isQuestionNoteShortcut(data: string): boolean {
return matchesKey(data, Key.ctrl("n"));
}
export { runAskFlow } from "./ui/controller.ts";
+5 -1
View File
@@ -5,6 +5,7 @@ import { renderResultText } from "./result.ts";
import { AskParamsSchema } from "./schema.ts";
import { createInitialState, summarizeResult, toAskResult } from "./state.ts";
import type { AskParams, AskQuestionInput, AskResult } from "./types.ts";
import { UI_DIMENSIONS } from "./ui/constants.ts";
export function registerAskTool(pi: ExtensionAPI) {
pi.registerTool({
@@ -63,7 +64,10 @@ export function registerAskTool(pi: ExtensionAPI) {
let text = theme.fg("toolTitle", theme.bold("ask_user "));
text += theme.fg("muted", `${params.questions?.length ?? 0} question(s)`);
if (labels) {
text += theme.fg("dim", ` (${truncateToWidth(labels, 50)})`);
text += theme.fg(
"dim",
` (${truncateToWidth(labels, UI_DIMENSIONS.callLabelTruncateWidth)})`,
);
}
return new Text(text, 0, 0);
},
+8
View File
@@ -0,0 +1,8 @@
export const OTHER_OPTION_VALUE = "__other__";
export const OTHER_OPTION_LABEL = "Type your own";
export const SUBMIT_CHOICES = ["Submit answers", "Cancel"] as const;
export const NO_PREVIEW_TEXT = "No preview available";
export const CANCELLED_SUMMARY = "User cancelled the ask flow";
export const SUBMITTED_SUMMARY = "User submitted the ask flow";
export const CANCELLED_RESULT_TEXT = "Cancelled";
export const SUBMITTED_RESULT_TEXT = "Submitted";
+86
View File
@@ -0,0 +1,86 @@
import type { AskResult } from "./types.ts";
export function formatResultLines(
result: AskResult,
options: { mode: "summary" | "render" },
): string[] {
const lines: string[] = [];
for (const question of result.questions) {
const answer = result.answers[question.id];
if (!answer) {
continue;
}
const answerLine = formatAnswerLine(question.label, answer, options.mode);
if (answerLine) {
lines.push(answerLine);
}
const questionNoteLine = formatQuestionNoteLine(
question.label,
answer.note,
options.mode,
);
if (questionNoteLine) {
lines.push(questionNoteLine);
}
lines.push(...formatOptionNoteLines(question.label, answer, options.mode));
}
return lines;
}
function formatAnswerLine(
questionLabel: string,
answer: AskResult["answers"][string],
mode: "summary" | "render",
): string | undefined {
const answerText = answer.customText ?? answer.labels.join(", ");
if (!answerText) {
return undefined;
}
if (mode === "summary") {
return `${questionLabel}: ${answerText}`;
}
if (answer.customText) {
return `✓ ${questionLabel}: (wrote) ${answerText}`;
}
return `✓ ${questionLabel}: ${answerText}`;
}
function formatQuestionNoteLine(
questionLabel: string,
note: string | undefined,
mode: "summary" | "render",
): string | undefined {
if (!note) {
return undefined;
}
return mode === "summary"
? `${questionLabel} note: ${note}`
: ` note: ${note}`;
}
function formatOptionNoteLines(
questionLabel: string,
answer: AskResult["answers"][string],
mode: "summary" | "render",
): string[] {
const lines: string[] = [];
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;
}
lines.push(
mode === "summary"
? `${questionLabel} / ${label} note: ${note}`
: ` ${label} note: ${note}`,
);
}
return lines;
}
+5 -22
View File
@@ -1,29 +1,12 @@
import { CANCELLED_RESULT_TEXT, SUBMITTED_RESULT_TEXT } from "./constants.ts";
import { formatResultLines } from "./result-format.ts";
import type { AskResult } from "./types.ts";
export function renderResultText(result: AskResult): string {
if (result.cancelled) {
return "Cancelled";
return CANCELLED_RESULT_TEXT;
}
const lines: string[] = [];
for (const question of result.questions) {
const answer = result.answers[question.id];
if (!answer) continue;
if (answer.customText) {
lines.push(`✓ ${question.label}: (wrote) ${answer.customText}`);
} else if (answer.labels.length > 0) {
lines.push(`✓ ${question.label}: ${answer.labels.join(", ")}`);
}
if (answer.note) {
lines.push(` note: ${answer.note}`);
}
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;
lines.push(` ${label} note: ${note}`);
}
}
return lines.join("\n") || "Submitted";
const lines = formatResultLines(result, { mode: "render" });
return lines.join("\n") || SUBMITTED_RESULT_TEXT;
}
+46 -541
View File
@@ -1,547 +1,52 @@
import type {
AskAnswer,
AskParams,
AskQuestion,
AskQuestionInput,
AskResult,
AskState,
RenderOption,
} from "./types.ts";
import { normalizeQuestions } from "./state/normalize.ts";
import { createInitialState as createBaseState } from "./state/transitions.ts";
const OTHER_VALUE = "__other__";
export { isOptionSelected } from "./state/answers.ts";
export { normalizeQuestions } from "./state/normalize.ts";
export { summarizeResult, toAskResult } from "./state/result.ts";
export {
getAnswer,
getCurrentOption,
getCurrentQuestion,
getOptionNote,
getQuestionById,
getQuestionNote,
getRenderableOptions,
isInputOpenForQuestion,
isOptionNoteOpen,
isQuestionAnswered,
isQuestionNoteOpen,
isSubmitTab,
} from "./state/selectors.ts";
export {
applyNumberShortcut,
cancelFlow,
confirmCurrentSelection,
enterInputMode,
enterOptionNoteMode,
enterQuestionNoteMode,
moveOption,
moveTab,
reduceAskState,
saveCustomAnswer,
saveNote,
submitCustomAnswer,
toggleCurrentMultiOption,
} from "./state/transitions.ts";
export {
inputView,
isEditingView,
navigateView,
optionNoteView,
questionNoteView,
submitView,
} from "./state/view.ts";
export function normalizeQuestions(params: AskParams): AskQuestion[] {
return params.questions.map((question, index) =>
normalizeQuestion(question, index),
);
}
function normalizeQuestion(
question: AskQuestionInput,
index: number,
): AskQuestion {
return {
...question,
label: question.label?.trim() || `Q${index + 1}`,
type: question.type ?? "single",
required: question.required !== false,
};
}
export function createInitialState(params: AskParams): AskState {
return {
export function createInitialState(
params: Parameters<typeof normalizeQuestions>[0],
) {
return createBaseState({
title: params.title,
questions: normalizeQuestions(params),
currentTab: 0,
optionIndex: 0,
submitIndex: 0,
mode: "navigate",
inputQuestionId: null,
noteQuestionId: null,
noteOptionValue: null,
answers: {},
completed: false,
cancelled: false,
};
}
export function getCurrentQuestion(state: AskState): AskQuestion | undefined {
return state.questions[state.currentTab];
}
export function isSubmitTab(state: AskState): boolean {
return state.currentTab >= state.questions.length;
}
export function getRenderableOptions(question?: AskQuestion): RenderOption[] {
if (!question) {
return [];
}
const options: RenderOption[] = [...question.options];
if (question.type !== "preview") {
options.push({
value: OTHER_VALUE,
label: "Type your own",
isOther: true,
});
}
return options;
}
export function getAnswer(
state: AskState,
questionId: string,
): AskAnswer | undefined {
return state.answers[questionId];
}
export function getQuestionNote(
state: AskState,
questionId: string,
): string | undefined {
return state.answers[questionId]?.note;
}
export function getOptionNote(
state: AskState,
questionId: string,
optionValue: string,
): string | undefined {
return state.answers[questionId]?.optionNotes?.[optionValue];
}
export function isQuestionAnswered(
state: AskState,
questionId: string,
): boolean {
const answer = state.answers[questionId];
return !!answer && answer.labels.length > 0;
}
export function moveTab(state: AskState, delta: number): AskState {
const totalTabs = state.questions.length + 1;
const currentTab = (state.currentTab + delta + totalTabs) % totalTabs;
return {
...state,
currentTab,
optionIndex: 0,
submitIndex: 0,
mode: isSubmitTab({ ...state, currentTab }) ? "submit" : "navigate",
inputQuestionId: null,
noteQuestionId: null,
noteOptionValue: null,
};
}
export function moveOption(state: AskState, delta: number): AskState {
if (isSubmitTab(state)) {
return {
...state,
submitIndex: clamp(state.submitIndex + delta, 0, 1),
};
}
const options = getRenderableOptions(getCurrentQuestion(state));
return {
...state,
optionIndex: clamp(
state.optionIndex + delta,
0,
Math.max(0, options.length - 1),
),
};
}
export function enterInputMode(state: AskState, questionId: string): AskState {
return {
...state,
mode: "input",
inputQuestionId: questionId,
noteQuestionId: null,
noteOptionValue: null,
};
}
export function exitInputMode(state: AskState): AskState {
return {
...state,
mode: isSubmitTab(state) ? "submit" : "navigate",
inputQuestionId: null,
};
}
export function enterQuestionNoteMode(
state: AskState,
questionId: string,
): AskState {
return {
...state,
mode: "note",
inputQuestionId: null,
noteQuestionId: questionId,
noteOptionValue: null,
};
}
export function enterOptionNoteMode(
state: AskState,
questionId: string,
optionValue: string,
): AskState {
return {
...state,
mode: "note",
inputQuestionId: null,
noteQuestionId: questionId,
noteOptionValue: optionValue,
};
}
export function exitNoteMode(state: AskState): AskState {
return {
...state,
mode: isSubmitTab(state) ? "submit" : "navigate",
noteQuestionId: null,
noteOptionValue: null,
};
}
export function toggleCurrentMultiOption(state: AskState): AskState {
const question = getCurrentQuestion(state);
if (!question || question.type === "single") return state;
const options = getRenderableOptions(question);
const option = options[state.optionIndex];
if (!option) return state;
if (option.isOther) {
return enterInputMode(state, question.id);
}
return toggleAnswer(state, question.id, option, state.optionIndex);
}
export function confirmCurrentSelection(state: AskState): AskState {
if (isSubmitTab(state)) {
if (state.submitIndex === 1) {
return { ...state, cancelled: true, completed: true };
}
return { ...state, completed: true };
}
const question = getCurrentQuestion(state);
if (!question) return state;
if (question.type === "multi") {
const currentOption = getRenderableOptions(question)[state.optionIndex];
if (currentOption?.isOther) {
return enterInputMode(state, question.id);
}
return advanceToNextTab(state);
}
const option = getRenderableOptions(question)[state.optionIndex];
if (!option) return state;
if (option.isOther) {
return enterInputMode(state, question.id);
}
const nextState = setSingleAnswer(
state,
question.id,
option,
state.optionIndex,
);
return advanceToNextTab(nextState);
}
export function applyNumberShortcut(state: AskState, digit: number): AskState {
if (digit <= 0 || isSubmitTab(state)) return state;
const question = getCurrentQuestion(state);
if (!question) return state;
const options = getRenderableOptions(question);
const index = digit - 1;
const option = options[index];
if (!option) return state;
const selectedState = { ...state, optionIndex: index };
if (option.isOther) {
return enterInputMode(selectedState, question.id);
}
if (question.type === "multi") {
return toggleAnswer(selectedState, question.id, option, index);
}
const nextState = setSingleAnswer(selectedState, question.id, option, index);
return advanceToNextTab(nextState);
}
export function saveCustomAnswer(state: AskState, rawValue: string): AskState {
const question = state.questions.find(
(item) => item.id === state.inputQuestionId,
);
if (!question) return exitInputMode(state);
const nextState = exitInputMode(state);
const trimmed = rawValue.trim();
const existing = nextState.answers[question.id];
if (!trimmed) {
return {
...nextState,
answers: updateAnswer(nextState.answers, question.id, (answer) => {
delete answer.customText;
answer.values = [];
answer.labels = [];
answer.indices = [];
}),
};
}
return {
...nextState,
answers: {
...nextState.answers,
[question.id]: {
...emptyAnswer(),
...existing,
values: [rawValue],
labels: [rawValue],
indices: [],
customText: rawValue,
},
},
};
}
export function submitCustomAnswer(
state: AskState,
rawValue: string,
): AskState {
const questionId = state.inputQuestionId;
const nextState = saveCustomAnswer(state, rawValue);
if (!questionId || !isQuestionAnswered(nextState, questionId)) {
return nextState;
}
return advanceToNextTab(nextState);
}
export function saveNote(state: AskState, rawValue: string): AskState {
const questionId = state.noteQuestionId;
if (!questionId) {
return exitNoteMode(state);
}
const optionValue = state.noteOptionValue;
const trimmed = rawValue.trim();
const nextState = exitNoteMode(state);
return {
...nextState,
answers: updateAnswer(nextState.answers, questionId, (answer) => {
if (optionValue) {
const optionNotes = { ...(answer.optionNotes ?? {}) };
if (trimmed) {
optionNotes[optionValue] = rawValue;
} else {
delete optionNotes[optionValue];
}
answer.optionNotes =
Object.keys(optionNotes).length > 0 ? optionNotes : undefined;
return;
}
if (trimmed) {
answer.note = rawValue;
} else {
delete answer.note;
}
}),
};
}
export function cancelFlow(state: AskState): AskState {
if (state.mode === "input") {
return exitInputMode(state);
}
if (state.mode === "note") {
return exitNoteMode(state);
}
return {
...state,
cancelled: true,
completed: true,
};
}
export function toAskResult(state: AskState): AskResult {
return {
title: state.title,
cancelled: state.cancelled,
questions: state.questions.map((question) => ({
id: question.id,
label: question.label,
prompt: question.prompt,
type: question.type,
})),
answers: filterAnswersForSubmission(state.answers),
};
}
export function summarizeResult(result: AskResult): string {
if (result.cancelled) {
return "User cancelled the ask flow";
}
const lines = result.questions.flatMap((question) => {
const answer = result.answers[question.id];
if (!answer) return [];
const questionLines: string[] = [];
if (answer.customText) {
questionLines.push(`${question.label}: ${answer.customText}`);
} else if (answer.labels.length > 0) {
questionLines.push(`${question.label}: ${answer.labels.join(", ")}`);
}
if (answer.note) {
questionLines.push(`${question.label} note: ${answer.note}`);
}
for (const label of answer.labels) {
const optionValue = answer.values[answer.labels.indexOf(label)];
const note = optionValue ? answer.optionNotes?.[optionValue] : undefined;
if (note) {
questionLines.push(`${question.label} / ${label} note: ${note}`);
}
}
return questionLines;
});
return lines.join("\n") || "User submitted the ask flow";
}
function toggleAnswer(
state: AskState,
questionId: string,
option: RenderOption,
index: number,
): AskState {
const existing = state.answers[questionId] ?? emptyAnswer();
const next = cloneAnswer(existing);
delete next.customText;
const foundIndex = next.values.indexOf(option.value);
if (foundIndex >= 0) {
next.values.splice(foundIndex, 1);
next.labels.splice(foundIndex, 1);
next.indices.splice(foundIndex, 1);
} else {
next.values.push(option.value);
next.labels.push(option.label);
next.indices.push(index + 1);
}
return {
...state,
answers: {
...state.answers,
[questionId]: next,
},
};
}
function setSingleAnswer(
state: AskState,
questionId: string,
option: RenderOption,
index: number,
): AskState {
const existing = state.answers[questionId] ?? emptyAnswer();
return {
...state,
answers: {
...state.answers,
[questionId]: {
...emptyAnswer(),
...existing,
values: [option.value],
labels: [option.label],
indices: [index + 1],
customText: undefined,
},
},
};
}
function advanceToNextTab(state: AskState): AskState {
const totalQuestions = state.questions.length;
if (totalQuestions === 1) {
return {
...state,
currentTab: totalQuestions,
mode: "submit",
optionIndex: 0,
submitIndex: 0,
inputQuestionId: null,
noteQuestionId: null,
noteOptionValue: null,
};
}
const nextTab = Math.min(state.currentTab + 1, totalQuestions);
return {
...state,
currentTab: nextTab,
mode: nextTab === totalQuestions ? "submit" : "navigate",
optionIndex: 0,
submitIndex: 0,
inputQuestionId: null,
noteQuestionId: null,
noteOptionValue: null,
};
}
function emptyAnswer(): AskAnswer {
return { values: [], labels: [], indices: [] };
}
function cloneAnswer(answer: AskAnswer): AskAnswer {
return {
values: [...answer.values],
labels: [...answer.labels],
indices: [...answer.indices],
customText: answer.customText,
note: answer.note,
optionNotes: answer.optionNotes ? { ...answer.optionNotes } : undefined,
};
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
function updateAnswer(
answers: Record<string, AskAnswer>,
questionId: string,
mutate: (answer: AskAnswer) => void,
): Record<string, AskAnswer> {
const nextAnswer = cloneAnswer(answers[questionId] ?? emptyAnswer());
mutate(nextAnswer);
if (isEmptyAnswer(nextAnswer)) {
const nextAnswers = { ...answers };
delete nextAnswers[questionId];
return nextAnswers;
}
return {
...answers,
[questionId]: nextAnswer,
};
}
function isEmptyAnswer(answer: AskAnswer): boolean {
return (
answer.values.length === 0 &&
answer.labels.length === 0 &&
answer.indices.length === 0 &&
!answer.customText &&
!answer.note &&
(!answer.optionNotes || Object.keys(answer.optionNotes).length === 0)
);
}
function filterAnswersForSubmission(
answers: Record<string, AskAnswer>,
): Record<string, AskAnswer> {
const result: Record<string, AskAnswer> = {};
for (const [questionId, answer] of Object.entries(answers)) {
const next = cloneAnswer(answer);
if (next.optionNotes) {
const selectedNotes = Object.fromEntries(
next.values
.map((value) => [value, next.optionNotes?.[value]])
.filter((entry): entry is [string, string] => !!entry[1]),
);
next.optionNotes =
Object.keys(selectedNotes).length > 0 ? selectedNotes : undefined;
}
if (!isEmptyAnswer(next)) {
result[questionId] = next;
}
}
return result;
}
+179
View File
@@ -0,0 +1,179 @@
import type {
AskDisplayOption,
AskResultAnswer,
AskSelectedOption,
AskStateAnswer,
} from "../types.ts";
export function emptyAnswer(): AskStateAnswer {
return { selected: [] };
}
export function cloneAnswer(answer: AskStateAnswer): AskStateAnswer {
return {
selected: answer.selected.map(cloneSelection),
customText: answer.customText,
note: answer.note,
optionNotes: answer.optionNotes ? { ...answer.optionNotes } : undefined,
};
}
export function cloneResultAnswer(answer: AskResultAnswer): AskResultAnswer {
return {
values: [...answer.values],
labels: [...answer.labels],
indices: [...answer.indices],
customText: answer.customText,
note: answer.note,
optionNotes: answer.optionNotes ? { ...answer.optionNotes } : undefined,
};
}
export function toggleSelection(
answer: AskStateAnswer,
option: AskDisplayOption,
index: number,
): AskStateAnswer {
const next = cloneAnswer(answer);
delete next.customText;
const selectedIndex = next.selected.findIndex(
(selection) => selection.value === option.value,
);
if (selectedIndex >= 0) {
next.selected.splice(selectedIndex, 1);
return next;
}
next.selected.push({
value: option.value,
label: option.label,
index: index + 1,
});
return next;
}
export function setSingleSelection(
answer: AskStateAnswer,
option: AskDisplayOption,
index: number,
): AskStateAnswer {
return {
...emptyAnswer(),
note: answer.note,
optionNotes: answer.optionNotes ? { ...answer.optionNotes } : undefined,
selected: [
{
value: option.value,
label: option.label,
index: index + 1,
},
],
};
}
export function saveCustomText(
answer: AskStateAnswer,
rawValue: string,
): AskStateAnswer {
const trimmed = rawValue.trim();
const next = cloneAnswer(answer);
next.selected = [];
if (!trimmed) {
delete next.customText;
return next;
}
next.customText = rawValue;
return next;
}
export function saveQuestionNote(
answer: AskStateAnswer,
rawValue: string,
): AskStateAnswer {
const next = cloneAnswer(answer);
if (rawValue.trim()) {
next.note = rawValue;
return next;
}
delete next.note;
return next;
}
export function saveOptionNote(
answer: AskStateAnswer,
optionValue: string,
rawValue: string,
): AskStateAnswer {
const next = cloneAnswer(answer);
const optionNotes = { ...(next.optionNotes ?? {}) };
if (rawValue.trim()) {
optionNotes[optionValue] = rawValue;
} else {
delete optionNotes[optionValue];
}
next.optionNotes =
Object.keys(optionNotes).length > 0 ? optionNotes : undefined;
return next;
}
export function isAnswerEmpty(answer: AskStateAnswer): boolean {
return (
answer.selected.length === 0 &&
!answer.customText &&
!answer.note &&
(!answer.optionNotes || Object.keys(answer.optionNotes).length === 0)
);
}
export function isAnswerAnswered(answer?: AskStateAnswer): boolean {
if (!answer) {
return false;
}
return answer.selected.length > 0 || !!answer.customText?.trim();
}
export function isOptionSelected(
answer: AskStateAnswer | undefined,
optionValue: string,
): boolean {
return !!answer?.selected.some(
(selection) => selection.value === optionValue,
);
}
export function serializeAnswer(answer: AskStateAnswer): AskResultAnswer {
const values = answer.customText
? [answer.customText]
: answer.selected.map((selection) => selection.value);
const labels = answer.customText
? [answer.customText]
: answer.selected.map((selection) => selection.label);
const indices = answer.selected.map((selection) => selection.index);
const selectedNotes = answer.optionNotes
? Object.fromEntries(
answer.selected
.map((selection) => [
selection.value,
answer.optionNotes?.[selection.value],
])
.filter((entry): entry is [string, string] => !!entry[1]),
)
: undefined;
return {
values,
labels,
indices,
customText: answer.customText,
note: answer.note,
optionNotes:
selectedNotes && Object.keys(selectedNotes).length > 0
? selectedNotes
: undefined,
};
}
function cloneSelection(selection: AskSelectedOption): AskSelectedOption {
return { ...selection };
}
+19
View File
@@ -0,0 +1,19 @@
import type { AskParams, AskQuestion, AskQuestionInput } from "../types.ts";
export function normalizeQuestions(params: AskParams): AskQuestion[] {
return params.questions.map((question, index) =>
normalizeQuestion(question, index),
);
}
function normalizeQuestion(
question: AskQuestionInput,
index: number,
): AskQuestion {
return {
...question,
label: question.label?.trim() || `Q${index + 1}`,
type: question.type ?? "single",
required: question.required !== false,
};
}
+54
View File
@@ -0,0 +1,54 @@
import { CANCELLED_SUMMARY, SUBMITTED_SUMMARY } from "../constants.ts";
import { formatResultLines } from "../result-format.ts";
import type { AskResult, AskState } from "../types.ts";
import { isAnswerEmpty, serializeAnswer } from "./answers.ts";
export function toAskResult(state: AskState): AskResult {
const answers = Object.fromEntries(
Object.entries(state.answers)
.map(
([questionId, answer]) =>
[questionId, serializeAnswer(answer)] as const,
)
.filter(([, answer]) => !isSerializedAnswerEmpty(answer)),
);
return {
title: state.title,
cancelled: state.cancelled,
questions: state.questions.map((question) => ({
id: question.id,
label: question.label,
prompt: question.prompt,
type: question.type,
})),
answers,
};
}
export function summarizeResult(result: AskResult): string {
if (result.cancelled) {
return CANCELLED_SUMMARY;
}
const lines = formatResultLines(result, { mode: "summary" });
return lines.join("\n") || SUBMITTED_SUMMARY;
}
export function hasAnswerContent(state: AskState, questionId: string): boolean {
const answer = state.answers[questionId];
return !!answer && !isAnswerEmpty(answer);
}
function isSerializedAnswerEmpty(
answer: AskResult["answers"][string],
): boolean {
return (
answer.values.length === 0 &&
answer.labels.length === 0 &&
answer.indices.length === 0 &&
!answer.customText &&
!answer.note &&
(!answer.optionNotes || Object.keys(answer.optionNotes).length === 0)
);
}
+110
View File
@@ -0,0 +1,110 @@
import { OTHER_OPTION_LABEL, OTHER_OPTION_VALUE } from "../constants.ts";
import type {
AskDisplayOption,
AskQuestion,
AskState,
AskStateAnswer,
} from "../types.ts";
import { isAnswerAnswered } from "./answers.ts";
export function getCurrentQuestion(state: AskState): AskQuestion | undefined {
return state.questions[state.activeTabIndex];
}
export function getQuestionById(
state: AskState,
questionId: string,
): AskQuestion | undefined {
return state.questions.find((question) => question.id === questionId);
}
export function isSubmitTab(state: AskState): boolean {
return state.activeTabIndex >= state.questions.length;
}
export function getRenderableOptions(
question?: AskQuestion,
): AskDisplayOption[] {
if (!question) {
return [];
}
const options: AskDisplayOption[] = question.options.map((option) => ({
...option,
}));
if (question.type !== "preview") {
options.push({
value: OTHER_OPTION_VALUE,
label: OTHER_OPTION_LABEL,
isCustomOption: true,
});
}
return options;
}
export function getCurrentOption(
state: AskState,
): AskDisplayOption | undefined {
return getRenderableOptions(getCurrentQuestion(state))[
state.activeOptionIndex
];
}
export function getAnswer(
state: AskState,
questionId: string,
): AskStateAnswer | undefined {
return state.answers[questionId];
}
export function getQuestionNote(
state: AskState,
questionId: string,
): string | undefined {
return getAnswer(state, questionId)?.note;
}
export function getOptionNote(
state: AskState,
questionId: string,
optionValue: string,
): string | undefined {
return getAnswer(state, questionId)?.optionNotes?.[optionValue];
}
export function isQuestionAnswered(
state: AskState,
questionId: string,
): boolean {
return isAnswerAnswered(getAnswer(state, questionId));
}
export function isQuestionNoteOpen(
state: AskState,
questionId: string,
): boolean {
return (
state.view.kind === "note" &&
state.view.questionId === questionId &&
state.view.optionValue === undefined
);
}
export function isOptionNoteOpen(
state: AskState,
questionId: string,
optionValue: string,
): boolean {
return (
state.view.kind === "note" &&
state.view.questionId === questionId &&
state.view.optionValue === optionValue
);
}
export function isInputOpenForQuestion(
state: AskState,
questionId: string,
): boolean {
return state.view.kind === "input" && state.view.questionId === questionId;
}
+351
View File
@@ -0,0 +1,351 @@
import type { AskAction, AskState } from "../types.ts";
import {
emptyAnswer,
isAnswerAnswered,
isAnswerEmpty,
saveCustomText,
saveOptionNote,
saveQuestionNote,
setSingleSelection,
toggleSelection,
} from "./answers.ts";
import {
getAnswer,
getCurrentOption,
getCurrentQuestion,
getQuestionById,
getRenderableOptions,
isSubmitTab,
} from "./selectors.ts";
import {
inputView,
isEditingView,
navigateView,
optionNoteView,
questionNoteView,
submitView,
} from "./view.ts";
const SUBMIT_ACTION_COUNT = 2;
export function createInitialState(params: {
title?: string;
questions: AskState["questions"];
}): AskState {
return {
title: params.title,
questions: params.questions,
activeTabIndex: 0,
activeOptionIndex: 0,
activeSubmitActionIndex: 0,
view: navigateView(),
answers: {},
completed: false,
cancelled: false,
};
}
export function reduceAskState(state: AskState, action: AskAction): AskState {
switch (action.type) {
case "MOVE_TAB":
return moveTabBy(state, action.delta);
case "MOVE_OPTION":
return moveOptionBy(state, action.delta);
case "OPEN_INPUT":
return setView(state, inputView(action.questionId));
case "OPEN_QUESTION_NOTE":
return setView(state, questionNoteView(action.questionId));
case "OPEN_OPTION_NOTE":
return setView(
state,
optionNoteView(action.questionId, action.optionValue),
);
case "CONFIRM":
return confirmCurrentSelection(state);
case "TOGGLE_MULTI":
return toggleCurrentMultiOption(state);
case "NUMBER_SHORTCUT":
return applyNumberShortcut(state, action.digit);
case "SAVE_INPUT":
return saveInputValue(state, action.value, action.submit ?? false);
case "SAVE_NOTE":
return saveNoteValue(state, action.value);
case "CANCEL":
return cancelFlow(state);
default:
return state;
}
}
export function moveTab(state: AskState, delta: number): AskState {
return reduceAskState(state, {
type: "MOVE_TAB",
delta: delta < 0 ? -1 : 1,
});
}
export function moveOption(state: AskState, delta: number): AskState {
return reduceAskState(state, {
type: "MOVE_OPTION",
delta: delta < 0 ? -1 : 1,
});
}
export function enterInputMode(state: AskState, questionId: string): AskState {
return reduceAskState(state, { type: "OPEN_INPUT", questionId });
}
export function enterQuestionNoteMode(
state: AskState,
questionId: string,
): AskState {
return reduceAskState(state, { type: "OPEN_QUESTION_NOTE", questionId });
}
export function enterOptionNoteMode(
state: AskState,
questionId: string,
optionValue: string,
): AskState {
return reduceAskState(state, {
type: "OPEN_OPTION_NOTE",
questionId,
optionValue,
});
}
export function toggleCurrentMultiOption(state: AskState): AskState {
const question = getCurrentQuestion(state);
if (!question || question.type === "single") {
return state;
}
const option = getCurrentOption(state);
if (!option) {
return state;
}
if (option.isCustomOption) {
return setView(state, inputView(question.id));
}
return updateAnswer(state, question.id, (answer) =>
toggleSelection(answer, option, state.activeOptionIndex),
);
}
export function confirmCurrentSelection(state: AskState): AskState {
if (isSubmitTab(state)) {
return state.activeSubmitActionIndex === 1
? { ...state, cancelled: true, completed: true }
: { ...state, completed: true };
}
const question = getCurrentQuestion(state);
const option = getCurrentOption(state);
if (!question || !option) {
return state;
}
if (question.type === "multi") {
if (option.isCustomOption) {
return setView(state, inputView(question.id));
}
return advanceToNextTab(state);
}
if (option.isCustomOption) {
return setView(state, inputView(question.id));
}
const nextState = updateAnswer(state, question.id, (answer) =>
setSingleSelection(answer, option, state.activeOptionIndex),
);
return advanceToNextTab(nextState);
}
export function applyNumberShortcut(state: AskState, digit: number): AskState {
if (digit <= 0 || isSubmitTab(state)) {
return state;
}
const question = getCurrentQuestion(state);
if (!question) {
return state;
}
const index = digit - 1;
const option = getRenderableOptions(question)[index];
if (!option) {
return state;
}
const selectedState = { ...state, activeOptionIndex: index };
if (option.isCustomOption) {
return setView(selectedState, inputView(question.id));
}
if (question.type === "multi") {
return updateAnswer(selectedState, question.id, (answer) =>
toggleSelection(answer, option, index),
);
}
const nextState = updateAnswer(selectedState, question.id, (answer) =>
setSingleSelection(answer, option, index),
);
return advanceToNextTab(nextState);
}
export function saveCustomAnswer(state: AskState, rawValue: string): AskState {
return saveInputValue(state, rawValue, false);
}
export function submitCustomAnswer(
state: AskState,
rawValue: string,
): AskState {
return saveInputValue(state, rawValue, true);
}
export function saveNote(state: AskState, rawValue: string): AskState {
return saveNoteValue(state, rawValue);
}
export function cancelFlow(state: AskState): AskState {
if (isEditingView(state)) {
return exitEditingView(state);
}
return {
...state,
cancelled: true,
completed: true,
};
}
function saveInputValue(
state: AskState,
rawValue: string,
submit: boolean,
): AskState {
if (state.view.kind !== "input") {
return state;
}
const question = getQuestionById(state, state.view.questionId);
if (!question) {
return exitEditingView(state);
}
const nextState = updateAnswer(
exitEditingView(state),
question.id,
(answer) => saveCustomText(answer, rawValue),
);
if (!submit || !isAnswerAnswered(nextState.answers[question.id])) {
return nextState;
}
return advanceToNextTab(nextState);
}
function saveNoteValue(state: AskState, rawValue: string): AskState {
if (state.view.kind !== "note") {
return exitEditingView(state);
}
const { questionId, optionValue } = state.view;
const nextState = updateAnswer(
exitEditingView(state),
questionId,
(answer) =>
optionValue
? saveOptionNote(answer, optionValue, rawValue)
: saveQuestionNote(answer, rawValue),
);
return nextState;
}
function moveTabBy(state: AskState, delta: 1 | -1): AskState {
const totalTabs = state.questions.length + 1;
const activeTabIndex = (state.activeTabIndex + delta + totalTabs) % totalTabs;
return {
...state,
activeTabIndex,
activeOptionIndex: 0,
activeSubmitActionIndex: 0,
view:
activeTabIndex >= state.questions.length ? submitView() : navigateView(),
};
}
function moveOptionBy(state: AskState, delta: 1 | -1): AskState {
if (isSubmitTab(state)) {
return {
...state,
activeSubmitActionIndex: clamp(
state.activeSubmitActionIndex + delta,
0,
SUBMIT_ACTION_COUNT - 1,
),
};
}
const options = getRenderableOptions(getCurrentQuestion(state));
return {
...state,
activeOptionIndex: clamp(
state.activeOptionIndex + delta,
0,
Math.max(0, options.length - 1),
),
};
}
function setView(state: AskState, view: AskState["view"]): AskState {
return {
...state,
view,
};
}
function exitEditingView(state: AskState): AskState {
return {
...state,
view: isSubmitTab(state) ? submitView() : navigateView(),
};
}
function advanceToNextTab(state: AskState): AskState {
const nextTab = Math.min(state.activeTabIndex + 1, state.questions.length);
return {
...state,
activeTabIndex: nextTab,
activeOptionIndex: 0,
activeSubmitActionIndex: 0,
view: nextTab === state.questions.length ? submitView() : navigateView(),
};
}
function updateAnswer(
state: AskState,
questionId: string,
mutate: (
answer: ReturnType<typeof emptyAnswer>,
) => ReturnType<typeof emptyAnswer>,
): AskState {
const existing = getAnswer(state, questionId) ?? emptyAnswer();
const nextAnswer = mutate(existing);
const answers = { ...state.answers };
if (isAnswerEmpty(nextAnswer)) {
delete answers[questionId];
} else {
answers[questionId] = nextAnswer;
}
return {
...state,
answers,
};
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
+28
View File
@@ -0,0 +1,28 @@
import type { AskState, ViewState } from "../types.ts";
export function navigateView(): ViewState {
return { kind: "navigate" };
}
export function submitView(): ViewState {
return { kind: "submit" };
}
export function inputView(questionId: string): ViewState {
return { kind: "input", questionId };
}
export function questionNoteView(questionId: string): ViewState {
return { kind: "note", questionId };
}
export function optionNoteView(
questionId: string,
optionValue: string,
): ViewState {
return { kind: "note", questionId, optionValue };
}
export function isEditingView(state: AskState): boolean {
return state.view.kind === "input" || state.view.kind === "note";
}
+26 -32
View File
@@ -2,43 +2,37 @@ 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),
const lines = text
.split("\n")
.flatMap((paragraph) =>
wrapParagraphWithIndentation(paragraph, effectiveWidth),
);
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 wrapParagraphWithIndentation(
paragraph: string,
width: number,
): string[] {
if (paragraph.length === 0) {
return [""];
}
const leadingWhitespace = paragraph.match(/^\s*/)?.[0] ?? "";
const body = paragraph.slice(leadingWhitespace.length).trim();
if (!body) {
return [leadingWhitespace];
}
const wrapped = wrapParagraph(body, width - visibleWidth(leadingWhitespace));
if (wrapped.length === 0) {
return [leadingWhitespace];
}
return wrapped.map((part) => `${leadingWhitespace}${part}`);
}
function wrapParagraph(text: string, width: number): string[] {
const effectiveWidth = Math.max(1, width);
const words = text.split(/\s+/).filter(Boolean);
+40 -13
View File
@@ -28,7 +28,20 @@ export interface AskQuestion
required: boolean;
}
export interface AskAnswer {
export interface AskSelectedOption {
value: string;
label: string;
index: number;
}
export interface AskStateAnswer {
selected: AskSelectedOption[];
customText?: string;
note?: string;
optionNotes?: Record<string, string>;
}
export interface AskResultAnswer {
values: string[];
labels: string[];
indices: number[];
@@ -46,26 +59,40 @@ export interface AskResult {
prompt: string;
type: AskQuestionType;
}>;
answers: Record<string, AskAnswer>;
answers: Record<string, AskResultAnswer>;
}
export type ViewMode = "navigate" | "input" | "note" | "submit";
export type ViewState =
| { kind: "navigate" }
| { kind: "submit" }
| { kind: "input"; questionId: string }
| { kind: "note"; questionId: string; optionValue?: string };
export interface AskState {
title?: string;
questions: AskQuestion[];
currentTab: number;
optionIndex: number;
submitIndex: number;
mode: ViewMode;
inputQuestionId: string | null;
noteQuestionId: string | null;
noteOptionValue: string | null;
answers: Record<string, AskAnswer>;
activeTabIndex: number;
activeOptionIndex: number;
activeSubmitActionIndex: number;
view: ViewState;
answers: Record<string, AskStateAnswer>;
completed: boolean;
cancelled: boolean;
}
export interface RenderOption extends AskOption {
isOther?: boolean;
export interface AskDisplayOption extends AskOption {
isCustomOption?: boolean;
}
export type AskAction =
| { type: "MOVE_TAB"; delta: 1 | -1 }
| { type: "MOVE_OPTION"; delta: 1 | -1 }
| { type: "OPEN_INPUT"; questionId: string }
| { type: "OPEN_QUESTION_NOTE"; questionId: string }
| { type: "OPEN_OPTION_NOTE"; questionId: string; optionValue: string }
| { type: "CONFIRM" }
| { type: "TOGGLE_MULTI" }
| { type: "NUMBER_SHORTCUT"; digit: number }
| { type: "SAVE_INPUT"; value: string; submit?: boolean }
| { type: "SAVE_NOTE"; value: string }
| { type: "CANCEL" };
+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;
}
+49 -34
View File
@@ -7,11 +7,11 @@ import {
createInitialState,
enterOptionNoteMode,
enterQuestionNoteMode,
saveCustomAnswer,
saveNote,
getRenderableOptions,
moveOption,
moveTab,
saveCustomAnswer,
saveNote,
submitCustomAnswer,
toAskResult,
toggleCurrentMultiOption,
@@ -63,7 +63,10 @@ test("normalize defaults via initial state", () => {
getRenderableOptions(state.questions[0]).at(-1)?.label,
"Type your own",
);
assert.equal(getRenderableOptions(state.questions[0]).at(-1)?.isOther, true);
assert.equal(
getRenderableOptions(state.questions[0]).at(-1)?.isCustomOption,
true,
);
});
test("preview questions keep their type and option previews", () => {
@@ -82,7 +85,7 @@ test("preview questions keep their type and option previews", () => {
assert.equal(state.questions[0].type, "preview");
assert.equal(state.questions[0].options[0].preview, "Example");
assert.equal(
getRenderableOptions(state.questions[0]).at(-1)?.isOther,
getRenderableOptions(state.questions[0]).at(-1)?.isCustomOption,
undefined,
);
assert.equal(getRenderableOptions(state.questions[0]).length, 1);
@@ -92,31 +95,37 @@ test("single-select number shortcut stores answer and advances to next tab", ()
let state = createInitialState(sampleParams());
state = applyNumberShortcut(state, 2);
assert.equal(state.answers.lang.labels[0], "TypeScript");
assert.equal(state.currentTab, 1);
assert.equal(state.mode, "navigate");
assert.equal(state.answers.lang.selected[0].label, "TypeScript");
assert.equal(state.activeTabIndex, 1);
assert.equal(state.view.kind, "navigate");
});
test("multi-select space toggles current option and enter advances to submit", () => {
let state = createInitialState(sampleParams());
state = applyNumberShortcut(state, 1);
state = toggleCurrentMultiOption(state);
assert.deepEqual(state.answers.fe.labels, ["React"]);
assert.deepEqual(
state.answers.fe.selected.map((selection) => selection.label),
["React"],
);
state = moveOption(state, 1);
state = toggleCurrentMultiOption(state);
assert.deepEqual(state.answers.fe.labels, ["React", "Vue"]);
assert.deepEqual(
state.answers.fe.selected.map((selection) => selection.label),
["React", "Vue"],
);
state = confirmCurrentSelection(state);
assert.equal(state.mode, "submit");
assert.equal(state.currentTab, 2);
assert.equal(state.view.kind, "submit");
assert.equal(state.activeTabIndex, 2);
assert.equal(state.completed, false);
});
test("submit can complete even when some questions are unanswered", () => {
let state = createInitialState(sampleParams());
state = moveTab(state, -1);
assert.equal(state.mode, "submit");
assert.equal(state.view.kind, "submit");
state = confirmCurrentSelection(state);
assert.equal(state.completed, true);
@@ -135,11 +144,11 @@ test("selecting other enters input mode and custom submit stores typed answer",
});
state = applyNumberShortcut(state, 2);
assert.equal(state.mode, "input");
assert.equal(state.view.kind, "input");
state = submitCustomAnswer(state, "my custom answer");
assert.equal(state.answers.q1.customText, "my custom answer");
assert.equal(state.mode, "submit");
assert.equal(state.view.kind, "submit");
});
test("escape saves typed custom answer without advancing", () => {
@@ -154,11 +163,11 @@ test("escape saves typed custom answer without advancing", () => {
});
state = applyNumberShortcut(state, 2);
assert.equal(state.mode, "input");
assert.equal(state.view.kind, "input");
state = saveCustomAnswer(state, "my draft answer");
assert.equal(state.mode, "navigate");
assert.equal(state.currentTab, 0);
assert.equal(state.view.kind, "navigate");
assert.equal(state.activeTabIndex, 0);
assert.equal(state.answers.q1.customText, "my draft answer");
});
@@ -182,7 +191,7 @@ test("empty custom draft clears the stored answer but preserves notes", () => {
assert.equal(state.answers.q1.customText, undefined);
assert.equal(state.answers.q1.note, "question note");
assert.equal(state.mode, "navigate");
assert.equal(state.view.kind, "navigate");
});
test("question note can be saved without selecting an answer and is submitted", () => {
@@ -193,10 +202,10 @@ test("question note can be saved without selecting an answer and is submitted",
});
state = enterQuestionNoteMode(state, "q1");
assert.equal(state.mode, "note");
assert.equal(state.view.kind, "note");
state = saveNote(state, "needs examples");
assert.equal(state.mode, "navigate");
assert.equal(state.view.kind, "navigate");
assert.equal(state.answers.q1.note, "needs examples");
assert.deepEqual(toAskResult(state).answers.q1, {
values: [],
@@ -229,10 +238,16 @@ test("option notes can exist before selection but only selected option notes are
assert.equal(toAskResult(state).answers.q1, undefined);
state = toggleCurrentMultiOption(state);
assert.deepEqual(state.answers.q1.values, ["react"]);
assert.deepEqual(
state.answers.q1.selected.map((selection) => selection.value),
["react"],
);
state = moveOption(state, 1);
state = toggleCurrentMultiOption(state);
assert.deepEqual(state.answers.q1.values, ["react", "vue"]);
assert.deepEqual(
state.answers.q1.selected.map((selection) => selection.value),
["react", "vue"],
);
assert.deepEqual(toAskResult(state).answers.q1.optionNotes, {
vue: "maybe later",
});
@@ -309,12 +324,12 @@ test("preview questions can store selected option notes for submission", () => {
test("tab navigation wraps including submit tab", () => {
let state = createInitialState(sampleParams());
state = moveTab(state, -1);
assert.equal(state.currentTab, 2);
assert.equal(state.mode, "submit");
assert.equal(state.activeTabIndex, 2);
assert.equal(state.view.kind, "submit");
state = moveTab(state, 1);
assert.equal(state.currentTab, 0);
assert.equal(state.mode, "navigate");
assert.equal(state.activeTabIndex, 0);
assert.equal(state.view.kind, "navigate");
});
test("tab navigation works with a single question", () => {
@@ -325,12 +340,12 @@ test("tab navigation works with a single question", () => {
});
state = moveTab(state, -1);
assert.equal(state.currentTab, 1);
assert.equal(state.mode, "submit");
assert.equal(state.activeTabIndex, 1);
assert.equal(state.view.kind, "submit");
state = moveTab(state, 1);
assert.equal(state.currentTab, 0);
assert.equal(state.mode, "navigate");
assert.equal(state.activeTabIndex, 0);
assert.equal(state.view.kind, "navigate");
});
test("escape cancels from main flow but only exits input and note modes when editing", () => {
@@ -345,14 +360,14 @@ test("escape cancels from main flow but only exits input and note modes when edi
],
});
state = applyNumberShortcut(state, 2);
assert.equal(state.mode, "input");
assert.equal(state.view.kind, "input");
state = cancelFlow(state);
assert.equal(state.mode, "navigate");
assert.equal(state.view.kind, "navigate");
assert.equal(state.cancelled, false);
state = enterQuestionNoteMode(state, "q");
assert.equal(state.mode, "note");
assert.equal(state.view.kind, "note");
state = cancelFlow(state);
assert.equal(state.mode, "navigate");
assert.equal(state.view.kind, "navigate");
assert.equal(state.cancelled, false);
});