refactor(ask): reorganize ask flow internals
This commit is contained in:
+56
-1
@@ -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
@@ -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
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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";
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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" };
|
||||
|
||||
@@ -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;
|
||||
@@ -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 "";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"),
|
||||
);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user