diff --git a/.vscode/settings.json b/.vscode/settings.json index 67949f7..406f71b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,54 +1,54 @@ { - "editor.defaultFormatter": "esbenp.prettier-vscode", - "editor.formatOnPaste": true, - "editor.formatOnSave": true, - "emmet.showExpandedAbbreviation": "never", - "js/ts.tsdk.path": "node_modules/typescript/lib", - "js/ts.tsdk.promptToUseWorkspaceVersion": true, - "[css]": { - "editor.defaultFormatter": "biomejs.biome" - }, - "[graphql]": { - "editor.defaultFormatter": "biomejs.biome" - }, - "[html]": { - "editor.defaultFormatter": "biomejs.biome" - }, - "[javascript]": { - "editor.defaultFormatter": "biomejs.biome" - }, - "[javascriptreact]": { - "editor.defaultFormatter": "biomejs.biome" - }, - "[json]": { - "editor.defaultFormatter": "biomejs.biome" - }, - "[jsonc]": { - "editor.defaultFormatter": "biomejs.biome" - }, - "[markdown]": { - "editor.defaultFormatter": "biomejs.biome" - }, - "[mdx]": { - "editor.defaultFormatter": "biomejs.biome" - }, - "[svelte]": { - "editor.defaultFormatter": "biomejs.biome" - }, - "[typescript]": { - "editor.defaultFormatter": "biomejs.biome" - }, - "[typescriptreact]": { - "editor.defaultFormatter": "biomejs.biome" - }, - "[vue]": { - "editor.defaultFormatter": "biomejs.biome" - }, - "[yaml]": { - "editor.defaultFormatter": "biomejs.biome" - }, - "editor.codeActionsOnSave": { - "source.fixAll.biome": "explicit", - "source.organizeImports.biome": "explicit" - } + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnPaste": true, + "editor.formatOnSave": true, + "emmet.showExpandedAbbreviation": "never", + "js/ts.tsdk.path": "node_modules/typescript/lib", + "js/ts.tsdk.promptToUseWorkspaceVersion": true, + "[css]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[graphql]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[html]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[javascript]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[javascriptreact]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[json]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[jsonc]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[markdown]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[mdx]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[svelte]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[typescript]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[typescriptreact]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[vue]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[yaml]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "editor.codeActionsOnSave": { + "source.fixAll.biome": "explicit", + "source.organizeImports.biome": "explicit" + } } diff --git a/.zed/settings.json b/.zed/settings.json index 0e11577..e5b6818 100644 --- a/.zed/settings.json +++ b/.zed/settings.json @@ -1,91 +1,91 @@ { - "format_on_save": "on", - "ensure_final_newline_on_save": true, - "remove_trailing_whitespace_on_save": true, - "languages": { - "TypeScript": { - "formatter": { - "external": { - "command": "pnpm", - "arguments": [ - "exec", - "biome", - "format", - "--stdin-file-path", - "{buffer_path}" - ] - }, - "language_server": { - "name": "biome" - } - }, - "hard_tabs": true, - "code_actions_on_format": { - "source.fixAll.biome": true, - "source.organizeImports.biome": true - } - }, - "JSON": { - "formatter": { - "external": { - "command": "pnpm", - "arguments": [ - "exec", - "biome", - "format", - "--stdin-file-path", - "{buffer_path}" - ] - } - } - }, - "JSONC": { - "formatter": { - "external": { - "command": "pnpm", - "arguments": [ - "exec", - "biome", - "format", - "--stdin-file-path", - "{buffer_path}" - ] - } - } - }, - "JavaScript": { - "code_actions_on_format": { - "source.fixAll.biome": true, - "source.organizeImports.biome": true - }, - "formatter": { - "language_server": { - "name": "biome" - } - } - }, - "TSX": { - "code_actions_on_format": { - "source.fixAll.biome": true, - "source.organizeImports.biome": true - }, - "formatter": { - "language_server": { - "name": "biome" - } - } - } - }, - "formatter": "language_server", - "lsp": { - "typescript-language-server": { - "settings": { - "typescript": { - "preferences": { - "includePackageJsonAutoImports": "on" - } - } - } - } - } + "format_on_save": "on", + "ensure_final_newline_on_save": true, + "remove_trailing_whitespace_on_save": true, + "languages": { + "TypeScript": { + "formatter": { + "external": { + "command": "pnpm", + "arguments": [ + "exec", + "biome", + "format", + "--stdin-file-path", + "{buffer_path}" + ] + }, + "language_server": { + "name": "biome" + } + }, + "hard_tabs": true, + "code_actions_on_format": { + "source.fixAll.biome": true, + "source.organizeImports.biome": true + } + }, + "JSON": { + "formatter": { + "external": { + "command": "pnpm", + "arguments": [ + "exec", + "biome", + "format", + "--stdin-file-path", + "{buffer_path}" + ] + } + } + }, + "JSONC": { + "formatter": { + "external": { + "command": "pnpm", + "arguments": [ + "exec", + "biome", + "format", + "--stdin-file-path", + "{buffer_path}" + ] + } + } + }, + "JavaScript": { + "code_actions_on_format": { + "source.fixAll.biome": true, + "source.organizeImports.biome": true + }, + "formatter": { + "language_server": { + "name": "biome" + } + } + }, + "TSX": { + "code_actions_on_format": { + "source.fixAll.biome": true, + "source.organizeImports.biome": true + }, + "formatter": { + "language_server": { + "name": "biome" + } + } + } + }, + "formatter": "language_server", + "lsp": { + "typescript-language-server": { + "settings": { + "typescript": { + "preferences": { + "includePackageJsonAutoImports": "on" + } + } + } + } + } } diff --git a/AGENTS.md b/AGENTS.md index 61e6380..da6acf1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. -Primary deliverables in this repo: +Primary deliverables: -- research and comparison docs: - - `RESEARCH.md` - - `FINDINGS.md` - - `SPEC.md` -- TypeScript extension implementation in `src/` -- tests for core logic in `tests/` +- `src/` — implementation +- `tests/` — behavior coverage +- `docs/` — contract and architecture -## 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 `ctx.ui.custom()` for complex interactive flows - - keep result shapes compatible with normal pi tool results + - prefer `ctx.ui.custom()` for complex flows + - keep results compatible with normal pi tool results 2. **testable architecture** - - keep state and decision logic in plain TypeScript modules + - keep state logic in plain TypeScript modules - 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** - - keep implementation consistent with `SPEC.md` - - if behavior changes materially, update `README.md` and `SPEC.md` - - if new harness findings are added, update `RESEARCH.md` and/or `FINDINGS.md` +3. **small docs, clear code** + - keep implementation consistent with `docs/contract.md` + - update docs when behavior changes materially + - keep docs focused on contracts and boundaries, not line-by-line explanations ## Tech stack @@ -92,12 +89,12 @@ pi -e ./src/index.ts ### 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 -- `SPEC.md`: intended design and UX contract -- `RESEARCH.md`: harness comparison notes -- `FINDINGS.md`: synthesized conclusions from source research +- `README.md`: install, run, and use +- `docs/README.md`: docs index +- `docs/contract.md`: external behavior and UX guarantees +- `docs/architecture.md`: module boundaries and invariants ## Coding expectations @@ -118,13 +115,13 @@ Keep docs practical and synced with the implementation. ## 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` -- architecture/contract changes -> `SPEC.md` -- comparative harness findings -> `RESEARCH.md` / `FINDINGS.md` +- usage changes -> `README.md` +- contract or UX changes -> `docs/contract.md` +- module boundary changes -> `docs/architecture.md` -## Preferred workflow for agents +## Preferred workflow 1. read the relevant docs first 2. inspect the existing implementation before refactoring diff --git a/README.md b/README.md index e255758..0257c19 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,16 @@ # 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 -- `tests/` — TypeScript tests for core logic/result formatting +- `tests/` — behavior-focused tests +- `docs/` — small docs set for contract and architecture -## Usage +## Getting started ### Requirements @@ -35,7 +27,7 @@ Run pi with the extension entrypoint: 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 @@ -87,10 +79,10 @@ Note: some Zed settings such as global extension auto-install and autosave are n ## 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 `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 @@ -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 - single-select questions @@ -187,6 +179,7 @@ The returned `details.answers[questionId]` object may include: { values: string[] labels: string[] + indices: number[] customText?: string note?: string // question-level note optionNotes?: Record // 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. ``` +## 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 -Prototype implemented in TypeScript and test-covered for core logic. +Implemented in TypeScript with behavior-focused test coverage. diff --git a/biome.json b/biome.json index 1bbb1c8..5f5d7a7 100644 --- a/biome.json +++ b/biome.json @@ -58,9 +58,7 @@ }, "overrides": [ { - "includes": [ - "tests/**/*.ts" - ], + "includes": ["tests/**/*.ts"], "linter": { "rules": { "complexity": { @@ -70,9 +68,7 @@ } }, { - "includes": [ - "src/index.ts" - ], + "includes": ["src/index.ts"], "linter": { "rules": { "style": { @@ -90,7 +86,5 @@ } } }, - "extends": [ - "ultracite/biome/core" - ] + "extends": ["ultracite/biome/core"] } diff --git a/commitlint.config.cjs b/commitlint.config.cjs index 69b4242..a9020a7 100644 --- a/commitlint.config.cjs +++ b/commitlint.config.cjs @@ -1,3 +1,5 @@ +"use strict"; + module.exports = { - extends: ["@commitlint/config-conventional"], + extends: ["@commitlint/config-conventional"], }; diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..c6bbabd --- /dev/null +++ b/docs/README.md @@ -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. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..fe167c5 --- /dev/null +++ b/docs/architecture.md @@ -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. diff --git a/docs/contract.md b/docs/contract.md new file mode 100644 index 0000000..febbd1c --- /dev/null +++ b/docs/contract.md @@ -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; + } + >; + }; +} +``` + +## 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 diff --git a/lefthook.yml b/lefthook.yml index 50c0cdb..c8c5242 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -7,7 +7,7 @@ prepare-commit-msg: commands: commitzen: 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: LEFTHOOK: "0" diff --git a/src/ask-component.ts b/src/ask-component.ts deleted file mode 100644 index 61a081a..0000000 --- a/src/ask-component.ts +++ /dev/null @@ -1 +0,0 @@ -export { runAskFlow } from "./ui/controller.ts"; diff --git a/src/ask-tool.ts b/src/ask-tool.ts index 4953f21..3d2549d 100644 --- a/src/ask-tool.ts +++ b/src/ask-tool.ts @@ -1,11 +1,12 @@ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import { Text, truncateToWidth } from "@mariozechner/pi-tui"; -import { runAskFlow } from "./ask-component.ts"; import { renderResultText } from "./result.ts"; import { AskParamsSchema } from "./schema.ts"; -import { createInitialState, summarizeResult, toAskResult } from "./state.ts"; +import { createInitialState } from "./state/create.ts"; +import { summarizeResult, toAskResult } from "./state/result.ts"; import type { AskParams, AskQuestionInput, AskResult } from "./types.ts"; import { UI_DIMENSIONS } from "./ui/constants.ts"; +import { runAskFlow } from "./ui/controller.ts"; export function registerAskTool(pi: ExtensionAPI) { pi.registerTool({ @@ -57,7 +58,7 @@ export function registerAskTool(pi: ExtensionAPI) { ? params.questions .map( (question: AskQuestionInput, index) => - question.label || `Q${index + 1}`, + question.label || `Q${index + 1}` ) .join(", ") : ""; @@ -66,7 +67,7 @@ export function registerAskTool(pi: ExtensionAPI) { if (labels) { text += theme.fg( "dim", - ` (${truncateToWidth(labels, UI_DIMENSIONS.callLabelTruncateWidth)})`, + ` (${truncateToWidth(labels, UI_DIMENSIONS.callLabelTruncateWidth)})` ); } return new Text(text, 0, 0); diff --git a/src/result-format.ts b/src/result-format.ts index a850fb8..5b4b0cf 100644 --- a/src/result-format.ts +++ b/src/result-format.ts @@ -2,7 +2,7 @@ import type { AskResult } from "./types.ts"; export function formatResultLines( result: AskResult, - options: { mode: "summary" | "render" }, + options: { mode: "summary" | "render" } ): string[] { const lines: string[] = []; @@ -20,7 +20,7 @@ export function formatResultLines( const questionNoteLine = formatQuestionNoteLine( question.label, answer.note, - options.mode, + options.mode ); if (questionNoteLine) { lines.push(questionNoteLine); @@ -35,11 +35,11 @@ export function formatResultLines( function formatAnswerLine( questionLabel: string, answer: AskResult["answers"][string], - mode: "summary" | "render", + mode: "summary" | "render" ): string | undefined { const answerText = answer.customText ?? answer.labels.join(", "); if (!answerText) { - return undefined; + return; } if (mode === "summary") { return `${questionLabel}: ${answerText}`; @@ -53,10 +53,10 @@ function formatAnswerLine( function formatQuestionNoteLine( questionLabel: string, note: string | undefined, - mode: "summary" | "render", + mode: "summary" | "render" ): string | undefined { if (!note) { - return undefined; + return; } return mode === "summary" ? `${questionLabel} note: ${note}` @@ -66,7 +66,7 @@ function formatQuestionNoteLine( function formatOptionNoteLines( questionLabel: string, answer: AskResult["answers"][string], - mode: "summary" | "render", + mode: "summary" | "render" ): string[] { const lines: string[] = []; for (let index = 0; index < answer.values.length; index++) { @@ -79,7 +79,7 @@ function formatOptionNoteLines( lines.push( mode === "summary" ? `${questionLabel} / ${label} note: ${note}` - : ` ${label} note: ${note}`, + : ` ${label} note: ${note}` ); } return lines; diff --git a/src/schema.ts b/src/schema.ts index 391606f..1e23ab6 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -6,32 +6,32 @@ export const AskOptionSchema = Type.Object({ }), label: Type.String({ description: "Short option title shown in the list" }), description: Type.Optional( - Type.String({ description: "Optional subtitle/description" }), + Type.String({ description: "Optional subtitle/description" }) ), preview: Type.Optional( Type.String({ description: "Optional preview text shown in the dedicated preview pane for preview questions", - }), + }) ), }); export const AskQuestionSchema = Type.Object({ id: Type.String({ description: "Unique question identifier" }), label: Type.Optional( - Type.String({ description: "Short tab label, e.g. Scope or Runtime" }), + Type.String({ description: "Short tab label, e.g. Scope or Runtime" }) ), prompt: Type.String({ description: "Full question prompt" }), type: Type.Optional( Type.Union( [Type.Literal("single"), Type.Literal("multi"), Type.Literal("preview")], - { description: "Question type" }, - ), + { description: "Question type" } + ) ), required: Type.Optional( Type.Boolean({ description: "Optional metadata flag; submit is never blocked", - }), + }) ), options: Type.Array(AskOptionSchema, { description: "Available answer options", @@ -40,7 +40,7 @@ export const AskQuestionSchema = Type.Object({ export const AskParamsSchema = Type.Object({ title: Type.Optional( - Type.String({ description: "Optional title shown above the interview" }), + Type.String({ description: "Optional title shown above the interview" }) ), questions: Type.Array(AskQuestionSchema, { description: "Questions to ask the user", diff --git a/src/state.ts b/src/state.ts deleted file mode 100644 index 79f546d..0000000 --- a/src/state.ts +++ /dev/null @@ -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[0], -) { - return createBaseState({ - title: params.title, - questions: normalizeQuestions(params), - }); -} diff --git a/src/state/answers.ts b/src/state/answers.ts index 3d39830..32da749 100644 --- a/src/state/answers.ts +++ b/src/state/answers.ts @@ -32,12 +32,12 @@ export function cloneResultAnswer(answer: AskResultAnswer): AskResultAnswer { export function toggleSelection( answer: AskStateAnswer, option: AskDisplayOption, - index: number, + index: number ): AskStateAnswer { const next = cloneAnswer(answer); - delete next.customText; + next.customText = undefined; const selectedIndex = next.selected.findIndex( - (selection) => selection.value === option.value, + (selection) => selection.value === option.value ); if (selectedIndex >= 0) { @@ -56,7 +56,7 @@ export function toggleSelection( export function setSingleSelection( answer: AskStateAnswer, option: AskDisplayOption, - index: number, + index: number ): AskStateAnswer { return { ...emptyAnswer(), @@ -74,13 +74,13 @@ export function setSingleSelection( export function saveCustomText( answer: AskStateAnswer, - rawValue: string, + rawValue: string ): AskStateAnswer { const trimmed = rawValue.trim(); const next = cloneAnswer(answer); next.selected = []; if (!trimmed) { - delete next.customText; + next.customText = undefined; return next; } next.customText = rawValue; @@ -89,21 +89,21 @@ export function saveCustomText( export function saveQuestionNote( answer: AskStateAnswer, - rawValue: string, + rawValue: string ): AskStateAnswer { const next = cloneAnswer(answer); if (rawValue.trim()) { next.note = rawValue; return next; } - delete next.note; + next.note = undefined; return next; } export function saveOptionNote( answer: AskStateAnswer, optionValue: string, - rawValue: string, + rawValue: string ): AskStateAnswer { const next = cloneAnswer(answer); const optionNotes = { ...(next.optionNotes ?? {}) }; @@ -135,10 +135,10 @@ export function isAnswerAnswered(answer?: AskStateAnswer): boolean { export function isOptionSelected( answer: AskStateAnswer | undefined, - optionValue: string, + optionValue: string ): boolean { return !!answer?.selected.some( - (selection) => selection.value === optionValue, + (selection) => selection.value === optionValue ); } @@ -157,7 +157,7 @@ export function serializeAnswer(answer: AskStateAnswer): AskResultAnswer { selection.value, answer.optionNotes?.[selection.value], ]) - .filter((entry): entry is [string, string] => !!entry[1]), + .filter((entry): entry is [string, string] => !!entry[1]) ) : undefined; diff --git a/src/state/create.ts b/src/state/create.ts new file mode 100644 index 0000000..3706f91 --- /dev/null +++ b/src/state/create.ts @@ -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), + }); +} diff --git a/src/state/normalize.ts b/src/state/normalize.ts index aeccce6..c4bce30 100644 --- a/src/state/normalize.ts +++ b/src/state/normalize.ts @@ -2,13 +2,13 @@ import type { AskParams, AskQuestion, AskQuestionInput } from "../types.ts"; export function normalizeQuestions(params: AskParams): AskQuestion[] { return params.questions.map((question, index) => - normalizeQuestion(question, index), + normalizeQuestion(question, index) ); } function normalizeQuestion( question: AskQuestionInput, - index: number, + index: number ): AskQuestion { return { ...question, diff --git a/src/state/result.ts b/src/state/result.ts index 828438e..a5d6f4c 100644 --- a/src/state/result.ts +++ b/src/state/result.ts @@ -7,10 +7,9 @@ export function toAskResult(state: AskState): AskResult { const answers = Object.fromEntries( Object.entries(state.answers) .map( - ([questionId, answer]) => - [questionId, serializeAnswer(answer)] as const, + ([questionId, answer]) => [questionId, serializeAnswer(answer)] as const ) - .filter(([, answer]) => !isSerializedAnswerEmpty(answer)), + .filter(([, answer]) => !isSerializedAnswerEmpty(answer)) ); return { @@ -41,7 +40,7 @@ export function hasAnswerContent(state: AskState, questionId: string): boolean { } function isSerializedAnswerEmpty( - answer: AskResult["answers"][string], + answer: AskResult["answers"][string] ): boolean { return ( answer.values.length === 0 && diff --git a/src/state/selectors.ts b/src/state/selectors.ts index c03aeed..af25de2 100644 --- a/src/state/selectors.ts +++ b/src/state/selectors.ts @@ -13,7 +13,7 @@ export function getCurrentQuestion(state: AskState): AskQuestion | undefined { export function getQuestionById( state: AskState, - questionId: string, + questionId: string ): AskQuestion | undefined { return state.questions.find((question) => question.id === questionId); } @@ -23,7 +23,7 @@ export function isSubmitTab(state: AskState): boolean { } export function getRenderableOptions( - question?: AskQuestion, + question?: AskQuestion ): AskDisplayOption[] { if (!question) { return []; @@ -43,7 +43,7 @@ export function getRenderableOptions( } export function getCurrentOption( - state: AskState, + state: AskState ): AskDisplayOption | undefined { return getRenderableOptions(getCurrentQuestion(state))[ state.activeOptionIndex @@ -52,14 +52,14 @@ export function getCurrentOption( export function getAnswer( state: AskState, - questionId: string, + questionId: string ): AskStateAnswer | undefined { return state.answers[questionId]; } export function getQuestionNote( state: AskState, - questionId: string, + questionId: string ): string | undefined { return getAnswer(state, questionId)?.note; } @@ -67,21 +67,21 @@ export function getQuestionNote( export function getOptionNote( state: AskState, questionId: string, - optionValue: string, + optionValue: string ): string | undefined { return getAnswer(state, questionId)?.optionNotes?.[optionValue]; } export function isQuestionAnswered( state: AskState, - questionId: string, + questionId: string ): boolean { return isAnswerAnswered(getAnswer(state, questionId)); } export function isQuestionNoteOpen( state: AskState, - questionId: string, + questionId: string ): boolean { return ( state.view.kind === "note" && @@ -93,7 +93,7 @@ export function isQuestionNoteOpen( export function isOptionNoteOpen( state: AskState, questionId: string, - optionValue: string, + optionValue: string ): boolean { return ( state.view.kind === "note" && @@ -104,7 +104,7 @@ export function isOptionNoteOpen( export function isInputOpenForQuestion( state: AskState, - questionId: string, + questionId: string ): boolean { return state.view.kind === "input" && state.view.questionId === questionId; } diff --git a/src/state/transitions.ts b/src/state/transitions.ts index bef58b0..c791614 100644 --- a/src/state/transitions.ts +++ b/src/state/transitions.ts @@ -58,7 +58,7 @@ export function reduceAskState(state: AskState, action: AskAction): AskState { case "OPEN_OPTION_NOTE": return setView( state, - optionNoteView(action.questionId, action.optionValue), + optionNoteView(action.questionId, action.optionValue) ); case "CONFIRM": return confirmCurrentSelection(state); @@ -97,7 +97,7 @@ export function enterInputMode(state: AskState, questionId: string): AskState { export function enterQuestionNoteMode( state: AskState, - questionId: string, + questionId: string ): AskState { return reduceAskState(state, { type: "OPEN_QUESTION_NOTE", questionId }); } @@ -105,7 +105,7 @@ export function enterQuestionNoteMode( export function enterOptionNoteMode( state: AskState, questionId: string, - optionValue: string, + optionValue: string ): AskState { return reduceAskState(state, { type: "OPEN_OPTION_NOTE", @@ -130,13 +130,13 @@ export function toggleCurrentOption(state: AskState): AskState { if (question.type === "multi") { return updateAnswer(state, question.id, (answer) => - toggleSelection(answer, option, state.activeOptionIndex), + toggleSelection(answer, option, state.activeOptionIndex) ); } return updateAnswer(state, question.id, (answer) => { const isSelected = answer.selected.some( - (selection) => selection.value === option.value, + (selection) => selection.value === option.value ); if (isSelected) { return { @@ -161,7 +161,7 @@ export function confirmCurrentSelection(state: AskState): AskState { const question = getCurrentQuestion(state); const option = getCurrentOption(state); - if (!question || !option) { + if (!(question && option)) { return state; } @@ -177,7 +177,7 @@ export function confirmCurrentSelection(state: AskState): AskState { } const nextState = updateAnswer(state, question.id, (answer) => - setSingleSelection(answer, option, state.activeOptionIndex), + setSingleSelection(answer, option, state.activeOptionIndex) ); return advanceToNextTab(nextState); } @@ -205,12 +205,12 @@ export function applyNumberShortcut(state: AskState, digit: number): AskState { if (question.type === "multi") { return updateAnswer(selectedState, question.id, (answer) => - toggleSelection(answer, option, index), + toggleSelection(answer, option, index) ); } const nextState = updateAnswer(selectedState, question.id, (answer) => - setSingleSelection(answer, option, index), + setSingleSelection(answer, option, index) ); return advanceToNextTab(nextState); } @@ -221,7 +221,7 @@ export function saveCustomAnswer(state: AskState, rawValue: string): AskState { export function submitCustomAnswer( state: AskState, - rawValue: string, + rawValue: string ): AskState { return saveInputValue(state, rawValue, true); } @@ -244,7 +244,7 @@ export function cancelFlow(state: AskState): AskState { function saveInputValue( state: AskState, rawValue: string, - submit: boolean, + submit: boolean ): AskState { if (state.view.kind !== "input") { return state; @@ -258,9 +258,9 @@ function saveInputValue( const nextState = updateAnswer( exitEditingView(state), question.id, - (answer) => saveCustomText(answer, rawValue), + (answer) => saveCustomText(answer, rawValue) ); - if (!submit || !isAnswerAnswered(nextState.answers[question.id])) { + if (!(submit && isAnswerAnswered(nextState.answers[question.id]))) { return nextState; } return advanceToNextTab(nextState); @@ -278,7 +278,7 @@ function saveNoteValue(state: AskState, rawValue: string): AskState { (answer) => optionValue ? saveOptionNote(answer, optionValue, rawValue) - : saveQuestionNote(answer, rawValue), + : saveQuestionNote(answer, rawValue) ); return nextState; } @@ -303,7 +303,7 @@ function moveOptionBy(state: AskState, delta: 1 | -1): AskState { activeSubmitActionIndex: clamp( state.activeSubmitActionIndex + delta, 0, - SUBMIT_ACTION_COUNT - 1, + SUBMIT_ACTION_COUNT - 1 ), }; } @@ -314,7 +314,7 @@ function moveOptionBy(state: AskState, delta: 1 | -1): AskState { activeOptionIndex: clamp( state.activeOptionIndex + delta, 0, - Math.max(0, options.length - 1), + Math.max(0, options.length - 1) ), }; } @@ -348,8 +348,8 @@ function updateAnswer( state: AskState, questionId: string, mutate: ( - answer: ReturnType, - ) => ReturnType, + answer: ReturnType + ) => ReturnType ): AskState { const existing = getAnswer(state, questionId) ?? emptyAnswer(); const nextAnswer = mutate(existing); diff --git a/src/state/view.ts b/src/state/view.ts index 4ef7d0a..c218b0e 100644 --- a/src/state/view.ts +++ b/src/state/view.ts @@ -18,7 +18,7 @@ export function questionNoteView(questionId: string): ViewState { export function optionNoteView( questionId: string, - optionValue: string, + optionValue: string ): ViewState { return { kind: "note", questionId, optionValue }; } diff --git a/src/text.ts b/src/text.ts index 832a33a..71cb059 100644 --- a/src/text.ts +++ b/src/text.ts @@ -1,11 +1,14 @@ import { visibleWidth } from "@mariozechner/pi-tui"; +const LEADING_WHITESPACE_PATTERN = /^\s*/; +const WORD_SPLIT_PATTERN = /\s+/; + export function wrapText(text: string, width: number): string[] { const effectiveWidth = Math.max(1, width); const lines = text .split("\n") .flatMap((paragraph) => - wrapParagraphWithIndentation(paragraph, effectiveWidth), + wrapParagraphWithIndentation(paragraph, effectiveWidth) ); return lines.length > 0 ? lines : [""]; @@ -13,13 +16,14 @@ export function wrapText(text: string, width: number): string[] { function wrapParagraphWithIndentation( paragraph: string, - width: number, + width: number ): string[] { if (paragraph.length === 0) { return [""]; } - const leadingWhitespace = paragraph.match(/^\s*/)?.[0] ?? ""; + const leadingWhitespace = + paragraph.match(LEADING_WHITESPACE_PATTERN)?.[0] ?? ""; const body = paragraph.slice(leadingWhitespace.length).trim(); if (!body) { return [leadingWhitespace]; @@ -35,7 +39,7 @@ function wrapParagraphWithIndentation( function wrapParagraph(text: string, width: number): string[] { const effectiveWidth = Math.max(1, width); - const words = text.split(/\s+/).filter(Boolean); + const words = text.split(WORD_SPLIT_PATTERN).filter(Boolean); const lines: string[] = []; let current = ""; diff --git a/src/types.ts b/src/types.ts index aa1b9f5..a14bb4d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,57 +1,57 @@ export type AskQuestionType = "single" | "multi" | "preview"; export interface AskOption { - value: string; - label: string; description?: string; + label: string; preview?: string; + value: string; } export interface AskQuestionInput { id: string; label?: string; - prompt: string; - type?: AskQuestionType; - required?: boolean; options: AskOption[]; + prompt: string; + required?: boolean; + type?: AskQuestionType; } export interface AskParams { - title?: string; questions: AskQuestionInput[]; + title?: string; } export interface AskQuestion extends Omit { label: string; - type: AskQuestionType; required: boolean; + type: AskQuestionType; } export interface AskSelectedOption { - value: string; - label: string; index: number; + label: string; + value: string; } export interface AskStateAnswer { - selected: AskSelectedOption[]; customText?: string; note?: string; optionNotes?: Record; + selected: AskSelectedOption[]; } export interface AskResultAnswer { - values: string[]; - labels: string[]; - indices: number[]; customText?: string; + indices: number[]; + labels: string[]; note?: string; optionNotes?: Record; + values: string[]; } export interface AskResult { - title?: string; + answers: Record; cancelled: boolean; questions: Array<{ id: string; @@ -59,7 +59,7 @@ export interface AskResult { prompt: string; type: AskQuestionType; }>; - answers: Record; + title?: string; } export type ViewState = @@ -69,15 +69,15 @@ export type ViewState = | { kind: "note"; questionId: string; optionValue?: string }; export interface AskState { - title?: string; - questions: AskQuestion[]; - activeTabIndex: number; activeOptionIndex: number; activeSubmitActionIndex: number; - view: ViewState; + activeTabIndex: number; answers: Record; - completed: boolean; cancelled: boolean; + completed: boolean; + questions: AskQuestion[]; + title?: string; + view: ViewState; } export interface AskDisplayOption extends AskOption { diff --git a/src/ui/controller.ts b/src/ui/controller.ts index fa28162..ed986f7 100644 --- a/src/ui/controller.ts +++ b/src/ui/controller.ts @@ -1,28 +1,30 @@ import type { ExtensionContext } from "@mariozechner/pi-coding-agent"; import { Editor, type EditorTheme } from "@mariozechner/pi-tui"; +import { createInitialState } from "../state/create.ts"; +import { toAskResult } from "../state/result.ts"; import { - applyNumberShortcut, - cancelFlow, - confirmCurrentSelection, - createInitialState, - enterInputMode, - enterOptionNoteMode, - enterQuestionNoteMode, getAnswer, getCurrentOption, getCurrentQuestion, getOptionNote, getQuestionNote, - isEditingView, isSubmitTab, +} from "../state/selectors.ts"; +import { + applyNumberShortcut, + cancelFlow, + confirmCurrentSelection, + enterInputMode, + enterOptionNoteMode, + enterQuestionNoteMode, moveOption, moveTab, saveCustomAnswer, saveNote, submitCustomAnswer, - toAskResult, toggleCurrentMultiOption, -} from "../state.ts"; +} from "../state/transitions.ts"; +import { isEditingView } from "../state/view.ts"; import type { AskParams, AskResult, AskState } from "../types.ts"; import type { AskInputCommand } from "./input.ts"; import { getInputCommand } from "./input.ts"; @@ -38,21 +40,21 @@ type Keybindings = CustomCallbackArgs[2]; type Done = (result: AskResult) => void; interface AskFlowController { - state: AskState; cachedLines: string[] | undefined; - suppressAutoInputForSelection: boolean; - tui: Tui; - theme: Theme; - editor: Editor; done: Done; + editor: Editor; + state: AskState; + suppressAutoInputForSelection: boolean; + theme: Theme; + tui: Tui; } -export async function runAskFlow( +export function runAskFlow( ctx: ExtensionContext, - params: AskParams, + params: AskParams ): Promise { return ctx.ui.custom((...args) => - createAskFlowController(args, params), + createAskFlowController(args, params) ); } @@ -63,16 +65,16 @@ function createAskFlowController( Keybindings, (result: AskResult) => void, ], - params: AskParams, + params: AskParams ) { const controller: AskFlowController = { - state: createInitialState(params), cachedLines: undefined, - suppressAutoInputForSelection: false, - tui, - theme, - editor: createEditor(tui, theme), done, + editor: createEditor(tui, theme), + state: createInitialState(params), + suppressAutoInputForSelection: false, + theme, + tui, }; controller.editor.onSubmit = (value) => submitEditor(controller, value); @@ -91,14 +93,14 @@ function createAskFlowController( function renderController( controller: AskFlowController, - width: number, + width: number ): string[] { if (!controller.cachedLines) { controller.cachedLines = renderAskScreen({ + editor: controller.editor, state: controller.state, theme: controller.theme, width, - editor: controller.editor, }); } return controller.cachedLines; @@ -116,7 +118,7 @@ function handleControllerInput(controller: AskFlowController, data: string) { function handleEditingCommand( controller: AskFlowController, command: AskInputCommand, - data: string, + data: string ) { if (command.kind === "editMoveTab") { controller.state = saveEditorAndMoveTab(controller, command.delta); @@ -145,7 +147,7 @@ function handleEditingCommand( function handleNavigationCommand( controller: AskFlowController, - command: AskInputCommand, + command: AskInputCommand ) { switch (command.kind) { case "moveTab": @@ -174,7 +176,7 @@ function handleNavigationCommand( case "numberShortcut": commitNavigation( controller, - applyNumberShortcut(controller.state, command.digit), + applyNumberShortcut(controller.state, command.digit) ); return; case "ignore": @@ -225,8 +227,7 @@ function openOptionNote(controller: AskFlowController) { const question = getCurrentQuestion(controller.state); const option = getCurrentOption(controller.state); if ( - !question || - !option || + !(question && option) || option.isCustomOption || isSubmitTab(controller.state) ) { @@ -235,7 +236,7 @@ function openOptionNote(controller: AskFlowController) { controller.state = enterOptionNoteMode( controller.state, question.id, - option.value, + option.value ); hydrateEditorForCurrentView(controller); refresh(controller); @@ -292,7 +293,7 @@ function syncInputModeWithSelection(controller: AskFlowController) { const question = getCurrentQuestion(controller.state); const option = getCurrentOption(controller.state); - if (!question || !option?.isCustomOption) { + if (!(question && option?.isCustomOption)) { return; } @@ -314,7 +315,7 @@ function saveEditorWithoutAdvancing(controller: AskFlowController): AskState { function saveEditorAndMoveTab( controller: AskFlowController, - delta: 1 | -1, + delta: 1 | -1 ): AskState { const nextState = saveEditorWithoutAdvancing(controller); controller.suppressAutoInputForSelection = false; @@ -323,7 +324,7 @@ function saveEditorAndMoveTab( function saveEditorAndMoveOption( controller: AskFlowController, - delta: 1 | -1, + delta: 1 | -1 ): AskState { const nextState = saveEditorWithoutAdvancing(controller); controller.suppressAutoInputForSelection = false; @@ -334,11 +335,11 @@ function createEditor(tui: Tui, theme: Theme) { const editorTheme: EditorTheme = { borderColor: (text) => theme.fg("accent", text), selectList: { + description: (text) => theme.fg("muted", text), + noMatch: (text) => theme.fg("warning", text), + scrollInfo: (text) => theme.fg("dim", text), selectedPrefix: (text) => theme.fg("accent", text), selectedText: (text) => theme.fg("accent", text), - description: (text) => theme.fg("muted", text), - scrollInfo: (text) => theme.fg("dim", text), - noMatch: (text) => theme.fg("warning", text), }, }; return new Editor(tui, editorTheme); diff --git a/src/ui/input.ts b/src/ui/input.ts index af89141..1f1d712 100644 --- a/src/ui/input.ts +++ b/src/ui/input.ts @@ -1,6 +1,8 @@ import { Key, matchesKey } from "@mariozechner/pi-tui"; import type { AskState } from "../types.ts"; +const DIGIT_PATTERN = /^[1-9]$/; + export type AskInputCommand = | { kind: "moveTab"; delta: 1 | -1 } | { kind: "moveOption"; delta: 1 | -1 } @@ -18,7 +20,7 @@ export type AskInputCommand = export function getInputCommand( state: AskState, - data: string, + data: string ): AskInputCommand { if (state.view.kind === "input" || state.view.kind === "note") { return getEditingInputCommand(data); @@ -102,5 +104,5 @@ function isQuestionNoteShortcut(data: string): boolean { } function parseDigit(data: string): number | null { - return /^[1-9]$/.test(data) ? Number(data) : null; + return DIGIT_PATTERN.test(data) ? Number(data) : null; } diff --git a/src/ui/render-frame.ts b/src/ui/render-frame.ts index ca240f9..555fad7 100644 --- a/src/ui/render-frame.ts +++ b/src/ui/render-frame.ts @@ -3,7 +3,7 @@ import { getCurrentQuestion, isQuestionAnswered, isSubmitTab, -} from "../state.ts"; +} from "../state/selectors.ts"; import type { AskState } from "../types.ts"; import { renderFooterText } from "./render-helpers.ts"; import type { Theme } from "./render-types.ts"; @@ -53,7 +53,7 @@ function renderTabs(state: AskState, theme: Theme, width: number): string { segments.push( active ? theme.bg("selectedBg", theme.fg("text", text)) - : theme.fg(answered ? "success" : "muted", text), + : theme.fg(answered ? "success" : "muted", text) ); } @@ -61,7 +61,7 @@ function renderTabs(state: AskState, theme: Theme, width: number): string { segments.push( isSubmitTab(state) ? theme.bg("selectedBg", theme.fg("text", submitText)) - : theme.fg("success", submitText), + : theme.fg("success", submitText) ); segments.push(theme.fg("dim", " →")); return truncateToWidth(` ${segments.join(" ")}`, width); @@ -80,6 +80,6 @@ function renderFooter(state: AskState, theme: Theme): string { const question = getCurrentQuestion(state); return theme.fg( "dim", - renderFooterText(question?.type === "multi" ? "multi" : "default"), + renderFooterText(question?.type === "multi" ? "multi" : "default") ); } diff --git a/src/ui/render-helpers.ts b/src/ui/render-helpers.ts index 815bc68..00f0114 100644 --- a/src/ui/render-helpers.ts +++ b/src/ui/render-helpers.ts @@ -14,7 +14,7 @@ export function pushWrappedText( theme: Theme, color: ThemeColor, prefix = "", - continuationPrefix = prefix, + continuationPrefix = prefix ) { const availableWidth = Math.max(1, width - visibleWidth(prefix)); const wrapped = wrapText(text, availableWidth); @@ -22,7 +22,7 @@ export function pushWrappedText( const line = wrapped[index]; const currentPrefix = index === 0 ? prefix : continuationPrefix; lines.push( - truncateToWidth(`${currentPrefix}${theme.fg(color, line)}`, width), + truncateToWidth(`${currentPrefix}${theme.fg(color, line)}`, width) ); } if (wrapped.length === 0) { @@ -34,7 +34,7 @@ export function renderInputLine( line: string, availableWidth: number, theme: Theme, - color: ThemeColor = "text", + color: ThemeColor = "text" ): string { const innerWidth = Math.max(4, availableWidth - 2); const truncated = truncateToWidth(line, innerWidth); @@ -66,7 +66,8 @@ export function renderEditorBlock(args: { contentColor = "text", isEmpty = false, } = args; - const innerLines = editorLines.length >= 2 ? editorLines.slice(1, -1) : editorLines; + const innerLines = + editorLines.length >= 2 ? editorLines.slice(1, -1) : editorLines; if (isEmpty && placeholder) { lines.push( @@ -75,10 +76,10 @@ export function renderEditorBlock(args: { placeholder, availableWidth, theme, - placeholderColor, + placeholderColor )}`, - width, - ), + width + ) ); return; } @@ -90,10 +91,10 @@ export function renderEditorBlock(args: { editorLine, availableWidth, theme, - contentColor, + contentColor )}`, - width, - ), + width + ) ); } } @@ -101,7 +102,7 @@ export function renderEditorBlock(args: { export function renderBox( content: Array<{ text: string; color: ThemeColor }>, width: number, - theme: Theme, + theme: Theme ): string[] { const boxWidth = Math.max(UI_DIMENSIONS.boxMinWidth, width); const innerWidth = Math.max(4, boxWidth - 2); @@ -113,7 +114,7 @@ export function renderBox( const line = theme.fg(item.color, rawLine); const padding = " ".repeat(Math.max(0, innerWidth - visibleWidth(line))); lines.push( - theme.fg("accent", "│") + line + padding + theme.fg("accent", "│"), + theme.fg("accent", "│") + line + padding + theme.fg("accent", "│") ); } } @@ -126,7 +127,7 @@ export function renderPreviewPaneContent( | { label: string; description?: string; preview?: string } | undefined, theme: Theme, - width: number, + width: number ): string[] { if (!selectedOption) { return renderBox([{ text: NO_PREVIEW_TEXT, color: "dim" }], width, theme); @@ -140,7 +141,7 @@ export function renderPreviewPaneContent( } content.push({ text: "", color: "dim" }); for (const previewLine of (selectedOption.preview ?? NO_PREVIEW_TEXT).split( - "\n", + "\n" )) { content.push({ text: previewLine, @@ -154,7 +155,7 @@ export function mergeColumns( left: string[], right: string[], leftWidth: number, - width: number, + width: number ): string[] { const lines: string[] = []; const rowCount = Math.max(left.length, right.length); @@ -169,7 +170,7 @@ export function mergeColumns( export function measurePreviewLeftWidth( options: Array<{ label: string; description?: string }>, - width: number, + width: number ): number { let widest = 0; for (let index = 0; index < options.length; index++) { @@ -177,24 +178,24 @@ export function measurePreviewLeftWidth( widest = Math.max( widest, visibleWidth(`${index + 1}. ${option.label}`), - option.description ? visibleWidth(option.description) : 0, + option.description ? visibleWidth(option.description) : 0 ); } const preferred = widest + 4; const maxWidth = Math.min( UI_DIMENSIONS.previewLeftMaxWidth, - Math.floor(width * UI_DIMENSIONS.previewLeftRatio), + Math.floor(width * UI_DIMENSIONS.previewLeftRatio) ); return clamp( preferred, UI_DIMENSIONS.previewLeftMinWidth, - Math.max(UI_DIMENSIONS.previewLeftMinWidth, maxWidth), + Math.max(UI_DIMENSIONS.previewLeftMinWidth, maxWidth) ); } export function renderFooterText( - mode: "input" | "note" | "submit" | "multi" | "default", + mode: "input" | "note" | "submit" | "multi" | "default" ) { switch (mode) { case "input": diff --git a/src/ui/render-question.ts b/src/ui/render-question.ts index 7655f64..0d7a8db 100644 --- a/src/ui/render-question.ts +++ b/src/ui/render-question.ts @@ -1,13 +1,13 @@ import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui"; +import { isOptionSelected } from "../state/answers.ts"; import { getAnswer, getOptionNote, getQuestionNote, isInputOpenForQuestion, isOptionNoteOpen, - isOptionSelected, isQuestionNoteOpen, -} from "../state.ts"; +} from "../state/selectors.ts"; import type { AskDisplayOption } from "../types.ts"; import { UI_DIMENSIONS, UI_TEXT } from "./constants.ts"; import { @@ -48,15 +48,15 @@ function renderQuestionNote(context: QuestionRenderContext) { theme, "accent", " ", - " ", + " " ); renderEditorBlock({ lines, editorLines: editor.render( Math.max( UI_DIMENSIONS.editorMinWidth, - width - UI_DIMENSIONS.editorContentPadding, - ), + width - UI_DIMENSIONS.editorContentPadding + ) ), width, theme, @@ -80,7 +80,7 @@ function renderQuestionNote(context: QuestionRenderContext) { theme, "muted", " ", - " ", + " " ); lines.push(""); } @@ -95,7 +95,7 @@ function renderStandardQuestion(context: QuestionRenderContext) { function renderStandardOption( context: QuestionRenderContext, option: AskDisplayOption, - index: number, + index: number ) { const { lines, state, question, theme, width } = context; const answer = getAnswer(state, question.id); @@ -106,7 +106,11 @@ function renderStandardOption( const pointer = selected ? "❯ " : " "; const prefix = getOptionPrefix(question.type, option, isAnsweredOption); const optionColor = getOptionColor(isAnsweredOption, selected); - if (option.isCustomOption && selected && isInputOpenForQuestion(state, question.id)) { + if ( + option.isCustomOption && + selected && + isInputOpenForQuestion(state, question.id) + ) { renderInteractiveCustomOption(context, index, pointer, prefix, optionColor); return; } @@ -118,7 +122,7 @@ function renderStandardOption( theme, optionColor, pointer, - " ".repeat(visibleWidth(pointer)), + " ".repeat(visibleWidth(pointer)) ); renderOptionMeta({ ...context, @@ -143,7 +147,7 @@ function renderPreviewQuestion(context: QuestionRenderContext) { theme, width, answer, - selectedOption, + selectedOption ); } else { renderStackedPreviewLayout( @@ -153,7 +157,7 @@ function renderPreviewQuestion(context: QuestionRenderContext) { theme, width, answer, - selectedOption, + selectedOption ); } @@ -173,19 +177,19 @@ function renderWidePreviewLayout( theme: Theme, width: number, answer: ReturnType, - selectedOption: AskDisplayOption | undefined, + selectedOption: AskDisplayOption | undefined ) { const leftWidth = measurePreviewLeftWidth(options, width); const rightWidth = Math.max( UI_DIMENSIONS.previewMinRightWidth, - width - leftWidth - 2, + width - leftWidth - 2 ); const leftPane = renderPreviewOptionList( state, options, theme, leftWidth, - answer, + answer ); const rightPane = renderPreviewPaneContent(selectedOption, theme, rightWidth); for (const line of mergeColumns(leftPane, rightPane, leftWidth, width)) { @@ -200,7 +204,7 @@ function renderStackedPreviewLayout( theme: Theme, width: number, answer: ReturnType, - selectedOption: AskDisplayOption | undefined, + selectedOption: AskDisplayOption | undefined ) { renderPreviewOptionList(state, options, theme, width, answer).forEach(add); add(""); @@ -212,7 +216,7 @@ function renderPreviewOptionList( options: QuestionRenderContext["options"], theme: Theme, width: number, - answer: ReturnType, + answer: ReturnType ): string[] { const lines: string[] = []; for (let index = 0; index < options.length; index++) { @@ -228,7 +232,7 @@ function renderPreviewOptionList( theme, getOptionColor(isAnsweredOption, selected), selected ? "❯ " : " ", - " ", + " " ); if (option.description) { pushWrappedText( @@ -238,7 +242,7 @@ function renderPreviewOptionList( theme, "muted", " ", - " ", + " " ); } } @@ -251,7 +255,7 @@ function renderOptionMeta( answer: ReturnType; option: AskDisplayOption; selected: boolean; - }, + } ) { const { lines, option, theme, width } = context; if (option.description) { @@ -262,7 +266,7 @@ function renderOptionMeta( theme, "muted", " ", - " ", + " " ); } renderOptionEditorOrNote(context); @@ -312,7 +316,7 @@ function getOptionDetailRenderState(context: OptionDetailRenderContext) { } function hasOptionDetailContent( - renderState: ReturnType, + renderState: ReturnType ) { return !!( renderState.noteOpen || @@ -324,15 +328,27 @@ function hasOptionDetailContent( function renderOptionEditor( context: OptionDetailRenderContext, - renderState: ReturnType, + renderState: ReturnType ): boolean { const { lines, editor, width, theme } = context; if (renderState.noteOpen) { - renderIndentedEditor(lines, editor, width, theme, UI_TEXT.editorPlaceholderNote); + renderIndentedEditor( + lines, + editor, + width, + theme, + UI_TEXT.editorPlaceholderNote + ); return true; } if (renderState.inputOpen) { - renderIndentedEditor(lines, editor, width, theme, UI_TEXT.editorPlaceholderInput); + renderIndentedEditor( + lines, + editor, + width, + theme, + UI_TEXT.editorPlaceholderInput + ); return true; } return false; @@ -340,10 +356,10 @@ function renderOptionEditor( function renderOptionContent( context: OptionDetailRenderContext, - renderState: ReturnType, + renderState: ReturnType ): boolean { const { lines, option, theme, width } = context; - if (!option?.isCustomOption || !renderState.customText) { + if (!(option?.isCustomOption && renderState.customText)) { return false; } pushWrappedText( @@ -353,14 +369,14 @@ function renderOptionContent( theme, "muted", " ", - " ", + " " ); return true; } function renderSavedOptionNote( context: OptionDetailRenderContext, - optionNote: string | undefined, + optionNote: string | undefined ) { if (!optionNote) { return; @@ -373,7 +389,7 @@ function renderSavedOptionNote( theme, "muted", " ", - " ", + " " ); } @@ -382,15 +398,15 @@ function renderIndentedEditor( editor: QuestionRenderContext["editor"], width: number, theme: Theme, - placeholder: string, + placeholder: string ) { renderEditorBlock({ lines, editorLines: editor.render( Math.max( UI_DIMENSIONS.editorMinWidth, - width - UI_DIMENSIONS.editorIndentedPadding, - ), + width - UI_DIMENSIONS.editorIndentedPadding + ) ), width, theme, @@ -404,7 +420,7 @@ function renderIndentedEditor( function getOptionPrefix( questionType: QuestionRenderContext["question"]["type"], option: AskDisplayOption, - isAnsweredOption: boolean, + isAnsweredOption: boolean ): string { if (questionType !== "multi" || option.isCustomOption) { return ""; @@ -417,13 +433,13 @@ function renderInteractiveCustomOption( index: number, pointer: string, prefix: string, - optionColor: ReturnType, + optionColor: ReturnType ) { const { lines, editor, theme, width } = context; const label = `${index + 1}. ${prefix}`; const availableWidth = Math.max( 1, - width - visibleWidth(pointer) - visibleWidth(label), + width - visibleWidth(pointer) - visibleWidth(label) ); const text = editor.getText().replace(/\n/g, " "); const hasText = text.trim().length > 0; @@ -435,10 +451,10 @@ function renderInteractiveCustomOption( inputText, availableWidth, theme, - inputColor, + inputColor )}`, - width, - ), + width + ) ); } diff --git a/src/ui/render-submit.ts b/src/ui/render-submit.ts index 36c4d47..fc606f8 100644 --- a/src/ui/render-submit.ts +++ b/src/ui/render-submit.ts @@ -1,6 +1,6 @@ import { truncateToWidth } from "@mariozechner/pi-tui"; import { SUBMIT_CHOICES } from "../constants.ts"; -import { toAskResult } from "../state.ts"; +import { toAskResult } from "../state/result.ts"; import type { AskResult, AskState } from "../types.ts"; import { UI_TEXT } from "./constants.ts"; import { pushWrappedText } from "./render-helpers.ts"; @@ -10,7 +10,7 @@ export function renderSubmitScreen( lines: string[], state: AskState, theme: Theme, - width: number, + width: number ) { pushWrappedText(lines, UI_TEXT.reviewTitle, width, theme, "accent", " ", " "); lines.push(""); @@ -22,7 +22,7 @@ export function renderSubmitScreen( question, submittedAnswers[question.id], theme, - width, + width ); } @@ -34,7 +34,7 @@ export function renderSubmitScreen( theme, "success", " ", - " ", + " " ); lines.push(""); renderSubmitActions(lines, state, theme, width); @@ -45,12 +45,12 @@ function renderSubmittedQuestion( question: AskState["questions"][number], answer: AskResult["answers"][string] | undefined, theme: Theme, - width: number, + width: number ) { pushWrappedText(lines, question.prompt, width, theme, "muted", " ● ", " "); if (!answer) { lines.push( - truncateToWidth(` ${theme.fg("warning", UI_TEXT.unanswered)}`, width), + truncateToWidth(` ${theme.fg("warning", UI_TEXT.unanswered)}`, width) ); return; } @@ -62,7 +62,7 @@ function renderSubmittedAnswer( lines: string[], answer: AskResult["answers"][string], theme: Theme, - width: number, + width: number ) { const answerText = answer.customText ?? answer.labels.join(", "); if (answerText) { @@ -73,7 +73,7 @@ function renderSubmittedAnswer( theme, answer.customText ? "text" : "success", " ", - " ", + " " ); } if (answer.note) { @@ -84,7 +84,7 @@ function renderSubmittedAnswer( theme, "muted", " ", - " ", + " " ); } for (let index = 0; index < answer.values.length; index++) { @@ -101,7 +101,7 @@ function renderSubmittedAnswer( theme, "muted", " ", - " ", + " " ); } } @@ -110,7 +110,7 @@ function renderSubmitActions( lines: string[], state: AskState, theme: Theme, - width: number, + width: number ) { for (let index = 0; index < SUBMIT_CHOICES.length; index++) { const selected = index === state.activeSubmitActionIndex; @@ -122,7 +122,7 @@ function renderSubmitActions( theme, selected ? "accent" : "text", prefix, - prefix, + prefix ); } } diff --git a/src/ui/render-types.ts b/src/ui/render-types.ts index a077ce7..8c98fcd 100644 --- a/src/ui/render-types.ts +++ b/src/ui/render-types.ts @@ -4,30 +4,30 @@ import type { getAnswer, getCurrentQuestion, getRenderableOptions, -} from "../state.ts"; +} from "../state/selectors.ts"; import type { AskDisplayOption, AskState } from "../types.ts"; export type Theme = ExtensionContext["ui"]["theme"]; export interface QuestionRenderContext { + editor: Editor; lines: string[]; - state: AskState; - question: NonNullable>; options: ReturnType; + question: NonNullable>; + state: AskState; theme: Theme; width: number; - editor: Editor; } export interface OptionDetailRenderContext { - lines: string[]; - state: AskState; - questionId: string; answer: ReturnType; + editor: Editor; + lines: string[]; option: AskDisplayOption | undefined; + questionId: string; + selected?: boolean; + state: AskState; theme: Theme; width: number; - editor: Editor; - selected?: boolean; withGap?: boolean; } diff --git a/src/ui/render.ts b/src/ui/render.ts index de4c39e..337ad70 100644 --- a/src/ui/render.ts +++ b/src/ui/render.ts @@ -2,7 +2,7 @@ import { getCurrentQuestion, getRenderableOptions, isSubmitTab, -} from "../state.ts"; +} from "../state/selectors.ts"; import type { AskState } from "../types.ts"; import { renderFrameFooter, renderFrameHeader } from "./render-frame.ts"; import { renderQuestionScreen } from "./render-question.ts"; diff --git a/tests/input.test.ts b/tests/input.test.ts index ac82ae0..3d60b46 100644 --- a/tests/input.test.ts +++ b/tests/input.test.ts @@ -1,6 +1,10 @@ import assert from "node:assert/strict"; 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"; function inputState() { @@ -30,15 +34,18 @@ test("editing views let up and down move navigation", () => { }); test("note editing views let up and down move navigation", () => { - const state = enterQuestionNoteMode(createInitialState({ - questions: [ - { - id: "q1", - prompt: "Question?", - options: [{ value: "a", label: "A" }], - }, - ], - }), "q1"); + const state = enterQuestionNoteMode( + createInitialState({ + questions: [ + { + id: "q1", + prompt: "Question?", + options: [{ value: "a", label: "A" }], + }, + ], + }), + "q1" + ); assert.deepEqual(getInputCommand(state, "\x1b[A"), { kind: "editMoveOption", diff --git a/tests/render-helpers.test.ts b/tests/render-helpers.test.ts index ad7efbd..9a2cc3c 100644 --- a/tests/render-helpers.test.ts +++ b/tests/render-helpers.test.ts @@ -2,6 +2,10 @@ import assert from "node:assert/strict"; import test from "node:test"; import { renderEditorBlock } from "../src/ui/render-helpers.ts"; +const ABC_PATTERN = /abc/; +const BORDER_PATTERN = /┌|└/; +const TYPE_YOUR_PATTERN = /Type your/; + function mockTheme() { return { fg(color: string, text: string) { @@ -25,8 +29,8 @@ test("renderEditorBlock strips editor borders", () => { }); assert.equal(lines.length, 1); - assert.match(lines[0], /abc/); - assert.doesNotMatch(lines[0], /┌|└/); + assert.match(lines[0], ABC_PATTERN); + assert.doesNotMatch(lines[0], BORDER_PATTERN); }); 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.match(lines[0], /Type your/); - assert.doesNotMatch(lines[0], /┌|└/); + assert.match(lines[0], TYPE_YOUR_PATTERN); + assert.doesNotMatch(lines[0], BORDER_PATTERN); }); diff --git a/tests/render-question.test.ts b/tests/render-question.test.ts index fd38b57..eb991c0 100644 --- a/tests/render-question.test.ts +++ b/tests/render-question.test.ts @@ -1,7 +1,8 @@ import assert from "node:assert/strict"; 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 { applyNumberShortcut } from "../src/state/transitions.ts"; import { renderQuestionScreen } from "../src/ui/render-question.ts"; function mockTheme() { @@ -42,13 +43,13 @@ test("custom option stays labeled before selection", () => { const lines: string[] = []; renderQuestionScreen({ + editor: mockEditor(), lines, - state, - question: state.questions[0], options: getRenderableOptions(state.questions[0]), + question: state.questions[0], + state, theme: mockTheme(), width: 80, - editor: mockEditor(), }); assert(lines.some((line) => line.includes("Type your own"))); @@ -69,13 +70,13 @@ test("selected custom option becomes inline input", () => { const lines: string[] = []; renderQuestionScreen({ + editor: mockEditor(""), lines, - state, - question: state.questions[0], options: getRenderableOptions(state.questions[0]), + question: state.questions[0], + state, theme: mockTheme(), width: 80, - editor: mockEditor(""), }); assert(lines.some((line) => line.includes("Type your answer..."))); diff --git a/tests/result.test.ts b/tests/result.test.ts index 24d2df5..4459d88 100644 --- a/tests/result.test.ts +++ b/tests/result.test.ts @@ -1,16 +1,15 @@ import assert from "node:assert/strict"; import test from "node:test"; import { renderResultText } from "../src/result.ts"; +import { createInitialState } from "../src/state/create.ts"; +import { summarizeResult, toAskResult } from "../src/state/result.ts"; import { applyNumberShortcut, - createInitialState, enterOptionNoteMode, enterQuestionNoteMode, saveNote, submitCustomAnswer, - summarizeResult, - toAskResult, -} from "../src/state.ts"; +} from "../src/state/transitions.ts"; test("summarizeResult formats selected answers", () => { let state = createInitialState({ @@ -53,7 +52,7 @@ test("summaries include custom text answers", () => { assert.equal(summarizeResult(result), "Notes: Please include examples"); assert.equal( 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 note: Need SSR support", "Framework / Vue note: Team has prior experience", - ].join("\n"), + ].join("\n") ); assert.equal( renderResultText(result), @@ -93,7 +92,7 @@ test("summaries and result text include question and selected option notes", () "✓ Framework: Vue", " note: Need SSR support", " Vue note: Team has prior experience", - ].join("\n"), + ].join("\n") ); }); diff --git a/tests/state.test.ts b/tests/state.test.ts index b1ef156..3c0c957 100644 --- a/tests/state.test.ts +++ b/tests/state.test.ts @@ -1,21 +1,21 @@ import assert from "node:assert/strict"; 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 { applyNumberShortcut, cancelFlow, confirmCurrentSelection, - createInitialState, enterOptionNoteMode, enterQuestionNoteMode, - getRenderableOptions, moveOption, moveTab, saveCustomAnswer, saveNote, submitCustomAnswer, - toAskResult, toggleCurrentMultiOption, -} from "../src/state.ts"; +} from "../src/state/transitions.ts"; import type { AskParams } from "../src/types.ts"; function sampleParams(): AskParams { @@ -61,11 +61,11 @@ test("normalize defaults via initial state", () => { assert.equal(state.questions[0].required, true); assert.equal( getRenderableOptions(state.questions[0]).at(-1)?.label, - "Type your own", + "Type your own" ); assert.equal( 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( getRenderableOptions(state.questions[0]).at(-1)?.isCustomOption, - undefined, + undefined ); 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); assert.deepEqual( state.answers.fe.selected.map((selection) => selection.label), - ["React"], + ["React"] ); state = moveOption(state, 1); state = toggleCurrentMultiOption(state); assert.deepEqual( state.answers.fe.selected.map((selection) => selection.label), - ["React", "Vue"], + ["React", "Vue"] ); state = confirmCurrentSelection(state); @@ -254,13 +254,13 @@ test("option notes can exist before selection but only selected option notes are state = toggleCurrentMultiOption(state); assert.deepEqual( state.answers.q1.selected.map((selection) => selection.value), - ["react"], + ["react"] ); state = moveOption(state, 1); state = toggleCurrentMultiOption(state); assert.deepEqual( state.answers.q1.selected.map((selection) => selection.value), - ["react", "vue"], + ["react", "vue"] ); assert.deepEqual(toAskResult(state).answers.q1.optionNotes, { vue: "maybe later", diff --git a/tsconfig.json b/tsconfig.json index c70a8d4..d0e2c3b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,15 +1,15 @@ { - "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "moduleResolution": "Bundler", - "allowImportingTsExtensions": true, - "strict": true, - "skipLibCheck": true, - "noEmit": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "types": ["node"] - }, - "include": ["src/**/*.ts", "tests/**/*.ts"] + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "types": ["node"] + }, + "include": ["src/**/*.ts", "tests/**/*.ts"] }