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