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
+25 -28
View File
@@ -6,33 +6,30 @@ Guidance for agents working in this repository.
This repo builds a **pi.dev extension** that adds an `ask` / interview-style user question flow. This repo builds a **pi.dev extension** that adds an `ask` / interview-style user question flow.
Primary deliverables in this repo: Primary deliverables:
- research and comparison docs: - `src/` — implementation
- `RESEARCH.md` - `tests/` — behavior coverage
- `FINDINGS.md` - `docs/` — contract and architecture
- `SPEC.md`
- TypeScript extension implementation in `src/`
- tests for core logic in `tests/`
## Key project goals ## Project goals
When making changes, optimize for: Optimize for:
1. **pi-native extension design** 1. **pi-native design**
- prefer `pi.registerTool()` - prefer `pi.registerTool()`
- prefer `ctx.ui.custom()` for complex interactive flows - prefer `ctx.ui.custom()` for complex flows
- keep result shapes compatible with normal pi tool results - keep results compatible with normal pi tool results
2. **testable architecture** 2. **testable architecture**
- keep state and decision logic in plain TypeScript modules - keep state logic in plain TypeScript modules
- keep pi/TUI wiring thin - keep pi/TUI wiring thin
- prefer testing state transitions and serialization over testing terminal rendering details - prefer testing transitions and serialization over terminal rendering details
3. **research alignment** 3. **small docs, clear code**
- keep implementation consistent with `SPEC.md` - keep implementation consistent with `docs/contract.md`
- if behavior changes materially, update `README.md` and `SPEC.md` - update docs when behavior changes materially
- if new harness findings are added, update `RESEARCH.md` and/or `FINDINGS.md` - keep docs focused on contracts and boundaries, not line-by-line explanations
## Tech stack ## Tech stack
@@ -92,12 +89,12 @@ pi -e ./src/index.ts
### docs ### docs
Keep docs practical and synced with the implementation. Keep docs short and synced with the implementation.
- `README.md`: how to install, run, test, and use the tool - `README.md`: install, run, and use
- `SPEC.md`: intended design and UX contract - `docs/README.md`: docs index
- `RESEARCH.md`: harness comparison notes - `docs/contract.md`: external behavior and UX guarantees
- `FINDINGS.md`: synthesized conclusions from source research - `docs/architecture.md`: module boundaries and invariants
## Coding expectations ## Coding expectations
@@ -118,13 +115,13 @@ Keep docs practical and synced with the implementation.
## When changing behavior ## When changing behavior
If you change the ask flow in a meaningful way, update the relevant docs in the same change: Update the relevant docs in the same change:
- user-facing usage changes -> `README.md` - usage changes -> `README.md`
- architecture/contract changes -> `SPEC.md` - contract or UX changes -> `docs/contract.md`
- comparative harness findings -> `RESEARCH.md` / `FINDINGS.md` - module boundary changes -> `docs/architecture.md`
## Preferred workflow for agents ## Preferred workflow
1. read the relevant docs first 1. read the relevant docs first
2. inspect the existing implementation before refactoring 2. inspect the existing implementation before refactoring
+23 -20
View File
@@ -1,24 +1,16 @@
# pi-ask # pi-ask
This project explores a **pi extension** that brings an `ask` capability into the **pi.dev harness**. `pi-ask` is a **pi.dev extension** that adds an interactive `ask_user` clarification tool.
## What this is It lets an agent pause, ask structured questions in a terminal UI, and continue with normalized answers instead of guessing.
The goal is to add a tool or extension layer that lets pi users ask questions through a dedicated `ask` interface, while fitting naturally into the pi.dev agent workflow. ## Project layout
## Why
Different agent harnesses expose question/answer style tools in different ways. This repository will collect research on those implementations and use that knowledge to design a pi extension for pi.dev.
## Contents
- `RESEARCH.md` — notes on how `ask` is implemented in different harnesses
- `FINDINGS.md` — synthesized findings from source research
- `SPEC.md` — implementation spec for a pi-native ask/interview tool
- `src/` — TypeScript extension implementation - `src/` — TypeScript extension implementation
- `tests/` — TypeScript tests for core logic/result formatting - `tests/` — behavior-focused tests
- `docs/` — small docs set for contract and architecture
## Usage ## Getting started
### Requirements ### Requirements
@@ -35,7 +27,7 @@ Run pi with the extension entrypoint:
pi -e ./src/index.ts pi -e ./src/index.ts
``` ```
This project is designed for dynamic loading via the `-e` flag. Load it dynamically with the `-e` flag.
### Install dependencies ### Install dependencies
@@ -87,10 +79,10 @@ Note: some Zed settings such as global extension auto-install and autosave are n
## Tool ## Tool
The extension registers a tool named `ask_user`. The extension registers one tool: `ask_user`.
Use it when the agent needs structured clarification before proceeding. Use it when the agent needs structured clarification before proceeding.
Use `type: "preview"` when the choice needs a dedicated preview pane (for example, code snippets, layouts, configuration examples, or other rich previews). Use `type: "preview"` when an option needs a dedicated preview pane.
### Input shape ### Input shape
@@ -163,9 +155,9 @@ Use `type: "preview"` when the choice needs a dedicated preview pane (for exampl
} }
``` ```
## Current UX ## Features
The current prototype supports: `ask_user` currently supports:
- tabbed multi-question flow - tabbed multi-question flow
- single-select questions - single-select questions
@@ -187,6 +179,7 @@ The returned `details.answers[questionId]` object may include:
{ {
values: string[] values: string[]
labels: string[] labels: string[]
indices: number[]
customText?: string customText?: string
note?: string // question-level note note?: string // question-level note
optionNotes?: Record<string, string> // selected options only optionNotes?: Record<string, string> // selected options only
@@ -230,6 +223,16 @@ For example, ask about framework choice, styling approach, and testing strictnes
Do not guess if those choices would change the implementation. Do not guess if those choices would change the implementation.
``` ```
## Documentation
Docs stay intentionally small:
- `docs/README.md` — index
- `docs/contract.md` — external behavior
- `docs/architecture.md` — module boundaries and invariants
Implementation detail should stay in the code and tests.
## Status ## Status
Prototype implemented in TypeScript and test-covered for core logic. Implemented in TypeScript with behavior-focused test coverage.
+3 -9
View File
@@ -58,9 +58,7 @@
}, },
"overrides": [ "overrides": [
{ {
"includes": [ "includes": ["tests/**/*.ts"],
"tests/**/*.ts"
],
"linter": { "linter": {
"rules": { "rules": {
"complexity": { "complexity": {
@@ -70,9 +68,7 @@
} }
}, },
{ {
"includes": [ "includes": ["src/index.ts"],
"src/index.ts"
],
"linter": { "linter": {
"rules": { "rules": {
"style": { "style": {
@@ -90,7 +86,5 @@
} }
} }
}, },
"extends": [ "extends": ["ultracite/biome/core"]
"ultracite/biome/core"
]
} }
+2
View File
@@ -1,3 +1,5 @@
"use strict";
module.exports = { module.exports = {
extends: ["@commitlint/config-conventional"], extends: ["@commitlint/config-conventional"],
}; };
+17
View File
@@ -0,0 +1,17 @@
# Docs
This folder keeps only the documentation needed to understand and maintain the extension without turning docs into a second copy of the code.
## Files
- `contract.md` — external behavior, result shape, and UX guarantees
- `architecture.md` — module boundaries and invariants
## Reading order
- start with `contract.md` for behavior
- read `architecture.md` for code layout
## Rule of thumb
If a detail is about implementation mechanics, it should usually live in `src/` or `tests/`, not here.
+65
View File
@@ -0,0 +1,65 @@
# Architecture
The codebase is split so the implementation reads through file boundaries and names, not through large explanatory docs.
## Design goals
- thin pi-specific wiring
- pure, testable state transitions
- rendering separated from decision logic
- a small stable tool contract
## Module map
### Tool surface
- `src/index.ts` — extension entrypoint
- `src/ask-tool.ts` — tool registration, non-interactive fallback, transcript rendering
- `src/schema.ts` — TypeBox schema
- `src/types.ts` — shared types
### State
- `src/state/normalize.ts` — normalize incoming questions
- `src/state/answers.ts` — mutate and serialize answers
- `src/state/selectors.ts` — read-only selectors
- `src/state/transitions.ts` — navigation, selection, notes, input, submit, cancel
- `src/state/result.ts` — convert UI state to `AskResult`
- `src/state/view.ts` — view-mode helpers
- `src/state.ts` — state barrel used by UI and tests
### UI
- `src/ui/controller.ts` — connects key input, editor lifecycle, and pure state transitions
- `src/ui/input.ts` — raw input to commands
- `src/ui/render.ts` and `src/ui/render-*.ts` — screen rendering
- `src/ui/constants.ts` and `src/ui/render-types.ts` — rendering constants/contracts
- `src/ask-component.ts` — thin custom UI export
### Result formatting
- `src/result-format.ts` — shared summary/result line formatting
- `src/result.ts` — final result rendering
- `src/text.ts` / `src/constants.ts` — shared display strings
### Tests
- `tests/state.test.ts` — state transitions and serialization
- `tests/input.test.ts` — editing/navigation key behavior
- `tests/result.test.ts` — summaries and transcript output
- `tests/render-*.test.ts` / `tests/text.test.ts` — rendering helpers
## Invariants worth preserving
- submit is never blocked by unanswered questions
- single-select answers serialize as arrays
- free-form answers replace selected options for that question
- preview questions do not get the synthetic custom-answer option
- deselected option notes stay in UI state
- only selected option notes are emitted in the final result
- editor lifecycle stays in the controller, not in the reducers
## Documentation rule
Docs should explain contracts, responsibilities, and invariants.
Code and tests should explain the rest.
+115
View File
@@ -0,0 +1,115 @@
# Ask tool contract
`ask_user` is a pi-native clarification tool for cases where implementation depends on user preference or missing requirements.
This document defines the stable external behavior. It does not explain internal helper-by-helper implementation.
## Input
```ts
{
title?: string;
questions: Array<{
id: string;
label?: string;
prompt: string;
type?: "single" | "multi" | "preview";
required?: boolean;
options: Array<{
value: string;
label: string;
description?: string;
preview?: string;
}>;
}>;
}
```
## Input rules
- at least one question is required
- `label` falls back to `Q1`, `Q2`, ...
- `type` defaults to `single`
- `required` is metadata only; it never blocks submission
- non-`preview` questions get an internal `Type your own` option
## Output
```ts
{
content: [{ type: "text"; text: string }];
details: {
title?: string;
cancelled: boolean;
questions: Array<{
id: string;
label: string;
prompt: string;
type: "single" | "multi" | "preview";
}>;
answers: Record<
string,
{
values: string[];
labels: string[];
indices: number[];
customText?: string;
note?: string;
optionNotes?: Record<string, string>;
}
>;
};
}
```
## Output rules
- `cancelled: true` means the user dismissed the flow or UI was unavailable
- single-select answers still use arrays
- `indices` are 1-based rendered option positions
- `customText` stores the free-form answer
- `note` stores a question-level note
- `optionNotes` includes only notes for selected options
- question notes may exist without a selected answer
## Supported UX
- tabbed multi-question flow
- single-select, multi-select, and preview questions
- inline free-form answers for non-preview questions
- question notes via `Ctrl+N`
- option notes via `N`
- number-key quick selection
- submit/cancel review tab
- transcript-friendly call and result rendering
## Keyboard behavior
Main flow:
- `Tab`, `Shift+Tab`, `Left`, `Right`: move between tabs
- `Up`, `Down`: move between options
- `Enter`: confirm or submit
- `Esc`: cancel the flow
- `1..9`: select or toggle the matching option
- `Space`: toggle the active option
- `N`: edit the active option note
- `Ctrl+N`: edit the current question note
Editing flow:
- `Enter`: save and submit the current input
- `Esc`: save draft and close the editor
- `Up`, `Down`, `Tab`, `Shift+Tab`: save draft and continue navigation
## Non-interactive mode
If `ctx.hasUI === false`, the tool returns an error message in `content` and a cancelled result in `details`.
## Source of truth
Behavior should be verified against:
1. `src/types.ts` and exported state/result helpers
2. `tests/*.test.ts`
3. this contract
+1 -1
View File
@@ -7,7 +7,7 @@ prepare-commit-msg:
commands: commands:
commitzen: commitzen:
interactive: true interactive: true
run: pnpm exec cz --hook run: if [ -t 0 ] && [ -t 1 ]; then pnpm exec cz --hook; else echo "skip commitzen (non-interactive)"; fi
env: env:
LEFTHOOK: "0" LEFTHOOK: "0"
-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 type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { Text, truncateToWidth } from "@mariozechner/pi-tui"; import { Text, truncateToWidth } from "@mariozechner/pi-tui";
import { runAskFlow } from "./ask-component.ts";
import { renderResultText } from "./result.ts"; import { renderResultText } from "./result.ts";
import { AskParamsSchema } from "./schema.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 type { AskParams, AskQuestionInput, AskResult } from "./types.ts";
import { UI_DIMENSIONS } from "./ui/constants.ts"; import { UI_DIMENSIONS } from "./ui/constants.ts";
import { runAskFlow } from "./ui/controller.ts";
export function registerAskTool(pi: ExtensionAPI) { export function registerAskTool(pi: ExtensionAPI) {
pi.registerTool({ pi.registerTool({
@@ -57,7 +58,7 @@ export function registerAskTool(pi: ExtensionAPI) {
? params.questions ? params.questions
.map( .map(
(question: AskQuestionInput, index) => (question: AskQuestionInput, index) =>
question.label || `Q${index + 1}`, question.label || `Q${index + 1}`
) )
.join(", ") .join(", ")
: ""; : "";
@@ -66,7 +67,7 @@ export function registerAskTool(pi: ExtensionAPI) {
if (labels) { if (labels) {
text += theme.fg( text += theme.fg(
"dim", "dim",
` (${truncateToWidth(labels, UI_DIMENSIONS.callLabelTruncateWidth)})`, ` (${truncateToWidth(labels, UI_DIMENSIONS.callLabelTruncateWidth)})`
); );
} }
return new Text(text, 0, 0); return new Text(text, 0, 0);
+8 -8
View File
@@ -2,7 +2,7 @@ import type { AskResult } from "./types.ts";
export function formatResultLines( export function formatResultLines(
result: AskResult, result: AskResult,
options: { mode: "summary" | "render" }, options: { mode: "summary" | "render" }
): string[] { ): string[] {
const lines: string[] = []; const lines: string[] = [];
@@ -20,7 +20,7 @@ export function formatResultLines(
const questionNoteLine = formatQuestionNoteLine( const questionNoteLine = formatQuestionNoteLine(
question.label, question.label,
answer.note, answer.note,
options.mode, options.mode
); );
if (questionNoteLine) { if (questionNoteLine) {
lines.push(questionNoteLine); lines.push(questionNoteLine);
@@ -35,11 +35,11 @@ export function formatResultLines(
function formatAnswerLine( function formatAnswerLine(
questionLabel: string, questionLabel: string,
answer: AskResult["answers"][string], answer: AskResult["answers"][string],
mode: "summary" | "render", mode: "summary" | "render"
): string | undefined { ): string | undefined {
const answerText = answer.customText ?? answer.labels.join(", "); const answerText = answer.customText ?? answer.labels.join(", ");
if (!answerText) { if (!answerText) {
return undefined; return;
} }
if (mode === "summary") { if (mode === "summary") {
return `${questionLabel}: ${answerText}`; return `${questionLabel}: ${answerText}`;
@@ -53,10 +53,10 @@ function formatAnswerLine(
function formatQuestionNoteLine( function formatQuestionNoteLine(
questionLabel: string, questionLabel: string,
note: string | undefined, note: string | undefined,
mode: "summary" | "render", mode: "summary" | "render"
): string | undefined { ): string | undefined {
if (!note) { if (!note) {
return undefined; return;
} }
return mode === "summary" return mode === "summary"
? `${questionLabel} note: ${note}` ? `${questionLabel} note: ${note}`
@@ -66,7 +66,7 @@ function formatQuestionNoteLine(
function formatOptionNoteLines( function formatOptionNoteLines(
questionLabel: string, questionLabel: string,
answer: AskResult["answers"][string], answer: AskResult["answers"][string],
mode: "summary" | "render", mode: "summary" | "render"
): string[] { ): string[] {
const lines: string[] = []; const lines: string[] = [];
for (let index = 0; index < answer.values.length; index++) { for (let index = 0; index < answer.values.length; index++) {
@@ -79,7 +79,7 @@ function formatOptionNoteLines(
lines.push( lines.push(
mode === "summary" mode === "summary"
? `${questionLabel} / ${label} note: ${note}` ? `${questionLabel} / ${label} note: ${note}`
: ` ${label} note: ${note}`, : ` ${label} note: ${note}`
); );
} }
return lines; 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" }), label: Type.String({ description: "Short option title shown in the list" }),
description: Type.Optional( description: Type.Optional(
Type.String({ description: "Optional subtitle/description" }), Type.String({ description: "Optional subtitle/description" })
), ),
preview: Type.Optional( preview: Type.Optional(
Type.String({ Type.String({
description: description:
"Optional preview text shown in the dedicated preview pane for preview questions", "Optional preview text shown in the dedicated preview pane for preview questions",
}), })
), ),
}); });
export const AskQuestionSchema = Type.Object({ export const AskQuestionSchema = Type.Object({
id: Type.String({ description: "Unique question identifier" }), id: Type.String({ description: "Unique question identifier" }),
label: Type.Optional( 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" }), prompt: Type.String({ description: "Full question prompt" }),
type: Type.Optional( type: Type.Optional(
Type.Union( Type.Union(
[Type.Literal("single"), Type.Literal("multi"), Type.Literal("preview")], [Type.Literal("single"), Type.Literal("multi"), Type.Literal("preview")],
{ description: "Question type" }, { description: "Question type" }
), )
), ),
required: Type.Optional( required: Type.Optional(
Type.Boolean({ Type.Boolean({
description: "Optional metadata flag; submit is never blocked", description: "Optional metadata flag; submit is never blocked",
}), })
), ),
options: Type.Array(AskOptionSchema, { options: Type.Array(AskOptionSchema, {
description: "Available answer options", description: "Available answer options",
@@ -40,7 +40,7 @@ export const AskQuestionSchema = Type.Object({
export const AskParamsSchema = Type.Object({ export const AskParamsSchema = Type.Object({
title: Type.Optional( 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, { questions: Type.Array(AskQuestionSchema, {
description: "Questions to ask the user", 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( export function toggleSelection(
answer: AskStateAnswer, answer: AskStateAnswer,
option: AskDisplayOption, option: AskDisplayOption,
index: number, index: number
): AskStateAnswer { ): AskStateAnswer {
const next = cloneAnswer(answer); const next = cloneAnswer(answer);
delete next.customText; next.customText = undefined;
const selectedIndex = next.selected.findIndex( const selectedIndex = next.selected.findIndex(
(selection) => selection.value === option.value, (selection) => selection.value === option.value
); );
if (selectedIndex >= 0) { if (selectedIndex >= 0) {
@@ -56,7 +56,7 @@ export function toggleSelection(
export function setSingleSelection( export function setSingleSelection(
answer: AskStateAnswer, answer: AskStateAnswer,
option: AskDisplayOption, option: AskDisplayOption,
index: number, index: number
): AskStateAnswer { ): AskStateAnswer {
return { return {
...emptyAnswer(), ...emptyAnswer(),
@@ -74,13 +74,13 @@ export function setSingleSelection(
export function saveCustomText( export function saveCustomText(
answer: AskStateAnswer, answer: AskStateAnswer,
rawValue: string, rawValue: string
): AskStateAnswer { ): AskStateAnswer {
const trimmed = rawValue.trim(); const trimmed = rawValue.trim();
const next = cloneAnswer(answer); const next = cloneAnswer(answer);
next.selected = []; next.selected = [];
if (!trimmed) { if (!trimmed) {
delete next.customText; next.customText = undefined;
return next; return next;
} }
next.customText = rawValue; next.customText = rawValue;
@@ -89,21 +89,21 @@ export function saveCustomText(
export function saveQuestionNote( export function saveQuestionNote(
answer: AskStateAnswer, answer: AskStateAnswer,
rawValue: string, rawValue: string
): AskStateAnswer { ): AskStateAnswer {
const next = cloneAnswer(answer); const next = cloneAnswer(answer);
if (rawValue.trim()) { if (rawValue.trim()) {
next.note = rawValue; next.note = rawValue;
return next; return next;
} }
delete next.note; next.note = undefined;
return next; return next;
} }
export function saveOptionNote( export function saveOptionNote(
answer: AskStateAnswer, answer: AskStateAnswer,
optionValue: string, optionValue: string,
rawValue: string, rawValue: string
): AskStateAnswer { ): AskStateAnswer {
const next = cloneAnswer(answer); const next = cloneAnswer(answer);
const optionNotes = { ...(next.optionNotes ?? {}) }; const optionNotes = { ...(next.optionNotes ?? {}) };
@@ -135,10 +135,10 @@ export function isAnswerAnswered(answer?: AskStateAnswer): boolean {
export function isOptionSelected( export function isOptionSelected(
answer: AskStateAnswer | undefined, answer: AskStateAnswer | undefined,
optionValue: string, optionValue: string
): boolean { ): boolean {
return !!answer?.selected.some( return !!answer?.selected.some(
(selection) => selection.value === optionValue, (selection) => selection.value === optionValue
); );
} }
@@ -157,7 +157,7 @@ export function serializeAnswer(answer: AskStateAnswer): AskResultAnswer {
selection.value, selection.value,
answer.optionNotes?.[selection.value], answer.optionNotes?.[selection.value],
]) ])
.filter((entry): entry is [string, string] => !!entry[1]), .filter((entry): entry is [string, string] => !!entry[1])
) )
: undefined; : 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[] { export function normalizeQuestions(params: AskParams): AskQuestion[] {
return params.questions.map((question, index) => return params.questions.map((question, index) =>
normalizeQuestion(question, index), normalizeQuestion(question, index)
); );
} }
function normalizeQuestion( function normalizeQuestion(
question: AskQuestionInput, question: AskQuestionInput,
index: number, index: number
): AskQuestion { ): AskQuestion {
return { return {
...question, ...question,
+3 -4
View File
@@ -7,10 +7,9 @@ export function toAskResult(state: AskState): AskResult {
const answers = Object.fromEntries( const answers = Object.fromEntries(
Object.entries(state.answers) Object.entries(state.answers)
.map( .map(
([questionId, answer]) => ([questionId, answer]) => [questionId, serializeAnswer(answer)] as const
[questionId, serializeAnswer(answer)] as const,
) )
.filter(([, answer]) => !isSerializedAnswerEmpty(answer)), .filter(([, answer]) => !isSerializedAnswerEmpty(answer))
); );
return { return {
@@ -41,7 +40,7 @@ export function hasAnswerContent(state: AskState, questionId: string): boolean {
} }
function isSerializedAnswerEmpty( function isSerializedAnswerEmpty(
answer: AskResult["answers"][string], answer: AskResult["answers"][string]
): boolean { ): boolean {
return ( return (
answer.values.length === 0 && answer.values.length === 0 &&
+10 -10
View File
@@ -13,7 +13,7 @@ export function getCurrentQuestion(state: AskState): AskQuestion | undefined {
export function getQuestionById( export function getQuestionById(
state: AskState, state: AskState,
questionId: string, questionId: string
): AskQuestion | undefined { ): AskQuestion | undefined {
return state.questions.find((question) => question.id === questionId); return state.questions.find((question) => question.id === questionId);
} }
@@ -23,7 +23,7 @@ export function isSubmitTab(state: AskState): boolean {
} }
export function getRenderableOptions( export function getRenderableOptions(
question?: AskQuestion, question?: AskQuestion
): AskDisplayOption[] { ): AskDisplayOption[] {
if (!question) { if (!question) {
return []; return [];
@@ -43,7 +43,7 @@ export function getRenderableOptions(
} }
export function getCurrentOption( export function getCurrentOption(
state: AskState, state: AskState
): AskDisplayOption | undefined { ): AskDisplayOption | undefined {
return getRenderableOptions(getCurrentQuestion(state))[ return getRenderableOptions(getCurrentQuestion(state))[
state.activeOptionIndex state.activeOptionIndex
@@ -52,14 +52,14 @@ export function getCurrentOption(
export function getAnswer( export function getAnswer(
state: AskState, state: AskState,
questionId: string, questionId: string
): AskStateAnswer | undefined { ): AskStateAnswer | undefined {
return state.answers[questionId]; return state.answers[questionId];
} }
export function getQuestionNote( export function getQuestionNote(
state: AskState, state: AskState,
questionId: string, questionId: string
): string | undefined { ): string | undefined {
return getAnswer(state, questionId)?.note; return getAnswer(state, questionId)?.note;
} }
@@ -67,21 +67,21 @@ export function getQuestionNote(
export function getOptionNote( export function getOptionNote(
state: AskState, state: AskState,
questionId: string, questionId: string,
optionValue: string, optionValue: string
): string | undefined { ): string | undefined {
return getAnswer(state, questionId)?.optionNotes?.[optionValue]; return getAnswer(state, questionId)?.optionNotes?.[optionValue];
} }
export function isQuestionAnswered( export function isQuestionAnswered(
state: AskState, state: AskState,
questionId: string, questionId: string
): boolean { ): boolean {
return isAnswerAnswered(getAnswer(state, questionId)); return isAnswerAnswered(getAnswer(state, questionId));
} }
export function isQuestionNoteOpen( export function isQuestionNoteOpen(
state: AskState, state: AskState,
questionId: string, questionId: string
): boolean { ): boolean {
return ( return (
state.view.kind === "note" && state.view.kind === "note" &&
@@ -93,7 +93,7 @@ export function isQuestionNoteOpen(
export function isOptionNoteOpen( export function isOptionNoteOpen(
state: AskState, state: AskState,
questionId: string, questionId: string,
optionValue: string, optionValue: string
): boolean { ): boolean {
return ( return (
state.view.kind === "note" && state.view.kind === "note" &&
@@ -104,7 +104,7 @@ export function isOptionNoteOpen(
export function isInputOpenForQuestion( export function isInputOpenForQuestion(
state: AskState, state: AskState,
questionId: string, questionId: string
): boolean { ): boolean {
return state.view.kind === "input" && state.view.questionId === questionId; 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": case "OPEN_OPTION_NOTE":
return setView( return setView(
state, state,
optionNoteView(action.questionId, action.optionValue), optionNoteView(action.questionId, action.optionValue)
); );
case "CONFIRM": case "CONFIRM":
return confirmCurrentSelection(state); return confirmCurrentSelection(state);
@@ -97,7 +97,7 @@ export function enterInputMode(state: AskState, questionId: string): AskState {
export function enterQuestionNoteMode( export function enterQuestionNoteMode(
state: AskState, state: AskState,
questionId: string, questionId: string
): AskState { ): AskState {
return reduceAskState(state, { type: "OPEN_QUESTION_NOTE", questionId }); return reduceAskState(state, { type: "OPEN_QUESTION_NOTE", questionId });
} }
@@ -105,7 +105,7 @@ export function enterQuestionNoteMode(
export function enterOptionNoteMode( export function enterOptionNoteMode(
state: AskState, state: AskState,
questionId: string, questionId: string,
optionValue: string, optionValue: string
): AskState { ): AskState {
return reduceAskState(state, { return reduceAskState(state, {
type: "OPEN_OPTION_NOTE", type: "OPEN_OPTION_NOTE",
@@ -130,13 +130,13 @@ export function toggleCurrentOption(state: AskState): AskState {
if (question.type === "multi") { if (question.type === "multi") {
return updateAnswer(state, question.id, (answer) => return updateAnswer(state, question.id, (answer) =>
toggleSelection(answer, option, state.activeOptionIndex), toggleSelection(answer, option, state.activeOptionIndex)
); );
} }
return updateAnswer(state, question.id, (answer) => { return updateAnswer(state, question.id, (answer) => {
const isSelected = answer.selected.some( const isSelected = answer.selected.some(
(selection) => selection.value === option.value, (selection) => selection.value === option.value
); );
if (isSelected) { if (isSelected) {
return { return {
@@ -161,7 +161,7 @@ export function confirmCurrentSelection(state: AskState): AskState {
const question = getCurrentQuestion(state); const question = getCurrentQuestion(state);
const option = getCurrentOption(state); const option = getCurrentOption(state);
if (!question || !option) { if (!(question && option)) {
return state; return state;
} }
@@ -177,7 +177,7 @@ export function confirmCurrentSelection(state: AskState): AskState {
} }
const nextState = updateAnswer(state, question.id, (answer) => const nextState = updateAnswer(state, question.id, (answer) =>
setSingleSelection(answer, option, state.activeOptionIndex), setSingleSelection(answer, option, state.activeOptionIndex)
); );
return advanceToNextTab(nextState); return advanceToNextTab(nextState);
} }
@@ -205,12 +205,12 @@ export function applyNumberShortcut(state: AskState, digit: number): AskState {
if (question.type === "multi") { if (question.type === "multi") {
return updateAnswer(selectedState, question.id, (answer) => return updateAnswer(selectedState, question.id, (answer) =>
toggleSelection(answer, option, index), toggleSelection(answer, option, index)
); );
} }
const nextState = updateAnswer(selectedState, question.id, (answer) => const nextState = updateAnswer(selectedState, question.id, (answer) =>
setSingleSelection(answer, option, index), setSingleSelection(answer, option, index)
); );
return advanceToNextTab(nextState); return advanceToNextTab(nextState);
} }
@@ -221,7 +221,7 @@ export function saveCustomAnswer(state: AskState, rawValue: string): AskState {
export function submitCustomAnswer( export function submitCustomAnswer(
state: AskState, state: AskState,
rawValue: string, rawValue: string
): AskState { ): AskState {
return saveInputValue(state, rawValue, true); return saveInputValue(state, rawValue, true);
} }
@@ -244,7 +244,7 @@ export function cancelFlow(state: AskState): AskState {
function saveInputValue( function saveInputValue(
state: AskState, state: AskState,
rawValue: string, rawValue: string,
submit: boolean, submit: boolean
): AskState { ): AskState {
if (state.view.kind !== "input") { if (state.view.kind !== "input") {
return state; return state;
@@ -258,9 +258,9 @@ function saveInputValue(
const nextState = updateAnswer( const nextState = updateAnswer(
exitEditingView(state), exitEditingView(state),
question.id, 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 nextState;
} }
return advanceToNextTab(nextState); return advanceToNextTab(nextState);
@@ -278,7 +278,7 @@ function saveNoteValue(state: AskState, rawValue: string): AskState {
(answer) => (answer) =>
optionValue optionValue
? saveOptionNote(answer, optionValue, rawValue) ? saveOptionNote(answer, optionValue, rawValue)
: saveQuestionNote(answer, rawValue), : saveQuestionNote(answer, rawValue)
); );
return nextState; return nextState;
} }
@@ -303,7 +303,7 @@ function moveOptionBy(state: AskState, delta: 1 | -1): AskState {
activeSubmitActionIndex: clamp( activeSubmitActionIndex: clamp(
state.activeSubmitActionIndex + delta, state.activeSubmitActionIndex + delta,
0, 0,
SUBMIT_ACTION_COUNT - 1, SUBMIT_ACTION_COUNT - 1
), ),
}; };
} }
@@ -314,7 +314,7 @@ function moveOptionBy(state: AskState, delta: 1 | -1): AskState {
activeOptionIndex: clamp( activeOptionIndex: clamp(
state.activeOptionIndex + delta, state.activeOptionIndex + delta,
0, 0,
Math.max(0, options.length - 1), Math.max(0, options.length - 1)
), ),
}; };
} }
@@ -348,8 +348,8 @@ function updateAnswer(
state: AskState, state: AskState,
questionId: string, questionId: string,
mutate: ( mutate: (
answer: ReturnType<typeof emptyAnswer>, answer: ReturnType<typeof emptyAnswer>
) => ReturnType<typeof emptyAnswer>, ) => ReturnType<typeof emptyAnswer>
): AskState { ): AskState {
const existing = getAnswer(state, questionId) ?? emptyAnswer(); const existing = getAnswer(state, questionId) ?? emptyAnswer();
const nextAnswer = mutate(existing); const nextAnswer = mutate(existing);
+1 -1
View File
@@ -18,7 +18,7 @@ export function questionNoteView(questionId: string): ViewState {
export function optionNoteView( export function optionNoteView(
questionId: string, questionId: string,
optionValue: string, optionValue: string
): ViewState { ): ViewState {
return { kind: "note", questionId, optionValue }; return { kind: "note", questionId, optionValue };
} }
+8 -4
View File
@@ -1,11 +1,14 @@
import { visibleWidth } from "@mariozechner/pi-tui"; import { visibleWidth } from "@mariozechner/pi-tui";
const LEADING_WHITESPACE_PATTERN = /^\s*/;
const WORD_SPLIT_PATTERN = /\s+/;
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 = text const lines = text
.split("\n") .split("\n")
.flatMap((paragraph) => .flatMap((paragraph) =>
wrapParagraphWithIndentation(paragraph, effectiveWidth), wrapParagraphWithIndentation(paragraph, effectiveWidth)
); );
return lines.length > 0 ? lines : [""]; return lines.length > 0 ? lines : [""];
@@ -13,13 +16,14 @@ export function wrapText(text: string, width: number): string[] {
function wrapParagraphWithIndentation( function wrapParagraphWithIndentation(
paragraph: string, paragraph: string,
width: number, width: number
): string[] { ): string[] {
if (paragraph.length === 0) { if (paragraph.length === 0) {
return [""]; return [""];
} }
const leadingWhitespace = paragraph.match(/^\s*/)?.[0] ?? ""; const leadingWhitespace =
paragraph.match(LEADING_WHITESPACE_PATTERN)?.[0] ?? "";
const body = paragraph.slice(leadingWhitespace.length).trim(); const body = paragraph.slice(leadingWhitespace.length).trim();
if (!body) { if (!body) {
return [leadingWhitespace]; return [leadingWhitespace];
@@ -35,7 +39,7 @@ function wrapParagraphWithIndentation(
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(WORD_SPLIT_PATTERN).filter(Boolean);
const lines: string[] = []; const lines: string[] = [];
let current = ""; let current = "";
+20 -20
View File
@@ -1,57 +1,57 @@
export type AskQuestionType = "single" | "multi" | "preview"; export type AskQuestionType = "single" | "multi" | "preview";
export interface AskOption { export interface AskOption {
value: string;
label: string;
description?: string; description?: string;
label: string;
preview?: string; preview?: string;
value: string;
} }
export interface AskQuestionInput { export interface AskQuestionInput {
id: string; id: string;
label?: string; label?: string;
prompt: string;
type?: AskQuestionType;
required?: boolean;
options: AskOption[]; options: AskOption[];
prompt: string;
required?: boolean;
type?: AskQuestionType;
} }
export interface AskParams { export interface AskParams {
title?: string;
questions: AskQuestionInput[]; questions: AskQuestionInput[];
title?: string;
} }
export interface AskQuestion export interface AskQuestion
extends Omit<AskQuestionInput, "type" | "required" | "label"> { extends Omit<AskQuestionInput, "type" | "required" | "label"> {
label: string; label: string;
type: AskQuestionType;
required: boolean; required: boolean;
type: AskQuestionType;
} }
export interface AskSelectedOption { export interface AskSelectedOption {
value: string;
label: string;
index: number; index: number;
label: string;
value: string;
} }
export interface AskStateAnswer { export interface AskStateAnswer {
selected: AskSelectedOption[];
customText?: string; customText?: string;
note?: string; note?: string;
optionNotes?: Record<string, string>; optionNotes?: Record<string, string>;
selected: AskSelectedOption[];
} }
export interface AskResultAnswer { export interface AskResultAnswer {
values: string[];
labels: string[];
indices: number[];
customText?: string; customText?: string;
indices: number[];
labels: string[];
note?: string; note?: string;
optionNotes?: Record<string, string>; optionNotes?: Record<string, string>;
values: string[];
} }
export interface AskResult { export interface AskResult {
title?: string; answers: Record<string, AskResultAnswer>;
cancelled: boolean; cancelled: boolean;
questions: Array<{ questions: Array<{
id: string; id: string;
@@ -59,7 +59,7 @@ export interface AskResult {
prompt: string; prompt: string;
type: AskQuestionType; type: AskQuestionType;
}>; }>;
answers: Record<string, AskResultAnswer>; title?: string;
} }
export type ViewState = export type ViewState =
@@ -69,15 +69,15 @@ export type ViewState =
| { kind: "note"; questionId: string; optionValue?: string }; | { kind: "note"; questionId: string; optionValue?: string };
export interface AskState { export interface AskState {
title?: string;
questions: AskQuestion[];
activeTabIndex: number;
activeOptionIndex: number; activeOptionIndex: number;
activeSubmitActionIndex: number; activeSubmitActionIndex: number;
view: ViewState; activeTabIndex: number;
answers: Record<string, AskStateAnswer>; answers: Record<string, AskStateAnswer>;
completed: boolean;
cancelled: boolean; cancelled: boolean;
completed: boolean;
questions: AskQuestion[];
title?: string;
view: ViewState;
} }
export interface AskDisplayOption extends AskOption { export interface AskDisplayOption extends AskOption {
+39 -38
View File
@@ -1,28 +1,30 @@
import type { ExtensionContext } from "@mariozechner/pi-coding-agent"; import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
import { Editor, type EditorTheme } from "@mariozechner/pi-tui"; import { Editor, type EditorTheme } from "@mariozechner/pi-tui";
import { createInitialState } from "../state/create.ts";
import { toAskResult } from "../state/result.ts";
import { import {
applyNumberShortcut,
cancelFlow,
confirmCurrentSelection,
createInitialState,
enterInputMode,
enterOptionNoteMode,
enterQuestionNoteMode,
getAnswer, getAnswer,
getCurrentOption, getCurrentOption,
getCurrentQuestion, getCurrentQuestion,
getOptionNote, getOptionNote,
getQuestionNote, getQuestionNote,
isEditingView,
isSubmitTab, isSubmitTab,
} from "../state/selectors.ts";
import {
applyNumberShortcut,
cancelFlow,
confirmCurrentSelection,
enterInputMode,
enterOptionNoteMode,
enterQuestionNoteMode,
moveOption, moveOption,
moveTab, moveTab,
saveCustomAnswer, saveCustomAnswer,
saveNote, saveNote,
submitCustomAnswer, submitCustomAnswer,
toAskResult,
toggleCurrentMultiOption, toggleCurrentMultiOption,
} from "../state.ts"; } from "../state/transitions.ts";
import { isEditingView } from "../state/view.ts";
import type { AskParams, AskResult, AskState } from "../types.ts"; import type { AskParams, AskResult, AskState } from "../types.ts";
import type { AskInputCommand } from "./input.ts"; import type { AskInputCommand } from "./input.ts";
import { getInputCommand } from "./input.ts"; import { getInputCommand } from "./input.ts";
@@ -38,21 +40,21 @@ type Keybindings = CustomCallbackArgs[2];
type Done = (result: AskResult) => void; type Done = (result: AskResult) => void;
interface AskFlowController { interface AskFlowController {
state: AskState;
cachedLines: string[] | undefined; cachedLines: string[] | undefined;
suppressAutoInputForSelection: boolean;
tui: Tui;
theme: Theme;
editor: Editor;
done: Done; done: Done;
editor: Editor;
state: AskState;
suppressAutoInputForSelection: boolean;
theme: Theme;
tui: Tui;
} }
export async function runAskFlow( export function runAskFlow(
ctx: ExtensionContext, ctx: ExtensionContext,
params: AskParams, params: AskParams
): Promise<AskResult> { ): Promise<AskResult> {
return ctx.ui.custom<AskResult>((...args) => return ctx.ui.custom<AskResult>((...args) =>
createAskFlowController(args, params), createAskFlowController(args, params)
); );
} }
@@ -63,16 +65,16 @@ function createAskFlowController(
Keybindings, Keybindings,
(result: AskResult) => void, (result: AskResult) => void,
], ],
params: AskParams, params: AskParams
) { ) {
const controller: AskFlowController = { const controller: AskFlowController = {
state: createInitialState(params),
cachedLines: undefined, cachedLines: undefined,
suppressAutoInputForSelection: false,
tui,
theme,
editor: createEditor(tui, theme),
done, done,
editor: createEditor(tui, theme),
state: createInitialState(params),
suppressAutoInputForSelection: false,
theme,
tui,
}; };
controller.editor.onSubmit = (value) => submitEditor(controller, value); controller.editor.onSubmit = (value) => submitEditor(controller, value);
@@ -91,14 +93,14 @@ function createAskFlowController(
function renderController( function renderController(
controller: AskFlowController, controller: AskFlowController,
width: number, width: number
): string[] { ): string[] {
if (!controller.cachedLines) { if (!controller.cachedLines) {
controller.cachedLines = renderAskScreen({ controller.cachedLines = renderAskScreen({
editor: controller.editor,
state: controller.state, state: controller.state,
theme: controller.theme, theme: controller.theme,
width, width,
editor: controller.editor,
}); });
} }
return controller.cachedLines; return controller.cachedLines;
@@ -116,7 +118,7 @@ function handleControllerInput(controller: AskFlowController, data: string) {
function handleEditingCommand( function handleEditingCommand(
controller: AskFlowController, controller: AskFlowController,
command: AskInputCommand, command: AskInputCommand,
data: string, data: string
) { ) {
if (command.kind === "editMoveTab") { if (command.kind === "editMoveTab") {
controller.state = saveEditorAndMoveTab(controller, command.delta); controller.state = saveEditorAndMoveTab(controller, command.delta);
@@ -145,7 +147,7 @@ function handleEditingCommand(
function handleNavigationCommand( function handleNavigationCommand(
controller: AskFlowController, controller: AskFlowController,
command: AskInputCommand, command: AskInputCommand
) { ) {
switch (command.kind) { switch (command.kind) {
case "moveTab": case "moveTab":
@@ -174,7 +176,7 @@ function handleNavigationCommand(
case "numberShortcut": case "numberShortcut":
commitNavigation( commitNavigation(
controller, controller,
applyNumberShortcut(controller.state, command.digit), applyNumberShortcut(controller.state, command.digit)
); );
return; return;
case "ignore": case "ignore":
@@ -225,8 +227,7 @@ function openOptionNote(controller: AskFlowController) {
const question = getCurrentQuestion(controller.state); const question = getCurrentQuestion(controller.state);
const option = getCurrentOption(controller.state); const option = getCurrentOption(controller.state);
if ( if (
!question || !(question && option) ||
!option ||
option.isCustomOption || option.isCustomOption ||
isSubmitTab(controller.state) isSubmitTab(controller.state)
) { ) {
@@ -235,7 +236,7 @@ function openOptionNote(controller: AskFlowController) {
controller.state = enterOptionNoteMode( controller.state = enterOptionNoteMode(
controller.state, controller.state,
question.id, question.id,
option.value, option.value
); );
hydrateEditorForCurrentView(controller); hydrateEditorForCurrentView(controller);
refresh(controller); refresh(controller);
@@ -292,7 +293,7 @@ function syncInputModeWithSelection(controller: AskFlowController) {
const question = getCurrentQuestion(controller.state); const question = getCurrentQuestion(controller.state);
const option = getCurrentOption(controller.state); const option = getCurrentOption(controller.state);
if (!question || !option?.isCustomOption) { if (!(question && option?.isCustomOption)) {
return; return;
} }
@@ -314,7 +315,7 @@ function saveEditorWithoutAdvancing(controller: AskFlowController): AskState {
function saveEditorAndMoveTab( function saveEditorAndMoveTab(
controller: AskFlowController, controller: AskFlowController,
delta: 1 | -1, delta: 1 | -1
): AskState { ): AskState {
const nextState = saveEditorWithoutAdvancing(controller); const nextState = saveEditorWithoutAdvancing(controller);
controller.suppressAutoInputForSelection = false; controller.suppressAutoInputForSelection = false;
@@ -323,7 +324,7 @@ function saveEditorAndMoveTab(
function saveEditorAndMoveOption( function saveEditorAndMoveOption(
controller: AskFlowController, controller: AskFlowController,
delta: 1 | -1, delta: 1 | -1
): AskState { ): AskState {
const nextState = saveEditorWithoutAdvancing(controller); const nextState = saveEditorWithoutAdvancing(controller);
controller.suppressAutoInputForSelection = false; controller.suppressAutoInputForSelection = false;
@@ -334,11 +335,11 @@ function createEditor(tui: Tui, theme: Theme) {
const editorTheme: EditorTheme = { const editorTheme: EditorTheme = {
borderColor: (text) => theme.fg("accent", text), borderColor: (text) => theme.fg("accent", text),
selectList: { 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), selectedPrefix: (text) => theme.fg("accent", text),
selectedText: (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); return new Editor(tui, editorTheme);
+4 -2
View File
@@ -1,6 +1,8 @@
import { Key, matchesKey } from "@mariozechner/pi-tui"; import { Key, matchesKey } from "@mariozechner/pi-tui";
import type { AskState } from "../types.ts"; import type { AskState } from "../types.ts";
const DIGIT_PATTERN = /^[1-9]$/;
export type AskInputCommand = export type AskInputCommand =
| { kind: "moveTab"; delta: 1 | -1 } | { kind: "moveTab"; delta: 1 | -1 }
| { kind: "moveOption"; delta: 1 | -1 } | { kind: "moveOption"; delta: 1 | -1 }
@@ -18,7 +20,7 @@ export type AskInputCommand =
export function getInputCommand( export function getInputCommand(
state: AskState, state: AskState,
data: string, data: string
): AskInputCommand { ): AskInputCommand {
if (state.view.kind === "input" || state.view.kind === "note") { if (state.view.kind === "input" || state.view.kind === "note") {
return getEditingInputCommand(data); return getEditingInputCommand(data);
@@ -102,5 +104,5 @@ function isQuestionNoteShortcut(data: string): boolean {
} }
function parseDigit(data: string): number | null { 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, getCurrentQuestion,
isQuestionAnswered, isQuestionAnswered,
isSubmitTab, isSubmitTab,
} from "../state.ts"; } from "../state/selectors.ts";
import type { AskState } from "../types.ts"; import type { AskState } from "../types.ts";
import { renderFooterText } from "./render-helpers.ts"; import { renderFooterText } from "./render-helpers.ts";
import type { Theme } from "./render-types.ts"; import type { Theme } from "./render-types.ts";
@@ -53,7 +53,7 @@ function renderTabs(state: AskState, theme: Theme, width: number): string {
segments.push( segments.push(
active active
? theme.bg("selectedBg", theme.fg("text", text)) ? 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( segments.push(
isSubmitTab(state) isSubmitTab(state)
? theme.bg("selectedBg", theme.fg("text", submitText)) ? theme.bg("selectedBg", theme.fg("text", submitText))
: theme.fg("success", submitText), : theme.fg("success", submitText)
); );
segments.push(theme.fg("dim", " →")); segments.push(theme.fg("dim", " →"));
return truncateToWidth(` ${segments.join(" ")}`, width); return truncateToWidth(` ${segments.join(" ")}`, width);
@@ -80,6 +80,6 @@ function renderFooter(state: AskState, theme: Theme): string {
const question = getCurrentQuestion(state); const question = getCurrentQuestion(state);
return theme.fg( return theme.fg(
"dim", "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, theme: Theme,
color: ThemeColor, color: ThemeColor,
prefix = "", prefix = "",
continuationPrefix = prefix, continuationPrefix = prefix
) { ) {
const availableWidth = Math.max(1, width - visibleWidth(prefix)); const availableWidth = Math.max(1, width - visibleWidth(prefix));
const wrapped = wrapText(text, availableWidth); const wrapped = wrapText(text, availableWidth);
@@ -22,7 +22,7 @@ export function pushWrappedText(
const line = wrapped[index]; const line = wrapped[index];
const currentPrefix = index === 0 ? prefix : continuationPrefix; const currentPrefix = index === 0 ? prefix : continuationPrefix;
lines.push( lines.push(
truncateToWidth(`${currentPrefix}${theme.fg(color, line)}`, width), truncateToWidth(`${currentPrefix}${theme.fg(color, line)}`, width)
); );
} }
if (wrapped.length === 0) { if (wrapped.length === 0) {
@@ -34,7 +34,7 @@ export function renderInputLine(
line: string, line: string,
availableWidth: number, availableWidth: number,
theme: Theme, theme: Theme,
color: ThemeColor = "text", color: ThemeColor = "text"
): string { ): string {
const innerWidth = Math.max(4, availableWidth - 2); const innerWidth = Math.max(4, availableWidth - 2);
const truncated = truncateToWidth(line, innerWidth); const truncated = truncateToWidth(line, innerWidth);
@@ -66,7 +66,8 @@ export function renderEditorBlock(args: {
contentColor = "text", contentColor = "text",
isEmpty = false, isEmpty = false,
} = args; } = args;
const innerLines = editorLines.length >= 2 ? editorLines.slice(1, -1) : editorLines; const innerLines =
editorLines.length >= 2 ? editorLines.slice(1, -1) : editorLines;
if (isEmpty && placeholder) { if (isEmpty && placeholder) {
lines.push( lines.push(
@@ -75,10 +76,10 @@ export function renderEditorBlock(args: {
placeholder, placeholder,
availableWidth, availableWidth,
theme, theme,
placeholderColor, placeholderColor
)}`, )}`,
width, width
), )
); );
return; return;
} }
@@ -90,10 +91,10 @@ export function renderEditorBlock(args: {
editorLine, editorLine,
availableWidth, availableWidth,
theme, theme,
contentColor, contentColor
)}`, )}`,
width, width
), )
); );
} }
} }
@@ -101,7 +102,7 @@ export function renderEditorBlock(args: {
export function renderBox( export function renderBox(
content: Array<{ text: string; color: ThemeColor }>, content: Array<{ text: string; color: ThemeColor }>,
width: number, width: number,
theme: Theme, theme: Theme
): string[] { ): string[] {
const boxWidth = Math.max(UI_DIMENSIONS.boxMinWidth, width); const boxWidth = Math.max(UI_DIMENSIONS.boxMinWidth, width);
const innerWidth = Math.max(4, boxWidth - 2); const innerWidth = Math.max(4, boxWidth - 2);
@@ -113,7 +114,7 @@ export function renderBox(
const line = theme.fg(item.color, rawLine); const line = theme.fg(item.color, rawLine);
const padding = " ".repeat(Math.max(0, innerWidth - visibleWidth(line))); const padding = " ".repeat(Math.max(0, innerWidth - visibleWidth(line)));
lines.push( 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 } | { label: string; description?: string; preview?: string }
| undefined, | undefined,
theme: Theme, theme: Theme,
width: number, width: number
): string[] { ): string[] {
if (!selectedOption) { if (!selectedOption) {
return renderBox([{ text: NO_PREVIEW_TEXT, color: "dim" }], width, theme); return renderBox([{ text: NO_PREVIEW_TEXT, color: "dim" }], width, theme);
@@ -140,7 +141,7 @@ export function renderPreviewPaneContent(
} }
content.push({ text: "", color: "dim" }); content.push({ text: "", color: "dim" });
for (const previewLine of (selectedOption.preview ?? NO_PREVIEW_TEXT).split( for (const previewLine of (selectedOption.preview ?? NO_PREVIEW_TEXT).split(
"\n", "\n"
)) { )) {
content.push({ content.push({
text: previewLine, text: previewLine,
@@ -154,7 +155,7 @@ export function mergeColumns(
left: string[], left: string[],
right: string[], right: string[],
leftWidth: number, leftWidth: number,
width: number, width: number
): string[] { ): string[] {
const lines: string[] = []; const lines: string[] = [];
const rowCount = Math.max(left.length, right.length); const rowCount = Math.max(left.length, right.length);
@@ -169,7 +170,7 @@ export function mergeColumns(
export function measurePreviewLeftWidth( export function measurePreviewLeftWidth(
options: Array<{ label: string; description?: string }>, options: Array<{ label: string; description?: string }>,
width: number, width: number
): number { ): number {
let widest = 0; let widest = 0;
for (let index = 0; index < options.length; index++) { for (let index = 0; index < options.length; index++) {
@@ -177,24 +178,24 @@ export function measurePreviewLeftWidth(
widest = Math.max( widest = Math.max(
widest, widest,
visibleWidth(`${index + 1}. ${option.label}`), visibleWidth(`${index + 1}. ${option.label}`),
option.description ? visibleWidth(option.description) : 0, option.description ? visibleWidth(option.description) : 0
); );
} }
const preferred = widest + 4; const preferred = widest + 4;
const maxWidth = Math.min( const maxWidth = Math.min(
UI_DIMENSIONS.previewLeftMaxWidth, UI_DIMENSIONS.previewLeftMaxWidth,
Math.floor(width * UI_DIMENSIONS.previewLeftRatio), Math.floor(width * UI_DIMENSIONS.previewLeftRatio)
); );
return clamp( return clamp(
preferred, preferred,
UI_DIMENSIONS.previewLeftMinWidth, UI_DIMENSIONS.previewLeftMinWidth,
Math.max(UI_DIMENSIONS.previewLeftMinWidth, maxWidth), Math.max(UI_DIMENSIONS.previewLeftMinWidth, maxWidth)
); );
} }
export function renderFooterText( export function renderFooterText(
mode: "input" | "note" | "submit" | "multi" | "default", mode: "input" | "note" | "submit" | "multi" | "default"
) { ) {
switch (mode) { switch (mode) {
case "input": case "input":
+54 -38
View File
@@ -1,13 +1,13 @@
import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui"; import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
import { isOptionSelected } from "../state/answers.ts";
import { import {
getAnswer, getAnswer,
getOptionNote, getOptionNote,
getQuestionNote, getQuestionNote,
isInputOpenForQuestion, isInputOpenForQuestion,
isOptionNoteOpen, isOptionNoteOpen,
isOptionSelected,
isQuestionNoteOpen, isQuestionNoteOpen,
} from "../state.ts"; } from "../state/selectors.ts";
import type { AskDisplayOption } from "../types.ts"; import type { AskDisplayOption } from "../types.ts";
import { UI_DIMENSIONS, UI_TEXT } from "./constants.ts"; import { UI_DIMENSIONS, UI_TEXT } from "./constants.ts";
import { import {
@@ -48,15 +48,15 @@ function renderQuestionNote(context: QuestionRenderContext) {
theme, theme,
"accent", "accent",
" ", " ",
" ", " "
); );
renderEditorBlock({ renderEditorBlock({
lines, lines,
editorLines: editor.render( editorLines: editor.render(
Math.max( Math.max(
UI_DIMENSIONS.editorMinWidth, UI_DIMENSIONS.editorMinWidth,
width - UI_DIMENSIONS.editorContentPadding, width - UI_DIMENSIONS.editorContentPadding
), )
), ),
width, width,
theme, theme,
@@ -80,7 +80,7 @@ function renderQuestionNote(context: QuestionRenderContext) {
theme, theme,
"muted", "muted",
" ", " ",
" ", " "
); );
lines.push(""); lines.push("");
} }
@@ -95,7 +95,7 @@ function renderStandardQuestion(context: QuestionRenderContext) {
function renderStandardOption( function renderStandardOption(
context: QuestionRenderContext, context: QuestionRenderContext,
option: AskDisplayOption, option: AskDisplayOption,
index: number, index: number
) { ) {
const { lines, state, question, theme, width } = context; const { lines, state, question, theme, width } = context;
const answer = getAnswer(state, question.id); const answer = getAnswer(state, question.id);
@@ -106,7 +106,11 @@ function renderStandardOption(
const pointer = selected ? "❯ " : " "; const pointer = selected ? "❯ " : " ";
const prefix = getOptionPrefix(question.type, option, isAnsweredOption); const prefix = getOptionPrefix(question.type, option, isAnsweredOption);
const optionColor = getOptionColor(isAnsweredOption, selected); 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); renderInteractiveCustomOption(context, index, pointer, prefix, optionColor);
return; return;
} }
@@ -118,7 +122,7 @@ function renderStandardOption(
theme, theme,
optionColor, optionColor,
pointer, pointer,
" ".repeat(visibleWidth(pointer)), " ".repeat(visibleWidth(pointer))
); );
renderOptionMeta({ renderOptionMeta({
...context, ...context,
@@ -143,7 +147,7 @@ function renderPreviewQuestion(context: QuestionRenderContext) {
theme, theme,
width, width,
answer, answer,
selectedOption, selectedOption
); );
} else { } else {
renderStackedPreviewLayout( renderStackedPreviewLayout(
@@ -153,7 +157,7 @@ function renderPreviewQuestion(context: QuestionRenderContext) {
theme, theme,
width, width,
answer, answer,
selectedOption, selectedOption
); );
} }
@@ -173,19 +177,19 @@ function renderWidePreviewLayout(
theme: Theme, theme: Theme,
width: number, width: number,
answer: ReturnType<typeof getAnswer>, answer: ReturnType<typeof getAnswer>,
selectedOption: AskDisplayOption | undefined, selectedOption: AskDisplayOption | undefined
) { ) {
const leftWidth = measurePreviewLeftWidth(options, width); const leftWidth = measurePreviewLeftWidth(options, width);
const rightWidth = Math.max( const rightWidth = Math.max(
UI_DIMENSIONS.previewMinRightWidth, UI_DIMENSIONS.previewMinRightWidth,
width - leftWidth - 2, width - leftWidth - 2
); );
const leftPane = renderPreviewOptionList( const leftPane = renderPreviewOptionList(
state, state,
options, options,
theme, theme,
leftWidth, leftWidth,
answer, answer
); );
const rightPane = renderPreviewPaneContent(selectedOption, theme, rightWidth); const rightPane = renderPreviewPaneContent(selectedOption, theme, rightWidth);
for (const line of mergeColumns(leftPane, rightPane, leftWidth, width)) { for (const line of mergeColumns(leftPane, rightPane, leftWidth, width)) {
@@ -200,7 +204,7 @@ function renderStackedPreviewLayout(
theme: Theme, theme: Theme,
width: number, width: number,
answer: ReturnType<typeof getAnswer>, answer: ReturnType<typeof getAnswer>,
selectedOption: AskDisplayOption | undefined, selectedOption: AskDisplayOption | undefined
) { ) {
renderPreviewOptionList(state, options, theme, width, answer).forEach(add); renderPreviewOptionList(state, options, theme, width, answer).forEach(add);
add(""); add("");
@@ -212,7 +216,7 @@ function renderPreviewOptionList(
options: QuestionRenderContext["options"], options: QuestionRenderContext["options"],
theme: Theme, theme: Theme,
width: number, width: number,
answer: ReturnType<typeof getAnswer>, answer: ReturnType<typeof getAnswer>
): string[] { ): string[] {
const lines: string[] = []; const lines: string[] = [];
for (let index = 0; index < options.length; index++) { for (let index = 0; index < options.length; index++) {
@@ -228,7 +232,7 @@ function renderPreviewOptionList(
theme, theme,
getOptionColor(isAnsweredOption, selected), getOptionColor(isAnsweredOption, selected),
selected ? "❯ " : " ", selected ? "❯ " : " ",
" ", " "
); );
if (option.description) { if (option.description) {
pushWrappedText( pushWrappedText(
@@ -238,7 +242,7 @@ function renderPreviewOptionList(
theme, theme,
"muted", "muted",
" ", " ",
" ", " "
); );
} }
} }
@@ -251,7 +255,7 @@ function renderOptionMeta(
answer: ReturnType<typeof getAnswer>; answer: ReturnType<typeof getAnswer>;
option: AskDisplayOption; option: AskDisplayOption;
selected: boolean; selected: boolean;
}, }
) { ) {
const { lines, option, theme, width } = context; const { lines, option, theme, width } = context;
if (option.description) { if (option.description) {
@@ -262,7 +266,7 @@ function renderOptionMeta(
theme, theme,
"muted", "muted",
" ", " ",
" ", " "
); );
} }
renderOptionEditorOrNote(context); renderOptionEditorOrNote(context);
@@ -312,7 +316,7 @@ function getOptionDetailRenderState(context: OptionDetailRenderContext) {
} }
function hasOptionDetailContent( function hasOptionDetailContent(
renderState: ReturnType<typeof getOptionDetailRenderState>, renderState: ReturnType<typeof getOptionDetailRenderState>
) { ) {
return !!( return !!(
renderState.noteOpen || renderState.noteOpen ||
@@ -324,15 +328,27 @@ function hasOptionDetailContent(
function renderOptionEditor( function renderOptionEditor(
context: OptionDetailRenderContext, context: OptionDetailRenderContext,
renderState: ReturnType<typeof getOptionDetailRenderState>, renderState: ReturnType<typeof getOptionDetailRenderState>
): boolean { ): boolean {
const { lines, editor, width, theme } = context; const { lines, editor, width, theme } = context;
if (renderState.noteOpen) { if (renderState.noteOpen) {
renderIndentedEditor(lines, editor, width, theme, UI_TEXT.editorPlaceholderNote); renderIndentedEditor(
lines,
editor,
width,
theme,
UI_TEXT.editorPlaceholderNote
);
return true; return true;
} }
if (renderState.inputOpen) { if (renderState.inputOpen) {
renderIndentedEditor(lines, editor, width, theme, UI_TEXT.editorPlaceholderInput); renderIndentedEditor(
lines,
editor,
width,
theme,
UI_TEXT.editorPlaceholderInput
);
return true; return true;
} }
return false; return false;
@@ -340,10 +356,10 @@ function renderOptionEditor(
function renderOptionContent( function renderOptionContent(
context: OptionDetailRenderContext, context: OptionDetailRenderContext,
renderState: ReturnType<typeof getOptionDetailRenderState>, renderState: ReturnType<typeof getOptionDetailRenderState>
): boolean { ): boolean {
const { lines, option, theme, width } = context; const { lines, option, theme, width } = context;
if (!option?.isCustomOption || !renderState.customText) { if (!(option?.isCustomOption && renderState.customText)) {
return false; return false;
} }
pushWrappedText( pushWrappedText(
@@ -353,14 +369,14 @@ function renderOptionContent(
theme, theme,
"muted", "muted",
" ", " ",
" ", " "
); );
return true; return true;
} }
function renderSavedOptionNote( function renderSavedOptionNote(
context: OptionDetailRenderContext, context: OptionDetailRenderContext,
optionNote: string | undefined, optionNote: string | undefined
) { ) {
if (!optionNote) { if (!optionNote) {
return; return;
@@ -373,7 +389,7 @@ function renderSavedOptionNote(
theme, theme,
"muted", "muted",
" ", " ",
" ", " "
); );
} }
@@ -382,15 +398,15 @@ function renderIndentedEditor(
editor: QuestionRenderContext["editor"], editor: QuestionRenderContext["editor"],
width: number, width: number,
theme: Theme, theme: Theme,
placeholder: string, placeholder: string
) { ) {
renderEditorBlock({ renderEditorBlock({
lines, lines,
editorLines: editor.render( editorLines: editor.render(
Math.max( Math.max(
UI_DIMENSIONS.editorMinWidth, UI_DIMENSIONS.editorMinWidth,
width - UI_DIMENSIONS.editorIndentedPadding, width - UI_DIMENSIONS.editorIndentedPadding
), )
), ),
width, width,
theme, theme,
@@ -404,7 +420,7 @@ function renderIndentedEditor(
function getOptionPrefix( function getOptionPrefix(
questionType: QuestionRenderContext["question"]["type"], questionType: QuestionRenderContext["question"]["type"],
option: AskDisplayOption, option: AskDisplayOption,
isAnsweredOption: boolean, isAnsweredOption: boolean
): string { ): string {
if (questionType !== "multi" || option.isCustomOption) { if (questionType !== "multi" || option.isCustomOption) {
return ""; return "";
@@ -417,13 +433,13 @@ function renderInteractiveCustomOption(
index: number, index: number,
pointer: string, pointer: string,
prefix: string, prefix: string,
optionColor: ReturnType<typeof getOptionColor>, optionColor: ReturnType<typeof getOptionColor>
) { ) {
const { lines, editor, theme, width } = context; const { lines, editor, theme, width } = context;
const label = `${index + 1}. ${prefix}`; const label = `${index + 1}. ${prefix}`;
const availableWidth = Math.max( const availableWidth = Math.max(
1, 1,
width - visibleWidth(pointer) - visibleWidth(label), width - visibleWidth(pointer) - visibleWidth(label)
); );
const text = editor.getText().replace(/\n/g, " "); const text = editor.getText().replace(/\n/g, " ");
const hasText = text.trim().length > 0; const hasText = text.trim().length > 0;
@@ -435,10 +451,10 @@ function renderInteractiveCustomOption(
inputText, inputText,
availableWidth, availableWidth,
theme, theme,
inputColor, inputColor
)}`, )}`,
width, width
), )
); );
} }
+12 -12
View File
@@ -1,6 +1,6 @@
import { truncateToWidth } from "@mariozechner/pi-tui"; import { truncateToWidth } from "@mariozechner/pi-tui";
import { SUBMIT_CHOICES } from "../constants.ts"; 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 type { AskResult, AskState } from "../types.ts";
import { UI_TEXT } from "./constants.ts"; import { UI_TEXT } from "./constants.ts";
import { pushWrappedText } from "./render-helpers.ts"; import { pushWrappedText } from "./render-helpers.ts";
@@ -10,7 +10,7 @@ export function renderSubmitScreen(
lines: string[], lines: string[],
state: AskState, state: AskState,
theme: Theme, theme: Theme,
width: number, width: number
) { ) {
pushWrappedText(lines, UI_TEXT.reviewTitle, width, theme, "accent", " ", " "); pushWrappedText(lines, UI_TEXT.reviewTitle, width, theme, "accent", " ", " ");
lines.push(""); lines.push("");
@@ -22,7 +22,7 @@ export function renderSubmitScreen(
question, question,
submittedAnswers[question.id], submittedAnswers[question.id],
theme, theme,
width, width
); );
} }
@@ -34,7 +34,7 @@ export function renderSubmitScreen(
theme, theme,
"success", "success",
" ", " ",
" ", " "
); );
lines.push(""); lines.push("");
renderSubmitActions(lines, state, theme, width); renderSubmitActions(lines, state, theme, width);
@@ -45,12 +45,12 @@ function renderSubmittedQuestion(
question: AskState["questions"][number], question: AskState["questions"][number],
answer: AskResult["answers"][string] | undefined, answer: AskResult["answers"][string] | undefined,
theme: Theme, theme: Theme,
width: number, width: number
) { ) {
pushWrappedText(lines, question.prompt, width, theme, "muted", " ● ", " "); pushWrappedText(lines, question.prompt, width, theme, "muted", " ● ", " ");
if (!answer) { if (!answer) {
lines.push( lines.push(
truncateToWidth(` ${theme.fg("warning", UI_TEXT.unanswered)}`, width), truncateToWidth(` ${theme.fg("warning", UI_TEXT.unanswered)}`, width)
); );
return; return;
} }
@@ -62,7 +62,7 @@ function renderSubmittedAnswer(
lines: string[], lines: string[],
answer: AskResult["answers"][string], answer: AskResult["answers"][string],
theme: Theme, theme: Theme,
width: number, width: number
) { ) {
const answerText = answer.customText ?? answer.labels.join(", "); const answerText = answer.customText ?? answer.labels.join(", ");
if (answerText) { if (answerText) {
@@ -73,7 +73,7 @@ function renderSubmittedAnswer(
theme, theme,
answer.customText ? "text" : "success", answer.customText ? "text" : "success",
" ", " ",
" ", " "
); );
} }
if (answer.note) { if (answer.note) {
@@ -84,7 +84,7 @@ function renderSubmittedAnswer(
theme, theme,
"muted", "muted",
" ", " ",
" ", " "
); );
} }
for (let index = 0; index < answer.values.length; index++) { for (let index = 0; index < answer.values.length; index++) {
@@ -101,7 +101,7 @@ function renderSubmittedAnswer(
theme, theme,
"muted", "muted",
" ", " ",
" ", " "
); );
} }
} }
@@ -110,7 +110,7 @@ function renderSubmitActions(
lines: string[], lines: string[],
state: AskState, state: AskState,
theme: Theme, theme: Theme,
width: number, width: number
) { ) {
for (let index = 0; index < SUBMIT_CHOICES.length; index++) { for (let index = 0; index < SUBMIT_CHOICES.length; index++) {
const selected = index === state.activeSubmitActionIndex; const selected = index === state.activeSubmitActionIndex;
@@ -122,7 +122,7 @@ function renderSubmitActions(
theme, theme,
selected ? "accent" : "text", selected ? "accent" : "text",
prefix, prefix,
prefix, prefix
); );
} }
} }
+9 -9
View File
@@ -4,30 +4,30 @@ import type {
getAnswer, getAnswer,
getCurrentQuestion, getCurrentQuestion,
getRenderableOptions, getRenderableOptions,
} from "../state.ts"; } from "../state/selectors.ts";
import type { AskDisplayOption, AskState } from "../types.ts"; import type { AskDisplayOption, AskState } from "../types.ts";
export type Theme = ExtensionContext["ui"]["theme"]; export type Theme = ExtensionContext["ui"]["theme"];
export interface QuestionRenderContext { export interface QuestionRenderContext {
editor: Editor;
lines: string[]; lines: string[];
state: AskState;
question: NonNullable<ReturnType<typeof getCurrentQuestion>>;
options: ReturnType<typeof getRenderableOptions>; options: ReturnType<typeof getRenderableOptions>;
question: NonNullable<ReturnType<typeof getCurrentQuestion>>;
state: AskState;
theme: Theme; theme: Theme;
width: number; width: number;
editor: Editor;
} }
export interface OptionDetailRenderContext { export interface OptionDetailRenderContext {
lines: string[];
state: AskState;
questionId: string;
answer: ReturnType<typeof getAnswer>; answer: ReturnType<typeof getAnswer>;
editor: Editor;
lines: string[];
option: AskDisplayOption | undefined; option: AskDisplayOption | undefined;
questionId: string;
selected?: boolean;
state: AskState;
theme: Theme; theme: Theme;
width: number; width: number;
editor: Editor;
selected?: boolean;
withGap?: boolean; withGap?: boolean;
} }
+1 -1
View File
@@ -2,7 +2,7 @@ import {
getCurrentQuestion, getCurrentQuestion,
getRenderableOptions, getRenderableOptions,
isSubmitTab, isSubmitTab,
} from "../state.ts"; } from "../state/selectors.ts";
import type { AskState } from "../types.ts"; import type { AskState } from "../types.ts";
import { renderFrameFooter, renderFrameHeader } from "./render-frame.ts"; import { renderFrameFooter, renderFrameHeader } from "./render-frame.ts";
import { renderQuestionScreen } from "./render-question.ts"; import { renderQuestionScreen } from "./render-question.ts";
+10 -3
View File
@@ -1,6 +1,10 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import test from "node:test"; import test from "node:test";
import { applyNumberShortcut, createInitialState, enterQuestionNoteMode } from "../src/state.ts"; import { createInitialState } from "../src/state/create.ts";
import {
applyNumberShortcut,
enterQuestionNoteMode,
} from "../src/state/transitions.ts";
import { getInputCommand } from "../src/ui/input.ts"; import { getInputCommand } from "../src/ui/input.ts";
function inputState() { function inputState() {
@@ -30,7 +34,8 @@ test("editing views let up and down move navigation", () => {
}); });
test("note editing views let up and down move navigation", () => { test("note editing views let up and down move navigation", () => {
const state = enterQuestionNoteMode(createInitialState({ const state = enterQuestionNoteMode(
createInitialState({
questions: [ questions: [
{ {
id: "q1", id: "q1",
@@ -38,7 +43,9 @@ test("note editing views let up and down move navigation", () => {
options: [{ value: "a", label: "A" }], options: [{ value: "a", label: "A" }],
}, },
], ],
}), "q1"); }),
"q1"
);
assert.deepEqual(getInputCommand(state, "\x1b[A"), { assert.deepEqual(getInputCommand(state, "\x1b[A"), {
kind: "editMoveOption", kind: "editMoveOption",
+8 -4
View File
@@ -2,6 +2,10 @@ import assert from "node:assert/strict";
import test from "node:test"; import test from "node:test";
import { renderEditorBlock } from "../src/ui/render-helpers.ts"; import { renderEditorBlock } from "../src/ui/render-helpers.ts";
const ABC_PATTERN = /abc/;
const BORDER_PATTERN = /┌|└/;
const TYPE_YOUR_PATTERN = /Type your/;
function mockTheme() { function mockTheme() {
return { return {
fg(color: string, text: string) { fg(color: string, text: string) {
@@ -25,8 +29,8 @@ test("renderEditorBlock strips editor borders", () => {
}); });
assert.equal(lines.length, 1); assert.equal(lines.length, 1);
assert.match(lines[0], /abc/); assert.match(lines[0], ABC_PATTERN);
assert.doesNotMatch(lines[0], /┌|└/); assert.doesNotMatch(lines[0], BORDER_PATTERN);
}); });
test("renderEditorBlock shows a muted placeholder when empty", () => { test("renderEditorBlock shows a muted placeholder when empty", () => {
@@ -43,6 +47,6 @@ test("renderEditorBlock shows a muted placeholder when empty", () => {
}); });
assert.equal(lines.length, 1); assert.equal(lines.length, 1);
assert.match(lines[0], /Type your/); assert.match(lines[0], TYPE_YOUR_PATTERN);
assert.doesNotMatch(lines[0], /┌|└/); assert.doesNotMatch(lines[0], BORDER_PATTERN);
}); });
+8 -7
View File
@@ -1,7 +1,8 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import test from "node:test"; import test from "node:test";
import { applyNumberShortcut, createInitialState } from "../src/state.ts"; import { createInitialState } from "../src/state/create.ts";
import { getRenderableOptions } from "../src/state/selectors.ts"; import { getRenderableOptions } from "../src/state/selectors.ts";
import { applyNumberShortcut } from "../src/state/transitions.ts";
import { renderQuestionScreen } from "../src/ui/render-question.ts"; import { renderQuestionScreen } from "../src/ui/render-question.ts";
function mockTheme() { function mockTheme() {
@@ -42,13 +43,13 @@ test("custom option stays labeled before selection", () => {
const lines: string[] = []; const lines: string[] = [];
renderQuestionScreen({ renderQuestionScreen({
editor: mockEditor(),
lines, lines,
state,
question: state.questions[0],
options: getRenderableOptions(state.questions[0]), options: getRenderableOptions(state.questions[0]),
question: state.questions[0],
state,
theme: mockTheme(), theme: mockTheme(),
width: 80, width: 80,
editor: mockEditor(),
}); });
assert(lines.some((line) => line.includes("Type your own"))); assert(lines.some((line) => line.includes("Type your own")));
@@ -69,13 +70,13 @@ test("selected custom option becomes inline input", () => {
const lines: string[] = []; const lines: string[] = [];
renderQuestionScreen({ renderQuestionScreen({
editor: mockEditor(""),
lines, lines,
state,
question: state.questions[0],
options: getRenderableOptions(state.questions[0]), options: getRenderableOptions(state.questions[0]),
question: state.questions[0],
state,
theme: mockTheme(), theme: mockTheme(),
width: 80, width: 80,
editor: mockEditor(""),
}); });
assert(lines.some((line) => line.includes("Type your answer..."))); assert(lines.some((line) => line.includes("Type your answer...")));
+6 -7
View File
@@ -1,16 +1,15 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import test from "node:test"; import test from "node:test";
import { renderResultText } from "../src/result.ts"; import { renderResultText } from "../src/result.ts";
import { createInitialState } from "../src/state/create.ts";
import { summarizeResult, toAskResult } from "../src/state/result.ts";
import { import {
applyNumberShortcut, applyNumberShortcut,
createInitialState,
enterOptionNoteMode, enterOptionNoteMode,
enterQuestionNoteMode, enterQuestionNoteMode,
saveNote, saveNote,
submitCustomAnswer, submitCustomAnswer,
summarizeResult, } from "../src/state/transitions.ts";
toAskResult,
} from "../src/state.ts";
test("summarizeResult formats selected answers", () => { test("summarizeResult formats selected answers", () => {
let state = createInitialState({ let state = createInitialState({
@@ -53,7 +52,7 @@ test("summaries include custom text answers", () => {
assert.equal(summarizeResult(result), "Notes: Please include examples"); assert.equal(summarizeResult(result), "Notes: Please include examples");
assert.equal( assert.equal(
renderResultText(result), renderResultText(result),
"✓ Notes: (wrote) Please include examples", "✓ Notes: (wrote) Please include examples"
); );
}); });
@@ -85,7 +84,7 @@ test("summaries and result text include question and selected option notes", ()
"Framework: Vue", "Framework: Vue",
"Framework note: Need SSR support", "Framework note: Need SSR support",
"Framework / Vue note: Team has prior experience", "Framework / Vue note: Team has prior experience",
].join("\n"), ].join("\n")
); );
assert.equal( assert.equal(
renderResultText(result), renderResultText(result),
@@ -93,7 +92,7 @@ test("summaries and result text include question and selected option notes", ()
"✓ Framework: Vue", "✓ Framework: Vue",
" note: Need SSR support", " note: Need SSR support",
" Vue note: Team has prior experience", " Vue note: Team has prior experience",
].join("\n"), ].join("\n")
); );
}); });
+11 -11
View File
@@ -1,21 +1,21 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import test from "node:test"; import test from "node:test";
import { createInitialState } from "../src/state/create.ts";
import { toAskResult } from "../src/state/result.ts";
import { getRenderableOptions } from "../src/state/selectors.ts";
import { import {
applyNumberShortcut, applyNumberShortcut,
cancelFlow, cancelFlow,
confirmCurrentSelection, confirmCurrentSelection,
createInitialState,
enterOptionNoteMode, enterOptionNoteMode,
enterQuestionNoteMode, enterQuestionNoteMode,
getRenderableOptions,
moveOption, moveOption,
moveTab, moveTab,
saveCustomAnswer, saveCustomAnswer,
saveNote, saveNote,
submitCustomAnswer, submitCustomAnswer,
toAskResult,
toggleCurrentMultiOption, toggleCurrentMultiOption,
} from "../src/state.ts"; } from "../src/state/transitions.ts";
import type { AskParams } from "../src/types.ts"; import type { AskParams } from "../src/types.ts";
function sampleParams(): AskParams { function sampleParams(): AskParams {
@@ -61,11 +61,11 @@ test("normalize defaults via initial state", () => {
assert.equal(state.questions[0].required, true); assert.equal(state.questions[0].required, true);
assert.equal( assert.equal(
getRenderableOptions(state.questions[0]).at(-1)?.label, getRenderableOptions(state.questions[0]).at(-1)?.label,
"Type your own", "Type your own"
); );
assert.equal( assert.equal(
getRenderableOptions(state.questions[0]).at(-1)?.isCustomOption, getRenderableOptions(state.questions[0]).at(-1)?.isCustomOption,
true, true
); );
}); });
@@ -86,7 +86,7 @@ test("preview questions keep their type and option previews", () => {
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)?.isCustomOption, 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);
}); });
@@ -106,14 +106,14 @@ test("multi-select space toggles current option and enter advances to submit", (
state = toggleCurrentMultiOption(state); state = toggleCurrentMultiOption(state);
assert.deepEqual( assert.deepEqual(
state.answers.fe.selected.map((selection) => selection.label), state.answers.fe.selected.map((selection) => selection.label),
["React"], ["React"]
); );
state = moveOption(state, 1); state = moveOption(state, 1);
state = toggleCurrentMultiOption(state); state = toggleCurrentMultiOption(state);
assert.deepEqual( assert.deepEqual(
state.answers.fe.selected.map((selection) => selection.label), state.answers.fe.selected.map((selection) => selection.label),
["React", "Vue"], ["React", "Vue"]
); );
state = confirmCurrentSelection(state); state = confirmCurrentSelection(state);
@@ -254,13 +254,13 @@ test("option notes can exist before selection but only selected option notes are
state = toggleCurrentMultiOption(state); state = toggleCurrentMultiOption(state);
assert.deepEqual( assert.deepEqual(
state.answers.q1.selected.map((selection) => selection.value), state.answers.q1.selected.map((selection) => selection.value),
["react"], ["react"]
); );
state = moveOption(state, 1); state = moveOption(state, 1);
state = toggleCurrentMultiOption(state); state = toggleCurrentMultiOption(state);
assert.deepEqual( assert.deepEqual(
state.answers.q1.selected.map((selection) => selection.value), state.answers.q1.selected.map((selection) => selection.value),
["react", "vue"], ["react", "vue"]
); );
assert.deepEqual(toAskResult(state).answers.q1.optionNotes, { assert.deepEqual(toAskResult(state).answers.q1.optionNotes, {
vue: "maybe later", vue: "maybe later",