refactor(ask): reorganize ask flow internals
This commit is contained in:
+56
-1
@@ -15,7 +15,40 @@
|
|||||||
"linter": {
|
"linter": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"rules": {
|
"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": {
|
"javascript": {
|
||||||
@@ -23,6 +56,28 @@
|
|||||||
"quoteStyle": "double"
|
"quoteStyle": "double"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"includes": ["tests/**/*.ts"],
|
||||||
|
"linter": {
|
||||||
|
"rules": {
|
||||||
|
"complexity": {
|
||||||
|
"noExcessiveLinesPerFunction": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"includes": ["src/index.ts"],
|
||||||
|
"linter": {
|
||||||
|
"rules": {
|
||||||
|
"style": {
|
||||||
|
"noDefaultExport": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
"assist": {
|
"assist": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"actions": {
|
"actions": {
|
||||||
|
|||||||
+1
-995
@@ -1,995 +1 @@
|
|||||||
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
export { runAskFlow } from "./ui/controller.ts";
|
||||||
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"));
|
|
||||||
}
|
|
||||||
|
|||||||
+5
-1
@@ -5,6 +5,7 @@ import { renderResultText } from "./result.ts";
|
|||||||
import { AskParamsSchema } from "./schema.ts";
|
import { AskParamsSchema } from "./schema.ts";
|
||||||
import { createInitialState, summarizeResult, toAskResult } from "./state.ts";
|
import { createInitialState, summarizeResult, toAskResult } from "./state.ts";
|
||||||
import type { AskParams, AskQuestionInput, AskResult } from "./types.ts";
|
import type { AskParams, AskQuestionInput, AskResult } from "./types.ts";
|
||||||
|
import { UI_DIMENSIONS } from "./ui/constants.ts";
|
||||||
|
|
||||||
export function registerAskTool(pi: ExtensionAPI) {
|
export function registerAskTool(pi: ExtensionAPI) {
|
||||||
pi.registerTool({
|
pi.registerTool({
|
||||||
@@ -63,7 +64,10 @@ export function registerAskTool(pi: ExtensionAPI) {
|
|||||||
let text = theme.fg("toolTitle", theme.bold("ask_user "));
|
let text = theme.fg("toolTitle", theme.bold("ask_user "));
|
||||||
text += theme.fg("muted", `${params.questions?.length ?? 0} question(s)`);
|
text += theme.fg("muted", `${params.questions?.length ?? 0} question(s)`);
|
||||||
if (labels) {
|
if (labels) {
|
||||||
text += theme.fg("dim", ` (${truncateToWidth(labels, 50)})`);
|
text += theme.fg(
|
||||||
|
"dim",
|
||||||
|
` (${truncateToWidth(labels, UI_DIMENSIONS.callLabelTruncateWidth)})`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return new Text(text, 0, 0);
|
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";
|
import type { AskResult } from "./types.ts";
|
||||||
|
|
||||||
export function renderResultText(result: AskResult): string {
|
export function renderResultText(result: AskResult): string {
|
||||||
if (result.cancelled) {
|
if (result.cancelled) {
|
||||||
return "Cancelled";
|
return CANCELLED_RESULT_TEXT;
|
||||||
}
|
}
|
||||||
|
|
||||||
const lines: string[] = [];
|
const lines = formatResultLines(result, { mode: "render" });
|
||||||
for (const question of result.questions) {
|
return lines.join("\n") || SUBMITTED_RESULT_TEXT;
|
||||||
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";
|
|
||||||
}
|
}
|
||||||
|
|||||||
+46
-541
@@ -1,547 +1,52 @@
|
|||||||
import type {
|
import { normalizeQuestions } from "./state/normalize.ts";
|
||||||
AskAnswer,
|
import { createInitialState as createBaseState } from "./state/transitions.ts";
|
||||||
AskParams,
|
|
||||||
AskQuestion,
|
|
||||||
AskQuestionInput,
|
|
||||||
AskResult,
|
|
||||||
AskState,
|
|
||||||
RenderOption,
|
|
||||||
} from "./types.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[] {
|
export function createInitialState(
|
||||||
return params.questions.map((question, index) =>
|
params: Parameters<typeof normalizeQuestions>[0],
|
||||||
normalizeQuestion(question, index),
|
) {
|
||||||
);
|
return createBaseState({
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
|
||||||
title: params.title,
|
title: params.title,
|
||||||
questions: normalizeQuestions(params),
|
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[] {
|
export function wrapText(text: string, width: number): string[] {
|
||||||
const effectiveWidth = Math.max(1, width);
|
const effectiveWidth = Math.max(1, width);
|
||||||
const lines: string[] = [];
|
const lines = text
|
||||||
const paragraphs = text.split("\n");
|
.split("\n")
|
||||||
|
.flatMap((paragraph) =>
|
||||||
for (const paragraph of paragraphs) {
|
wrapParagraphWithIndentation(paragraph, effectiveWidth),
|
||||||
if (paragraph.length === 0) {
|
|
||||||
lines.push("");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const leadingWhitespace = paragraph.match(/^\s*/)?.[0] ?? "";
|
|
||||||
const body = paragraph.slice(leadingWhitespace.length).trim();
|
|
||||||
if (!body) {
|
|
||||||
lines.push(leadingWhitespace);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const wrapped = wrapParagraph(
|
|
||||||
body,
|
|
||||||
effectiveWidth - visibleWidth(leadingWhitespace),
|
|
||||||
);
|
);
|
||||||
if (wrapped.length === 0) {
|
|
||||||
lines.push(leadingWhitespace);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [index, part] of wrapped.entries()) {
|
|
||||||
lines.push(
|
|
||||||
index === 0
|
|
||||||
? `${leadingWhitespace}${part}`
|
|
||||||
: `${leadingWhitespace}${part}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return lines.length > 0 ? lines : [""];
|
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[] {
|
function wrapParagraph(text: string, width: number): string[] {
|
||||||
const effectiveWidth = Math.max(1, width);
|
const effectiveWidth = Math.max(1, width);
|
||||||
const words = text.split(/\s+/).filter(Boolean);
|
const words = text.split(/\s+/).filter(Boolean);
|
||||||
|
|||||||
+40
-13
@@ -28,7 +28,20 @@ export interface AskQuestion
|
|||||||
required: boolean;
|
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[];
|
values: string[];
|
||||||
labels: string[];
|
labels: string[];
|
||||||
indices: number[];
|
indices: number[];
|
||||||
@@ -46,26 +59,40 @@ export interface AskResult {
|
|||||||
prompt: string;
|
prompt: string;
|
||||||
type: AskQuestionType;
|
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 {
|
export interface AskState {
|
||||||
title?: string;
|
title?: string;
|
||||||
questions: AskQuestion[];
|
questions: AskQuestion[];
|
||||||
currentTab: number;
|
activeTabIndex: number;
|
||||||
optionIndex: number;
|
activeOptionIndex: number;
|
||||||
submitIndex: number;
|
activeSubmitActionIndex: number;
|
||||||
mode: ViewMode;
|
view: ViewState;
|
||||||
inputQuestionId: string | null;
|
answers: Record<string, AskStateAnswer>;
|
||||||
noteQuestionId: string | null;
|
|
||||||
noteOptionValue: string | null;
|
|
||||||
answers: Record<string, AskAnswer>;
|
|
||||||
completed: boolean;
|
completed: boolean;
|
||||||
cancelled: boolean;
|
cancelled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RenderOption extends AskOption {
|
export interface AskDisplayOption extends AskOption {
|
||||||
isOther?: boolean;
|
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,
|
createInitialState,
|
||||||
enterOptionNoteMode,
|
enterOptionNoteMode,
|
||||||
enterQuestionNoteMode,
|
enterQuestionNoteMode,
|
||||||
saveCustomAnswer,
|
|
||||||
saveNote,
|
|
||||||
getRenderableOptions,
|
getRenderableOptions,
|
||||||
moveOption,
|
moveOption,
|
||||||
moveTab,
|
moveTab,
|
||||||
|
saveCustomAnswer,
|
||||||
|
saveNote,
|
||||||
submitCustomAnswer,
|
submitCustomAnswer,
|
||||||
toAskResult,
|
toAskResult,
|
||||||
toggleCurrentMultiOption,
|
toggleCurrentMultiOption,
|
||||||
@@ -63,7 +63,10 @@ test("normalize defaults via initial state", () => {
|
|||||||
getRenderableOptions(state.questions[0]).at(-1)?.label,
|
getRenderableOptions(state.questions[0]).at(-1)?.label,
|
||||||
"Type your own",
|
"Type your own",
|
||||||
);
|
);
|
||||||
assert.equal(getRenderableOptions(state.questions[0]).at(-1)?.isOther, true);
|
assert.equal(
|
||||||
|
getRenderableOptions(state.questions[0]).at(-1)?.isCustomOption,
|
||||||
|
true,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("preview questions keep their type and option previews", () => {
|
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].type, "preview");
|
||||||
assert.equal(state.questions[0].options[0].preview, "Example");
|
assert.equal(state.questions[0].options[0].preview, "Example");
|
||||||
assert.equal(
|
assert.equal(
|
||||||
getRenderableOptions(state.questions[0]).at(-1)?.isOther,
|
getRenderableOptions(state.questions[0]).at(-1)?.isCustomOption,
|
||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
assert.equal(getRenderableOptions(state.questions[0]).length, 1);
|
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());
|
let state = createInitialState(sampleParams());
|
||||||
state = applyNumberShortcut(state, 2);
|
state = applyNumberShortcut(state, 2);
|
||||||
|
|
||||||
assert.equal(state.answers.lang.labels[0], "TypeScript");
|
assert.equal(state.answers.lang.selected[0].label, "TypeScript");
|
||||||
assert.equal(state.currentTab, 1);
|
assert.equal(state.activeTabIndex, 1);
|
||||||
assert.equal(state.mode, "navigate");
|
assert.equal(state.view.kind, "navigate");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("multi-select space toggles current option and enter advances to submit", () => {
|
test("multi-select space toggles current option and enter advances to submit", () => {
|
||||||
let state = createInitialState(sampleParams());
|
let state = createInitialState(sampleParams());
|
||||||
state = applyNumberShortcut(state, 1);
|
state = applyNumberShortcut(state, 1);
|
||||||
state = toggleCurrentMultiOption(state);
|
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 = moveOption(state, 1);
|
||||||
state = toggleCurrentMultiOption(state);
|
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);
|
state = confirmCurrentSelection(state);
|
||||||
assert.equal(state.mode, "submit");
|
assert.equal(state.view.kind, "submit");
|
||||||
assert.equal(state.currentTab, 2);
|
assert.equal(state.activeTabIndex, 2);
|
||||||
assert.equal(state.completed, false);
|
assert.equal(state.completed, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("submit can complete even when some questions are unanswered", () => {
|
test("submit can complete even when some questions are unanswered", () => {
|
||||||
let state = createInitialState(sampleParams());
|
let state = createInitialState(sampleParams());
|
||||||
state = moveTab(state, -1);
|
state = moveTab(state, -1);
|
||||||
assert.equal(state.mode, "submit");
|
assert.equal(state.view.kind, "submit");
|
||||||
state = confirmCurrentSelection(state);
|
state = confirmCurrentSelection(state);
|
||||||
|
|
||||||
assert.equal(state.completed, true);
|
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);
|
state = applyNumberShortcut(state, 2);
|
||||||
assert.equal(state.mode, "input");
|
assert.equal(state.view.kind, "input");
|
||||||
state = submitCustomAnswer(state, "my custom answer");
|
state = submitCustomAnswer(state, "my custom answer");
|
||||||
|
|
||||||
assert.equal(state.answers.q1.customText, "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", () => {
|
test("escape saves typed custom answer without advancing", () => {
|
||||||
@@ -154,11 +163,11 @@ test("escape saves typed custom answer without advancing", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
state = applyNumberShortcut(state, 2);
|
state = applyNumberShortcut(state, 2);
|
||||||
assert.equal(state.mode, "input");
|
assert.equal(state.view.kind, "input");
|
||||||
state = saveCustomAnswer(state, "my draft answer");
|
state = saveCustomAnswer(state, "my draft answer");
|
||||||
|
|
||||||
assert.equal(state.mode, "navigate");
|
assert.equal(state.view.kind, "navigate");
|
||||||
assert.equal(state.currentTab, 0);
|
assert.equal(state.activeTabIndex, 0);
|
||||||
assert.equal(state.answers.q1.customText, "my draft answer");
|
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.customText, undefined);
|
||||||
assert.equal(state.answers.q1.note, "question note");
|
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", () => {
|
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");
|
state = enterQuestionNoteMode(state, "q1");
|
||||||
assert.equal(state.mode, "note");
|
assert.equal(state.view.kind, "note");
|
||||||
state = saveNote(state, "needs examples");
|
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.equal(state.answers.q1.note, "needs examples");
|
||||||
assert.deepEqual(toAskResult(state).answers.q1, {
|
assert.deepEqual(toAskResult(state).answers.q1, {
|
||||||
values: [],
|
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);
|
assert.equal(toAskResult(state).answers.q1, undefined);
|
||||||
|
|
||||||
state = toggleCurrentMultiOption(state);
|
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 = moveOption(state, 1);
|
||||||
state = toggleCurrentMultiOption(state);
|
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, {
|
assert.deepEqual(toAskResult(state).answers.q1.optionNotes, {
|
||||||
vue: "maybe later",
|
vue: "maybe later",
|
||||||
});
|
});
|
||||||
@@ -309,12 +324,12 @@ test("preview questions can store selected option notes for submission", () => {
|
|||||||
test("tab navigation wraps including submit tab", () => {
|
test("tab navigation wraps including submit tab", () => {
|
||||||
let state = createInitialState(sampleParams());
|
let state = createInitialState(sampleParams());
|
||||||
state = moveTab(state, -1);
|
state = moveTab(state, -1);
|
||||||
assert.equal(state.currentTab, 2);
|
assert.equal(state.activeTabIndex, 2);
|
||||||
assert.equal(state.mode, "submit");
|
assert.equal(state.view.kind, "submit");
|
||||||
|
|
||||||
state = moveTab(state, 1);
|
state = moveTab(state, 1);
|
||||||
assert.equal(state.currentTab, 0);
|
assert.equal(state.activeTabIndex, 0);
|
||||||
assert.equal(state.mode, "navigate");
|
assert.equal(state.view.kind, "navigate");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("tab navigation works with a single question", () => {
|
test("tab navigation works with a single question", () => {
|
||||||
@@ -325,12 +340,12 @@ test("tab navigation works with a single question", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
state = moveTab(state, -1);
|
state = moveTab(state, -1);
|
||||||
assert.equal(state.currentTab, 1);
|
assert.equal(state.activeTabIndex, 1);
|
||||||
assert.equal(state.mode, "submit");
|
assert.equal(state.view.kind, "submit");
|
||||||
|
|
||||||
state = moveTab(state, 1);
|
state = moveTab(state, 1);
|
||||||
assert.equal(state.currentTab, 0);
|
assert.equal(state.activeTabIndex, 0);
|
||||||
assert.equal(state.mode, "navigate");
|
assert.equal(state.view.kind, "navigate");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("escape cancels from main flow but only exits input and note modes when editing", () => {
|
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);
|
state = applyNumberShortcut(state, 2);
|
||||||
assert.equal(state.mode, "input");
|
assert.equal(state.view.kind, "input");
|
||||||
state = cancelFlow(state);
|
state = cancelFlow(state);
|
||||||
assert.equal(state.mode, "navigate");
|
assert.equal(state.view.kind, "navigate");
|
||||||
assert.equal(state.cancelled, false);
|
assert.equal(state.cancelled, false);
|
||||||
|
|
||||||
state = enterQuestionNoteMode(state, "q");
|
state = enterQuestionNoteMode(state, "q");
|
||||||
assert.equal(state.mode, "note");
|
assert.equal(state.view.kind, "note");
|
||||||
state = cancelFlow(state);
|
state = cancelFlow(state);
|
||||||
assert.equal(state.mode, "navigate");
|
assert.equal(state.view.kind, "navigate");
|
||||||
assert.equal(state.cancelled, false);
|
assert.equal(state.cancelled, false);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user