chore(ask): reorganize docs and fix repo checks

This commit is contained in:
Erik Kotsiuba
2026-04-18 21:31:39 +02:00
parent 3ec7af2fd8
commit 884c5fcf0d
38 changed files with 704 additions and 519 deletions
-1
View File
@@ -1 +0,0 @@
export { runAskFlow } from "./ui/controller.ts";
+5 -4
View File
@@ -1,11 +1,12 @@
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { Text, truncateToWidth } from "@mariozechner/pi-tui";
import { runAskFlow } from "./ask-component.ts";
import { renderResultText } from "./result.ts";
import { AskParamsSchema } from "./schema.ts";
import { createInitialState, summarizeResult, toAskResult } from "./state.ts";
import { createInitialState } from "./state/create.ts";
import { summarizeResult, toAskResult } from "./state/result.ts";
import type { AskParams, AskQuestionInput, AskResult } from "./types.ts";
import { UI_DIMENSIONS } from "./ui/constants.ts";
import { runAskFlow } from "./ui/controller.ts";
export function registerAskTool(pi: ExtensionAPI) {
pi.registerTool({
@@ -57,7 +58,7 @@ export function registerAskTool(pi: ExtensionAPI) {
? params.questions
.map(
(question: AskQuestionInput, index) =>
question.label || `Q${index + 1}`,
question.label || `Q${index + 1}`
)
.join(", ")
: "";
@@ -66,7 +67,7 @@ export function registerAskTool(pi: ExtensionAPI) {
if (labels) {
text += theme.fg(
"dim",
` (${truncateToWidth(labels, UI_DIMENSIONS.callLabelTruncateWidth)})`,
` (${truncateToWidth(labels, UI_DIMENSIONS.callLabelTruncateWidth)})`
);
}
return new Text(text, 0, 0);
+8 -8
View File
@@ -2,7 +2,7 @@ import type { AskResult } from "./types.ts";
export function formatResultLines(
result: AskResult,
options: { mode: "summary" | "render" },
options: { mode: "summary" | "render" }
): string[] {
const lines: string[] = [];
@@ -20,7 +20,7 @@ export function formatResultLines(
const questionNoteLine = formatQuestionNoteLine(
question.label,
answer.note,
options.mode,
options.mode
);
if (questionNoteLine) {
lines.push(questionNoteLine);
@@ -35,11 +35,11 @@ export function formatResultLines(
function formatAnswerLine(
questionLabel: string,
answer: AskResult["answers"][string],
mode: "summary" | "render",
mode: "summary" | "render"
): string | undefined {
const answerText = answer.customText ?? answer.labels.join(", ");
if (!answerText) {
return undefined;
return;
}
if (mode === "summary") {
return `${questionLabel}: ${answerText}`;
@@ -53,10 +53,10 @@ function formatAnswerLine(
function formatQuestionNoteLine(
questionLabel: string,
note: string | undefined,
mode: "summary" | "render",
mode: "summary" | "render"
): string | undefined {
if (!note) {
return undefined;
return;
}
return mode === "summary"
? `${questionLabel} note: ${note}`
@@ -66,7 +66,7 @@ function formatQuestionNoteLine(
function formatOptionNoteLines(
questionLabel: string,
answer: AskResult["answers"][string],
mode: "summary" | "render",
mode: "summary" | "render"
): string[] {
const lines: string[] = [];
for (let index = 0; index < answer.values.length; index++) {
@@ -79,7 +79,7 @@ function formatOptionNoteLines(
lines.push(
mode === "summary"
? `${questionLabel} / ${label} note: ${note}`
: ` ${label} note: ${note}`,
: ` ${label} note: ${note}`
);
}
return lines;
+7 -7
View File
@@ -6,32 +6,32 @@ export const AskOptionSchema = Type.Object({
}),
label: Type.String({ description: "Short option title shown in the list" }),
description: Type.Optional(
Type.String({ description: "Optional subtitle/description" }),
Type.String({ description: "Optional subtitle/description" })
),
preview: Type.Optional(
Type.String({
description:
"Optional preview text shown in the dedicated preview pane for preview questions",
}),
})
),
});
export const AskQuestionSchema = Type.Object({
id: Type.String({ description: "Unique question identifier" }),
label: Type.Optional(
Type.String({ description: "Short tab label, e.g. Scope or Runtime" }),
Type.String({ description: "Short tab label, e.g. Scope or Runtime" })
),
prompt: Type.String({ description: "Full question prompt" }),
type: Type.Optional(
Type.Union(
[Type.Literal("single"), Type.Literal("multi"), Type.Literal("preview")],
{ description: "Question type" },
),
{ description: "Question type" }
)
),
required: Type.Optional(
Type.Boolean({
description: "Optional metadata flag; submit is never blocked",
}),
})
),
options: Type.Array(AskOptionSchema, {
description: "Available answer options",
@@ -40,7 +40,7 @@ export const AskQuestionSchema = Type.Object({
export const AskParamsSchema = Type.Object({
title: Type.Optional(
Type.String({ description: "Optional title shown above the interview" }),
Type.String({ description: "Optional title shown above the interview" })
),
questions: Type.Array(AskQuestionSchema, {
description: "Questions to ask the user",
-52
View File
@@ -1,52 +0,0 @@
import { normalizeQuestions } from "./state/normalize.ts";
import { createInitialState as createBaseState } from "./state/transitions.ts";
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 createInitialState(
params: Parameters<typeof normalizeQuestions>[0],
) {
return createBaseState({
title: params.title,
questions: normalizeQuestions(params),
});
}
+12 -12
View File
@@ -32,12 +32,12 @@ export function cloneResultAnswer(answer: AskResultAnswer): AskResultAnswer {
export function toggleSelection(
answer: AskStateAnswer,
option: AskDisplayOption,
index: number,
index: number
): AskStateAnswer {
const next = cloneAnswer(answer);
delete next.customText;
next.customText = undefined;
const selectedIndex = next.selected.findIndex(
(selection) => selection.value === option.value,
(selection) => selection.value === option.value
);
if (selectedIndex >= 0) {
@@ -56,7 +56,7 @@ export function toggleSelection(
export function setSingleSelection(
answer: AskStateAnswer,
option: AskDisplayOption,
index: number,
index: number
): AskStateAnswer {
return {
...emptyAnswer(),
@@ -74,13 +74,13 @@ export function setSingleSelection(
export function saveCustomText(
answer: AskStateAnswer,
rawValue: string,
rawValue: string
): AskStateAnswer {
const trimmed = rawValue.trim();
const next = cloneAnswer(answer);
next.selected = [];
if (!trimmed) {
delete next.customText;
next.customText = undefined;
return next;
}
next.customText = rawValue;
@@ -89,21 +89,21 @@ export function saveCustomText(
export function saveQuestionNote(
answer: AskStateAnswer,
rawValue: string,
rawValue: string
): AskStateAnswer {
const next = cloneAnswer(answer);
if (rawValue.trim()) {
next.note = rawValue;
return next;
}
delete next.note;
next.note = undefined;
return next;
}
export function saveOptionNote(
answer: AskStateAnswer,
optionValue: string,
rawValue: string,
rawValue: string
): AskStateAnswer {
const next = cloneAnswer(answer);
const optionNotes = { ...(next.optionNotes ?? {}) };
@@ -135,10 +135,10 @@ export function isAnswerAnswered(answer?: AskStateAnswer): boolean {
export function isOptionSelected(
answer: AskStateAnswer | undefined,
optionValue: string,
optionValue: string
): boolean {
return !!answer?.selected.some(
(selection) => selection.value === optionValue,
(selection) => selection.value === optionValue
);
}
@@ -157,7 +157,7 @@ export function serializeAnswer(answer: AskStateAnswer): AskResultAnswer {
selection.value,
answer.optionNotes?.[selection.value],
])
.filter((entry): entry is [string, string] => !!entry[1]),
.filter((entry): entry is [string, string] => !!entry[1])
)
: undefined;
+10
View File
@@ -0,0 +1,10 @@
import type { AskParams } from "../types.ts";
import { normalizeQuestions } from "./normalize.ts";
import { createInitialState as createBaseState } from "./transitions.ts";
export function createInitialState(params: AskParams) {
return createBaseState({
title: params.title,
questions: normalizeQuestions(params),
});
}
+2 -2
View File
@@ -2,13 +2,13 @@ import type { AskParams, AskQuestion, AskQuestionInput } from "../types.ts";
export function normalizeQuestions(params: AskParams): AskQuestion[] {
return params.questions.map((question, index) =>
normalizeQuestion(question, index),
normalizeQuestion(question, index)
);
}
function normalizeQuestion(
question: AskQuestionInput,
index: number,
index: number
): AskQuestion {
return {
...question,
+3 -4
View File
@@ -7,10 +7,9 @@ export function toAskResult(state: AskState): AskResult {
const answers = Object.fromEntries(
Object.entries(state.answers)
.map(
([questionId, answer]) =>
[questionId, serializeAnswer(answer)] as const,
([questionId, answer]) => [questionId, serializeAnswer(answer)] as const
)
.filter(([, answer]) => !isSerializedAnswerEmpty(answer)),
.filter(([, answer]) => !isSerializedAnswerEmpty(answer))
);
return {
@@ -41,7 +40,7 @@ export function hasAnswerContent(state: AskState, questionId: string): boolean {
}
function isSerializedAnswerEmpty(
answer: AskResult["answers"][string],
answer: AskResult["answers"][string]
): boolean {
return (
answer.values.length === 0 &&
+10 -10
View File
@@ -13,7 +13,7 @@ export function getCurrentQuestion(state: AskState): AskQuestion | undefined {
export function getQuestionById(
state: AskState,
questionId: string,
questionId: string
): AskQuestion | undefined {
return state.questions.find((question) => question.id === questionId);
}
@@ -23,7 +23,7 @@ export function isSubmitTab(state: AskState): boolean {
}
export function getRenderableOptions(
question?: AskQuestion,
question?: AskQuestion
): AskDisplayOption[] {
if (!question) {
return [];
@@ -43,7 +43,7 @@ export function getRenderableOptions(
}
export function getCurrentOption(
state: AskState,
state: AskState
): AskDisplayOption | undefined {
return getRenderableOptions(getCurrentQuestion(state))[
state.activeOptionIndex
@@ -52,14 +52,14 @@ export function getCurrentOption(
export function getAnswer(
state: AskState,
questionId: string,
questionId: string
): AskStateAnswer | undefined {
return state.answers[questionId];
}
export function getQuestionNote(
state: AskState,
questionId: string,
questionId: string
): string | undefined {
return getAnswer(state, questionId)?.note;
}
@@ -67,21 +67,21 @@ export function getQuestionNote(
export function getOptionNote(
state: AskState,
questionId: string,
optionValue: string,
optionValue: string
): string | undefined {
return getAnswer(state, questionId)?.optionNotes?.[optionValue];
}
export function isQuestionAnswered(
state: AskState,
questionId: string,
questionId: string
): boolean {
return isAnswerAnswered(getAnswer(state, questionId));
}
export function isQuestionNoteOpen(
state: AskState,
questionId: string,
questionId: string
): boolean {
return (
state.view.kind === "note" &&
@@ -93,7 +93,7 @@ export function isQuestionNoteOpen(
export function isOptionNoteOpen(
state: AskState,
questionId: string,
optionValue: string,
optionValue: string
): boolean {
return (
state.view.kind === "note" &&
@@ -104,7 +104,7 @@ export function isOptionNoteOpen(
export function isInputOpenForQuestion(
state: AskState,
questionId: string,
questionId: string
): boolean {
return state.view.kind === "input" && state.view.questionId === questionId;
}
+18 -18
View File
@@ -58,7 +58,7 @@ export function reduceAskState(state: AskState, action: AskAction): AskState {
case "OPEN_OPTION_NOTE":
return setView(
state,
optionNoteView(action.questionId, action.optionValue),
optionNoteView(action.questionId, action.optionValue)
);
case "CONFIRM":
return confirmCurrentSelection(state);
@@ -97,7 +97,7 @@ export function enterInputMode(state: AskState, questionId: string): AskState {
export function enterQuestionNoteMode(
state: AskState,
questionId: string,
questionId: string
): AskState {
return reduceAskState(state, { type: "OPEN_QUESTION_NOTE", questionId });
}
@@ -105,7 +105,7 @@ export function enterQuestionNoteMode(
export function enterOptionNoteMode(
state: AskState,
questionId: string,
optionValue: string,
optionValue: string
): AskState {
return reduceAskState(state, {
type: "OPEN_OPTION_NOTE",
@@ -130,13 +130,13 @@ export function toggleCurrentOption(state: AskState): AskState {
if (question.type === "multi") {
return updateAnswer(state, question.id, (answer) =>
toggleSelection(answer, option, state.activeOptionIndex),
toggleSelection(answer, option, state.activeOptionIndex)
);
}
return updateAnswer(state, question.id, (answer) => {
const isSelected = answer.selected.some(
(selection) => selection.value === option.value,
(selection) => selection.value === option.value
);
if (isSelected) {
return {
@@ -161,7 +161,7 @@ export function confirmCurrentSelection(state: AskState): AskState {
const question = getCurrentQuestion(state);
const option = getCurrentOption(state);
if (!question || !option) {
if (!(question && option)) {
return state;
}
@@ -177,7 +177,7 @@ export function confirmCurrentSelection(state: AskState): AskState {
}
const nextState = updateAnswer(state, question.id, (answer) =>
setSingleSelection(answer, option, state.activeOptionIndex),
setSingleSelection(answer, option, state.activeOptionIndex)
);
return advanceToNextTab(nextState);
}
@@ -205,12 +205,12 @@ export function applyNumberShortcut(state: AskState, digit: number): AskState {
if (question.type === "multi") {
return updateAnswer(selectedState, question.id, (answer) =>
toggleSelection(answer, option, index),
toggleSelection(answer, option, index)
);
}
const nextState = updateAnswer(selectedState, question.id, (answer) =>
setSingleSelection(answer, option, index),
setSingleSelection(answer, option, index)
);
return advanceToNextTab(nextState);
}
@@ -221,7 +221,7 @@ export function saveCustomAnswer(state: AskState, rawValue: string): AskState {
export function submitCustomAnswer(
state: AskState,
rawValue: string,
rawValue: string
): AskState {
return saveInputValue(state, rawValue, true);
}
@@ -244,7 +244,7 @@ export function cancelFlow(state: AskState): AskState {
function saveInputValue(
state: AskState,
rawValue: string,
submit: boolean,
submit: boolean
): AskState {
if (state.view.kind !== "input") {
return state;
@@ -258,9 +258,9 @@ function saveInputValue(
const nextState = updateAnswer(
exitEditingView(state),
question.id,
(answer) => saveCustomText(answer, rawValue),
(answer) => saveCustomText(answer, rawValue)
);
if (!submit || !isAnswerAnswered(nextState.answers[question.id])) {
if (!(submit && isAnswerAnswered(nextState.answers[question.id]))) {
return nextState;
}
return advanceToNextTab(nextState);
@@ -278,7 +278,7 @@ function saveNoteValue(state: AskState, rawValue: string): AskState {
(answer) =>
optionValue
? saveOptionNote(answer, optionValue, rawValue)
: saveQuestionNote(answer, rawValue),
: saveQuestionNote(answer, rawValue)
);
return nextState;
}
@@ -303,7 +303,7 @@ function moveOptionBy(state: AskState, delta: 1 | -1): AskState {
activeSubmitActionIndex: clamp(
state.activeSubmitActionIndex + delta,
0,
SUBMIT_ACTION_COUNT - 1,
SUBMIT_ACTION_COUNT - 1
),
};
}
@@ -314,7 +314,7 @@ function moveOptionBy(state: AskState, delta: 1 | -1): AskState {
activeOptionIndex: clamp(
state.activeOptionIndex + delta,
0,
Math.max(0, options.length - 1),
Math.max(0, options.length - 1)
),
};
}
@@ -348,8 +348,8 @@ function updateAnswer(
state: AskState,
questionId: string,
mutate: (
answer: ReturnType<typeof emptyAnswer>,
) => ReturnType<typeof emptyAnswer>,
answer: ReturnType<typeof emptyAnswer>
) => ReturnType<typeof emptyAnswer>
): AskState {
const existing = getAnswer(state, questionId) ?? emptyAnswer();
const nextAnswer = mutate(existing);
+1 -1
View File
@@ -18,7 +18,7 @@ export function questionNoteView(questionId: string): ViewState {
export function optionNoteView(
questionId: string,
optionValue: string,
optionValue: string
): ViewState {
return { kind: "note", questionId, optionValue };
}
+8 -4
View File
@@ -1,11 +1,14 @@
import { visibleWidth } from "@mariozechner/pi-tui";
const LEADING_WHITESPACE_PATTERN = /^\s*/;
const WORD_SPLIT_PATTERN = /\s+/;
export function wrapText(text: string, width: number): string[] {
const effectiveWidth = Math.max(1, width);
const lines = text
.split("\n")
.flatMap((paragraph) =>
wrapParagraphWithIndentation(paragraph, effectiveWidth),
wrapParagraphWithIndentation(paragraph, effectiveWidth)
);
return lines.length > 0 ? lines : [""];
@@ -13,13 +16,14 @@ export function wrapText(text: string, width: number): string[] {
function wrapParagraphWithIndentation(
paragraph: string,
width: number,
width: number
): string[] {
if (paragraph.length === 0) {
return [""];
}
const leadingWhitespace = paragraph.match(/^\s*/)?.[0] ?? "";
const leadingWhitespace =
paragraph.match(LEADING_WHITESPACE_PATTERN)?.[0] ?? "";
const body = paragraph.slice(leadingWhitespace.length).trim();
if (!body) {
return [leadingWhitespace];
@@ -35,7 +39,7 @@ function wrapParagraphWithIndentation(
function wrapParagraph(text: string, width: number): string[] {
const effectiveWidth = Math.max(1, width);
const words = text.split(/\s+/).filter(Boolean);
const words = text.split(WORD_SPLIT_PATTERN).filter(Boolean);
const lines: string[] = [];
let current = "";
+20 -20
View File
@@ -1,57 +1,57 @@
export type AskQuestionType = "single" | "multi" | "preview";
export interface AskOption {
value: string;
label: string;
description?: string;
label: string;
preview?: string;
value: string;
}
export interface AskQuestionInput {
id: string;
label?: string;
prompt: string;
type?: AskQuestionType;
required?: boolean;
options: AskOption[];
prompt: string;
required?: boolean;
type?: AskQuestionType;
}
export interface AskParams {
title?: string;
questions: AskQuestionInput[];
title?: string;
}
export interface AskQuestion
extends Omit<AskQuestionInput, "type" | "required" | "label"> {
label: string;
type: AskQuestionType;
required: boolean;
type: AskQuestionType;
}
export interface AskSelectedOption {
value: string;
label: string;
index: number;
label: string;
value: string;
}
export interface AskStateAnswer {
selected: AskSelectedOption[];
customText?: string;
note?: string;
optionNotes?: Record<string, string>;
selected: AskSelectedOption[];
}
export interface AskResultAnswer {
values: string[];
labels: string[];
indices: number[];
customText?: string;
indices: number[];
labels: string[];
note?: string;
optionNotes?: Record<string, string>;
values: string[];
}
export interface AskResult {
title?: string;
answers: Record<string, AskResultAnswer>;
cancelled: boolean;
questions: Array<{
id: string;
@@ -59,7 +59,7 @@ export interface AskResult {
prompt: string;
type: AskQuestionType;
}>;
answers: Record<string, AskResultAnswer>;
title?: string;
}
export type ViewState =
@@ -69,15 +69,15 @@ export type ViewState =
| { kind: "note"; questionId: string; optionValue?: string };
export interface AskState {
title?: string;
questions: AskQuestion[];
activeTabIndex: number;
activeOptionIndex: number;
activeSubmitActionIndex: number;
view: ViewState;
activeTabIndex: number;
answers: Record<string, AskStateAnswer>;
completed: boolean;
cancelled: boolean;
completed: boolean;
questions: AskQuestion[];
title?: string;
view: ViewState;
}
export interface AskDisplayOption extends AskOption {
+39 -38
View File
@@ -1,28 +1,30 @@
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
import { Editor, type EditorTheme } from "@mariozechner/pi-tui";
import { createInitialState } from "../state/create.ts";
import { toAskResult } from "../state/result.ts";
import {
applyNumberShortcut,
cancelFlow,
confirmCurrentSelection,
createInitialState,
enterInputMode,
enterOptionNoteMode,
enterQuestionNoteMode,
getAnswer,
getCurrentOption,
getCurrentQuestion,
getOptionNote,
getQuestionNote,
isEditingView,
isSubmitTab,
} from "../state/selectors.ts";
import {
applyNumberShortcut,
cancelFlow,
confirmCurrentSelection,
enterInputMode,
enterOptionNoteMode,
enterQuestionNoteMode,
moveOption,
moveTab,
saveCustomAnswer,
saveNote,
submitCustomAnswer,
toAskResult,
toggleCurrentMultiOption,
} from "../state.ts";
} from "../state/transitions.ts";
import { isEditingView } from "../state/view.ts";
import type { AskParams, AskResult, AskState } from "../types.ts";
import type { AskInputCommand } from "./input.ts";
import { getInputCommand } from "./input.ts";
@@ -38,21 +40,21 @@ type Keybindings = CustomCallbackArgs[2];
type Done = (result: AskResult) => void;
interface AskFlowController {
state: AskState;
cachedLines: string[] | undefined;
suppressAutoInputForSelection: boolean;
tui: Tui;
theme: Theme;
editor: Editor;
done: Done;
editor: Editor;
state: AskState;
suppressAutoInputForSelection: boolean;
theme: Theme;
tui: Tui;
}
export async function runAskFlow(
export function runAskFlow(
ctx: ExtensionContext,
params: AskParams,
params: AskParams
): Promise<AskResult> {
return ctx.ui.custom<AskResult>((...args) =>
createAskFlowController(args, params),
createAskFlowController(args, params)
);
}
@@ -63,16 +65,16 @@ function createAskFlowController(
Keybindings,
(result: AskResult) => void,
],
params: AskParams,
params: AskParams
) {
const controller: AskFlowController = {
state: createInitialState(params),
cachedLines: undefined,
suppressAutoInputForSelection: false,
tui,
theme,
editor: createEditor(tui, theme),
done,
editor: createEditor(tui, theme),
state: createInitialState(params),
suppressAutoInputForSelection: false,
theme,
tui,
};
controller.editor.onSubmit = (value) => submitEditor(controller, value);
@@ -91,14 +93,14 @@ function createAskFlowController(
function renderController(
controller: AskFlowController,
width: number,
width: number
): string[] {
if (!controller.cachedLines) {
controller.cachedLines = renderAskScreen({
editor: controller.editor,
state: controller.state,
theme: controller.theme,
width,
editor: controller.editor,
});
}
return controller.cachedLines;
@@ -116,7 +118,7 @@ function handleControllerInput(controller: AskFlowController, data: string) {
function handleEditingCommand(
controller: AskFlowController,
command: AskInputCommand,
data: string,
data: string
) {
if (command.kind === "editMoveTab") {
controller.state = saveEditorAndMoveTab(controller, command.delta);
@@ -145,7 +147,7 @@ function handleEditingCommand(
function handleNavigationCommand(
controller: AskFlowController,
command: AskInputCommand,
command: AskInputCommand
) {
switch (command.kind) {
case "moveTab":
@@ -174,7 +176,7 @@ function handleNavigationCommand(
case "numberShortcut":
commitNavigation(
controller,
applyNumberShortcut(controller.state, command.digit),
applyNumberShortcut(controller.state, command.digit)
);
return;
case "ignore":
@@ -225,8 +227,7 @@ function openOptionNote(controller: AskFlowController) {
const question = getCurrentQuestion(controller.state);
const option = getCurrentOption(controller.state);
if (
!question ||
!option ||
!(question && option) ||
option.isCustomOption ||
isSubmitTab(controller.state)
) {
@@ -235,7 +236,7 @@ function openOptionNote(controller: AskFlowController) {
controller.state = enterOptionNoteMode(
controller.state,
question.id,
option.value,
option.value
);
hydrateEditorForCurrentView(controller);
refresh(controller);
@@ -292,7 +293,7 @@ function syncInputModeWithSelection(controller: AskFlowController) {
const question = getCurrentQuestion(controller.state);
const option = getCurrentOption(controller.state);
if (!question || !option?.isCustomOption) {
if (!(question && option?.isCustomOption)) {
return;
}
@@ -314,7 +315,7 @@ function saveEditorWithoutAdvancing(controller: AskFlowController): AskState {
function saveEditorAndMoveTab(
controller: AskFlowController,
delta: 1 | -1,
delta: 1 | -1
): AskState {
const nextState = saveEditorWithoutAdvancing(controller);
controller.suppressAutoInputForSelection = false;
@@ -323,7 +324,7 @@ function saveEditorAndMoveTab(
function saveEditorAndMoveOption(
controller: AskFlowController,
delta: 1 | -1,
delta: 1 | -1
): AskState {
const nextState = saveEditorWithoutAdvancing(controller);
controller.suppressAutoInputForSelection = false;
@@ -334,11 +335,11 @@ function createEditor(tui: Tui, theme: Theme) {
const editorTheme: EditorTheme = {
borderColor: (text) => theme.fg("accent", text),
selectList: {
description: (text) => theme.fg("muted", text),
noMatch: (text) => theme.fg("warning", text),
scrollInfo: (text) => theme.fg("dim", text),
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);
+4 -2
View File
@@ -1,6 +1,8 @@
import { Key, matchesKey } from "@mariozechner/pi-tui";
import type { AskState } from "../types.ts";
const DIGIT_PATTERN = /^[1-9]$/;
export type AskInputCommand =
| { kind: "moveTab"; delta: 1 | -1 }
| { kind: "moveOption"; delta: 1 | -1 }
@@ -18,7 +20,7 @@ export type AskInputCommand =
export function getInputCommand(
state: AskState,
data: string,
data: string
): AskInputCommand {
if (state.view.kind === "input" || state.view.kind === "note") {
return getEditingInputCommand(data);
@@ -102,5 +104,5 @@ function isQuestionNoteShortcut(data: string): boolean {
}
function parseDigit(data: string): number | null {
return /^[1-9]$/.test(data) ? Number(data) : null;
return DIGIT_PATTERN.test(data) ? Number(data) : null;
}
+4 -4
View File
@@ -3,7 +3,7 @@ import {
getCurrentQuestion,
isQuestionAnswered,
isSubmitTab,
} from "../state.ts";
} from "../state/selectors.ts";
import type { AskState } from "../types.ts";
import { renderFooterText } from "./render-helpers.ts";
import type { Theme } from "./render-types.ts";
@@ -53,7 +53,7 @@ function renderTabs(state: AskState, theme: Theme, width: number): string {
segments.push(
active
? theme.bg("selectedBg", theme.fg("text", text))
: theme.fg(answered ? "success" : "muted", text),
: theme.fg(answered ? "success" : "muted", text)
);
}
@@ -61,7 +61,7 @@ function renderTabs(state: AskState, theme: Theme, width: number): string {
segments.push(
isSubmitTab(state)
? theme.bg("selectedBg", theme.fg("text", submitText))
: theme.fg("success", submitText),
: theme.fg("success", submitText)
);
segments.push(theme.fg("dim", " →"));
return truncateToWidth(` ${segments.join(" ")}`, width);
@@ -80,6 +80,6 @@ function renderFooter(state: AskState, theme: Theme): string {
const question = getCurrentQuestion(state);
return theme.fg(
"dim",
renderFooterText(question?.type === "multi" ? "multi" : "default"),
renderFooterText(question?.type === "multi" ? "multi" : "default")
);
}
+21 -20
View File
@@ -14,7 +14,7 @@ export function pushWrappedText(
theme: Theme,
color: ThemeColor,
prefix = "",
continuationPrefix = prefix,
continuationPrefix = prefix
) {
const availableWidth = Math.max(1, width - visibleWidth(prefix));
const wrapped = wrapText(text, availableWidth);
@@ -22,7 +22,7 @@ export function pushWrappedText(
const line = wrapped[index];
const currentPrefix = index === 0 ? prefix : continuationPrefix;
lines.push(
truncateToWidth(`${currentPrefix}${theme.fg(color, line)}`, width),
truncateToWidth(`${currentPrefix}${theme.fg(color, line)}`, width)
);
}
if (wrapped.length === 0) {
@@ -34,7 +34,7 @@ export function renderInputLine(
line: string,
availableWidth: number,
theme: Theme,
color: ThemeColor = "text",
color: ThemeColor = "text"
): string {
const innerWidth = Math.max(4, availableWidth - 2);
const truncated = truncateToWidth(line, innerWidth);
@@ -66,7 +66,8 @@ export function renderEditorBlock(args: {
contentColor = "text",
isEmpty = false,
} = args;
const innerLines = editorLines.length >= 2 ? editorLines.slice(1, -1) : editorLines;
const innerLines =
editorLines.length >= 2 ? editorLines.slice(1, -1) : editorLines;
if (isEmpty && placeholder) {
lines.push(
@@ -75,10 +76,10 @@ export function renderEditorBlock(args: {
placeholder,
availableWidth,
theme,
placeholderColor,
placeholderColor
)}`,
width,
),
width
)
);
return;
}
@@ -90,10 +91,10 @@ export function renderEditorBlock(args: {
editorLine,
availableWidth,
theme,
contentColor,
contentColor
)}`,
width,
),
width
)
);
}
}
@@ -101,7 +102,7 @@ export function renderEditorBlock(args: {
export function renderBox(
content: Array<{ text: string; color: ThemeColor }>,
width: number,
theme: Theme,
theme: Theme
): string[] {
const boxWidth = Math.max(UI_DIMENSIONS.boxMinWidth, width);
const innerWidth = Math.max(4, boxWidth - 2);
@@ -113,7 +114,7 @@ export function renderBox(
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", "│"),
theme.fg("accent", "│") + line + padding + theme.fg("accent", "│")
);
}
}
@@ -126,7 +127,7 @@ export function renderPreviewPaneContent(
| { label: string; description?: string; preview?: string }
| undefined,
theme: Theme,
width: number,
width: number
): string[] {
if (!selectedOption) {
return renderBox([{ text: NO_PREVIEW_TEXT, color: "dim" }], width, theme);
@@ -140,7 +141,7 @@ export function renderPreviewPaneContent(
}
content.push({ text: "", color: "dim" });
for (const previewLine of (selectedOption.preview ?? NO_PREVIEW_TEXT).split(
"\n",
"\n"
)) {
content.push({
text: previewLine,
@@ -154,7 +155,7 @@ export function mergeColumns(
left: string[],
right: string[],
leftWidth: number,
width: number,
width: number
): string[] {
const lines: string[] = [];
const rowCount = Math.max(left.length, right.length);
@@ -169,7 +170,7 @@ export function mergeColumns(
export function measurePreviewLeftWidth(
options: Array<{ label: string; description?: string }>,
width: number,
width: number
): number {
let widest = 0;
for (let index = 0; index < options.length; index++) {
@@ -177,24 +178,24 @@ export function measurePreviewLeftWidth(
widest = Math.max(
widest,
visibleWidth(`${index + 1}. ${option.label}`),
option.description ? visibleWidth(option.description) : 0,
option.description ? visibleWidth(option.description) : 0
);
}
const preferred = widest + 4;
const maxWidth = Math.min(
UI_DIMENSIONS.previewLeftMaxWidth,
Math.floor(width * UI_DIMENSIONS.previewLeftRatio),
Math.floor(width * UI_DIMENSIONS.previewLeftRatio)
);
return clamp(
preferred,
UI_DIMENSIONS.previewLeftMinWidth,
Math.max(UI_DIMENSIONS.previewLeftMinWidth, maxWidth),
Math.max(UI_DIMENSIONS.previewLeftMinWidth, maxWidth)
);
}
export function renderFooterText(
mode: "input" | "note" | "submit" | "multi" | "default",
mode: "input" | "note" | "submit" | "multi" | "default"
) {
switch (mode) {
case "input":
+54 -38
View File
@@ -1,13 +1,13 @@
import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
import { isOptionSelected } from "../state/answers.ts";
import {
getAnswer,
getOptionNote,
getQuestionNote,
isInputOpenForQuestion,
isOptionNoteOpen,
isOptionSelected,
isQuestionNoteOpen,
} from "../state.ts";
} from "../state/selectors.ts";
import type { AskDisplayOption } from "../types.ts";
import { UI_DIMENSIONS, UI_TEXT } from "./constants.ts";
import {
@@ -48,15 +48,15 @@ function renderQuestionNote(context: QuestionRenderContext) {
theme,
"accent",
" ",
" ",
" "
);
renderEditorBlock({
lines,
editorLines: editor.render(
Math.max(
UI_DIMENSIONS.editorMinWidth,
width - UI_DIMENSIONS.editorContentPadding,
),
width - UI_DIMENSIONS.editorContentPadding
)
),
width,
theme,
@@ -80,7 +80,7 @@ function renderQuestionNote(context: QuestionRenderContext) {
theme,
"muted",
" ",
" ",
" "
);
lines.push("");
}
@@ -95,7 +95,7 @@ function renderStandardQuestion(context: QuestionRenderContext) {
function renderStandardOption(
context: QuestionRenderContext,
option: AskDisplayOption,
index: number,
index: number
) {
const { lines, state, question, theme, width } = context;
const answer = getAnswer(state, question.id);
@@ -106,7 +106,11 @@ function renderStandardOption(
const pointer = selected ? "❯ " : " ";
const prefix = getOptionPrefix(question.type, option, isAnsweredOption);
const optionColor = getOptionColor(isAnsweredOption, selected);
if (option.isCustomOption && selected && isInputOpenForQuestion(state, question.id)) {
if (
option.isCustomOption &&
selected &&
isInputOpenForQuestion(state, question.id)
) {
renderInteractiveCustomOption(context, index, pointer, prefix, optionColor);
return;
}
@@ -118,7 +122,7 @@ function renderStandardOption(
theme,
optionColor,
pointer,
" ".repeat(visibleWidth(pointer)),
" ".repeat(visibleWidth(pointer))
);
renderOptionMeta({
...context,
@@ -143,7 +147,7 @@ function renderPreviewQuestion(context: QuestionRenderContext) {
theme,
width,
answer,
selectedOption,
selectedOption
);
} else {
renderStackedPreviewLayout(
@@ -153,7 +157,7 @@ function renderPreviewQuestion(context: QuestionRenderContext) {
theme,
width,
answer,
selectedOption,
selectedOption
);
}
@@ -173,19 +177,19 @@ function renderWidePreviewLayout(
theme: Theme,
width: number,
answer: ReturnType<typeof getAnswer>,
selectedOption: AskDisplayOption | undefined,
selectedOption: AskDisplayOption | undefined
) {
const leftWidth = measurePreviewLeftWidth(options, width);
const rightWidth = Math.max(
UI_DIMENSIONS.previewMinRightWidth,
width - leftWidth - 2,
width - leftWidth - 2
);
const leftPane = renderPreviewOptionList(
state,
options,
theme,
leftWidth,
answer,
answer
);
const rightPane = renderPreviewPaneContent(selectedOption, theme, rightWidth);
for (const line of mergeColumns(leftPane, rightPane, leftWidth, width)) {
@@ -200,7 +204,7 @@ function renderStackedPreviewLayout(
theme: Theme,
width: number,
answer: ReturnType<typeof getAnswer>,
selectedOption: AskDisplayOption | undefined,
selectedOption: AskDisplayOption | undefined
) {
renderPreviewOptionList(state, options, theme, width, answer).forEach(add);
add("");
@@ -212,7 +216,7 @@ function renderPreviewOptionList(
options: QuestionRenderContext["options"],
theme: Theme,
width: number,
answer: ReturnType<typeof getAnswer>,
answer: ReturnType<typeof getAnswer>
): string[] {
const lines: string[] = [];
for (let index = 0; index < options.length; index++) {
@@ -228,7 +232,7 @@ function renderPreviewOptionList(
theme,
getOptionColor(isAnsweredOption, selected),
selected ? "❯ " : " ",
" ",
" "
);
if (option.description) {
pushWrappedText(
@@ -238,7 +242,7 @@ function renderPreviewOptionList(
theme,
"muted",
" ",
" ",
" "
);
}
}
@@ -251,7 +255,7 @@ function renderOptionMeta(
answer: ReturnType<typeof getAnswer>;
option: AskDisplayOption;
selected: boolean;
},
}
) {
const { lines, option, theme, width } = context;
if (option.description) {
@@ -262,7 +266,7 @@ function renderOptionMeta(
theme,
"muted",
" ",
" ",
" "
);
}
renderOptionEditorOrNote(context);
@@ -312,7 +316,7 @@ function getOptionDetailRenderState(context: OptionDetailRenderContext) {
}
function hasOptionDetailContent(
renderState: ReturnType<typeof getOptionDetailRenderState>,
renderState: ReturnType<typeof getOptionDetailRenderState>
) {
return !!(
renderState.noteOpen ||
@@ -324,15 +328,27 @@ function hasOptionDetailContent(
function renderOptionEditor(
context: OptionDetailRenderContext,
renderState: ReturnType<typeof getOptionDetailRenderState>,
renderState: ReturnType<typeof getOptionDetailRenderState>
): boolean {
const { lines, editor, width, theme } = context;
if (renderState.noteOpen) {
renderIndentedEditor(lines, editor, width, theme, UI_TEXT.editorPlaceholderNote);
renderIndentedEditor(
lines,
editor,
width,
theme,
UI_TEXT.editorPlaceholderNote
);
return true;
}
if (renderState.inputOpen) {
renderIndentedEditor(lines, editor, width, theme, UI_TEXT.editorPlaceholderInput);
renderIndentedEditor(
lines,
editor,
width,
theme,
UI_TEXT.editorPlaceholderInput
);
return true;
}
return false;
@@ -340,10 +356,10 @@ function renderOptionEditor(
function renderOptionContent(
context: OptionDetailRenderContext,
renderState: ReturnType<typeof getOptionDetailRenderState>,
renderState: ReturnType<typeof getOptionDetailRenderState>
): boolean {
const { lines, option, theme, width } = context;
if (!option?.isCustomOption || !renderState.customText) {
if (!(option?.isCustomOption && renderState.customText)) {
return false;
}
pushWrappedText(
@@ -353,14 +369,14 @@ function renderOptionContent(
theme,
"muted",
" ",
" ",
" "
);
return true;
}
function renderSavedOptionNote(
context: OptionDetailRenderContext,
optionNote: string | undefined,
optionNote: string | undefined
) {
if (!optionNote) {
return;
@@ -373,7 +389,7 @@ function renderSavedOptionNote(
theme,
"muted",
" ",
" ",
" "
);
}
@@ -382,15 +398,15 @@ function renderIndentedEditor(
editor: QuestionRenderContext["editor"],
width: number,
theme: Theme,
placeholder: string,
placeholder: string
) {
renderEditorBlock({
lines,
editorLines: editor.render(
Math.max(
UI_DIMENSIONS.editorMinWidth,
width - UI_DIMENSIONS.editorIndentedPadding,
),
width - UI_DIMENSIONS.editorIndentedPadding
)
),
width,
theme,
@@ -404,7 +420,7 @@ function renderIndentedEditor(
function getOptionPrefix(
questionType: QuestionRenderContext["question"]["type"],
option: AskDisplayOption,
isAnsweredOption: boolean,
isAnsweredOption: boolean
): string {
if (questionType !== "multi" || option.isCustomOption) {
return "";
@@ -417,13 +433,13 @@ function renderInteractiveCustomOption(
index: number,
pointer: string,
prefix: string,
optionColor: ReturnType<typeof getOptionColor>,
optionColor: ReturnType<typeof getOptionColor>
) {
const { lines, editor, theme, width } = context;
const label = `${index + 1}. ${prefix}`;
const availableWidth = Math.max(
1,
width - visibleWidth(pointer) - visibleWidth(label),
width - visibleWidth(pointer) - visibleWidth(label)
);
const text = editor.getText().replace(/\n/g, " ");
const hasText = text.trim().length > 0;
@@ -435,10 +451,10 @@ function renderInteractiveCustomOption(
inputText,
availableWidth,
theme,
inputColor,
inputColor
)}`,
width,
),
width
)
);
}
+12 -12
View File
@@ -1,6 +1,6 @@
import { truncateToWidth } from "@mariozechner/pi-tui";
import { SUBMIT_CHOICES } from "../constants.ts";
import { toAskResult } from "../state.ts";
import { toAskResult } from "../state/result.ts";
import type { AskResult, AskState } from "../types.ts";
import { UI_TEXT } from "./constants.ts";
import { pushWrappedText } from "./render-helpers.ts";
@@ -10,7 +10,7 @@ export function renderSubmitScreen(
lines: string[],
state: AskState,
theme: Theme,
width: number,
width: number
) {
pushWrappedText(lines, UI_TEXT.reviewTitle, width, theme, "accent", " ", " ");
lines.push("");
@@ -22,7 +22,7 @@ export function renderSubmitScreen(
question,
submittedAnswers[question.id],
theme,
width,
width
);
}
@@ -34,7 +34,7 @@ export function renderSubmitScreen(
theme,
"success",
" ",
" ",
" "
);
lines.push("");
renderSubmitActions(lines, state, theme, width);
@@ -45,12 +45,12 @@ function renderSubmittedQuestion(
question: AskState["questions"][number],
answer: AskResult["answers"][string] | undefined,
theme: Theme,
width: number,
width: number
) {
pushWrappedText(lines, question.prompt, width, theme, "muted", " ● ", " ");
if (!answer) {
lines.push(
truncateToWidth(` ${theme.fg("warning", UI_TEXT.unanswered)}`, width),
truncateToWidth(` ${theme.fg("warning", UI_TEXT.unanswered)}`, width)
);
return;
}
@@ -62,7 +62,7 @@ function renderSubmittedAnswer(
lines: string[],
answer: AskResult["answers"][string],
theme: Theme,
width: number,
width: number
) {
const answerText = answer.customText ?? answer.labels.join(", ");
if (answerText) {
@@ -73,7 +73,7 @@ function renderSubmittedAnswer(
theme,
answer.customText ? "text" : "success",
" ",
" ",
" "
);
}
if (answer.note) {
@@ -84,7 +84,7 @@ function renderSubmittedAnswer(
theme,
"muted",
" ",
" ",
" "
);
}
for (let index = 0; index < answer.values.length; index++) {
@@ -101,7 +101,7 @@ function renderSubmittedAnswer(
theme,
"muted",
" ",
" ",
" "
);
}
}
@@ -110,7 +110,7 @@ function renderSubmitActions(
lines: string[],
state: AskState,
theme: Theme,
width: number,
width: number
) {
for (let index = 0; index < SUBMIT_CHOICES.length; index++) {
const selected = index === state.activeSubmitActionIndex;
@@ -122,7 +122,7 @@ function renderSubmitActions(
theme,
selected ? "accent" : "text",
prefix,
prefix,
prefix
);
}
}
+9 -9
View File
@@ -4,30 +4,30 @@ import type {
getAnswer,
getCurrentQuestion,
getRenderableOptions,
} from "../state.ts";
} from "../state/selectors.ts";
import type { AskDisplayOption, AskState } from "../types.ts";
export type Theme = ExtensionContext["ui"]["theme"];
export interface QuestionRenderContext {
editor: Editor;
lines: string[];
state: AskState;
question: NonNullable<ReturnType<typeof getCurrentQuestion>>;
options: ReturnType<typeof getRenderableOptions>;
question: NonNullable<ReturnType<typeof getCurrentQuestion>>;
state: AskState;
theme: Theme;
width: number;
editor: Editor;
}
export interface OptionDetailRenderContext {
lines: string[];
state: AskState;
questionId: string;
answer: ReturnType<typeof getAnswer>;
editor: Editor;
lines: string[];
option: AskDisplayOption | undefined;
questionId: string;
selected?: boolean;
state: AskState;
theme: Theme;
width: number;
editor: Editor;
selected?: boolean;
withGap?: boolean;
}
+1 -1
View File
@@ -2,7 +2,7 @@ import {
getCurrentQuestion,
getRenderableOptions,
isSubmitTab,
} from "../state.ts";
} from "../state/selectors.ts";
import type { AskState } from "../types.ts";
import { renderFrameFooter, renderFrameHeader } from "./render-frame.ts";
import { renderQuestionScreen } from "./render-question.ts";