chore(ask): reorganize docs and fix repo checks
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
extends: ["@commitlint/config-conventional"],
|
extends: ["@commitlint/config-conventional"],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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
@@ -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 +0,0 @@
|
|||||||
export { runAskFlow } from "./ui/controller.ts";
|
|
||||||
+5
-4
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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,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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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...")));
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user