diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..5e266ad6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,40 @@ +name: Build, typecheck, and test + +on: + pull_request: + types: + - ready_for_review + - reopened + - synchronize + workflow_dispatch: + +jobs: + validate: + name: Validate + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 11.1.1 + + - name: Setup Node + uses: actions/setup-node@v5 + with: + node-version: 25 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build + run: pnpm build + + - name: Typecheck + run: pnpm typecheck + + - name: Test + run: pnpm test diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..59fd5465 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +node_modules/ +.turbo/ +**/.turbo/ +.cache/ +**/.cache/ +provision/.cache/ +.DS_Store +coverage/ +*.tsbuildinfo +packages/artefacts/.logs/ +packages/artefacts/dist/ +packages/tui/dist/ +packages/docs/src/.vitepress/ +packages/docs/src/api/ diff --git a/.npmrc b/.npmrc new file mode 100644 index 00000000..94c622a1 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +store-dir=provision/.cache/pnpm/store diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 00000000..c53de0be --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,23 @@ +MIT License + +Copyright (c) 2026 Oullin + +Portions of this package are derived from upstream MIT-licensed prompt toolkit code. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..87959dc6 --- /dev/null +++ b/Makefile @@ -0,0 +1,70 @@ +SHELL := /bin/bash + +ROOT_PATH := $(CURDIR) +FMT_IMAGE := ghcr.io/oullin/go-fmt:v0.4.0 +FMT_RUN := docker run --rm \ + --user $$(id -u):$$(id -g) \ + -v $(ROOT_PATH):/work \ + -w /work \ + -e HOST_PROJECT_PATH=$(ROOT_PATH) \ + $(FMT_IMAGE) + +TS_GLOBS := '*.ts' '*.tsx' '*.vue' '*.mts' '*.cts' + +.DEFAULT_GOAL := help + +define step + @printf "\n\033[1;36m==>\033[0m \033[1m%s\033[0m\n" "$(1)" +endef + +define run_oxfmt +@cd "$(ROOT_PATH)" && tmp=$$(mktemp); git ls-files -z $(1) --exclude-standard -- $(TS_GLOBS) | while IFS= read -r -d '' f; do [ -f "$$f" ] && printf '%s\0' "$$f"; done > "$$tmp"; if [ ! -s "$$tmp" ]; then echo "No TS/Vue files to format."; rm -f "$$tmp"; else xargs -0 $(FMT_RUN) format < "$$tmp"; rc=$$?; rm -f "$$tmp"; exit $$rc; fi +endef + +define run_in + @cd "$(1)" && $(2) +endef + +.PHONY: help dev format format-all fresh + +help: + @awk 'BEGIN {FS = ":.*?## "; printf "\nUsage: \033[36mmake \033[0m\n"} \ + /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5); next } \ + /^[a-zA-Z0-9_.-]+:.*?##/ { printf " \033[36m%-28s\033[0m %s\n", $$1, $$2 }' $(MAKEFILE_LIST) + @echo "" + +##@ Development +dev: ## Run the docs dev server + $(call run_in,$(ROOT_PATH),pnpm --filter docs dev) + +##@ Formatting +format: ## Format + lint untracked + modified TS/Vue files via oxfmt/oxlint + $(call run_oxfmt,--others --modified) + +format-all: ## Format + lint every non-ignored TS/Vue file in the repo via oxfmt/oxlint + @cd "$(ROOT_PATH)" && tmp=$$(mktemp); git ls-files -z --cached --others --exclude-standard -- $(TS_GLOBS) | while IFS= read -r -d '' f; do [ -f "$$f" ] && printf '%s\0' "$$f"; done > "$$tmp"; if [ ! -s "$$tmp" ]; then echo "No TS/Vue files to format."; rm -f "$$tmp"; else xargs -0 $(FMT_RUN) format < "$$tmp"; rc=$$?; rm -f "$$tmp"; exit $$rc; fi + +##@ Maintenance +fresh: ## Clean generated state, reinstall dependencies, then build + $(call step,Removing generated dependency/cache/build state) + @test -n "$(ROOT_PATH)" && test "$(ROOT_PATH)" != "/" || { echo "Refusing to clean unsafe ROOT_PATH='$(ROOT_PATH)'"; exit 1; } + rm -rf \ + "$(ROOT_PATH)/node_modules" \ + "$(ROOT_PATH)"/packages/*/node_modules \ + "$(ROOT_PATH)"/provision/*/node_modules \ + "$(ROOT_PATH)/provision/.cache" \ + "$(ROOT_PATH)/packages/artefacts/.logs" \ + "$(ROOT_PATH)/packages/artefacts/dist" \ + "$(ROOT_PATH)/packages/docs/src/.vitepress/cache" \ + "$(ROOT_PATH)/packages/docs/src/.vitepress/dist" \ + "$(ROOT_PATH)/packages/docs/src/api" \ + "$(ROOT_PATH)/packages/tui/dist" + $(call step,Removing generated workspace caches) + find "$(ROOT_PATH)" -path "$(ROOT_PATH)/.git" -prune -o -type d -name '.turbo' -prune -exec rm -rf {} + + find "$(ROOT_PATH)" -path "$(ROOT_PATH)/.git" -prune -o -path "$(ROOT_PATH)/provision/.cache" -prune -o -type d -name '.cache' -prune -exec rm -rf {} + + $(call step,Removing TypeScript build info files) + find "$(ROOT_PATH)" -name '*.tsbuildinfo' -type f -delete + $(call step,Installing dependencies) + $(call run_in,$(ROOT_PATH),pnpm install) + $(call step,Building from scratch) + $(call run_in,$(ROOT_PATH),pnpm build) diff --git a/README.md b/README.md new file mode 100644 index 00000000..5e0fa2ae --- /dev/null +++ b/README.md @@ -0,0 +1,27 @@ +# `@ollin/tui` + +An idiomatic TypeScript/OpenTUI prompt toolkit. + +This repository targets behavioral parity with reference prompt toolkit release +`v0.3.18` while using TypeScript-first modules and package boundaries. + +## Status + +The current codebase is a TypeScript ESM package with the public `@ollin/tui` +entrypoint exporting prompts, form composition, output helpers, status +indicators, terminal utilities, OpenTUI adapter wiring, and deterministic prompt +environment helpers. + +Local test suites cover prompt behavior, form flows, output and status helpers, +terminal and string utilities, key handling, typed-value editing, and package +consumption through `packages/acceptance`. The docs package builds a VitePress +guide plus generated API reference. Reference parity and the completion audit are +tracked in `UPSTREAM.md`. + +## Development + +```sh +pnpm install +pnpm typecheck +pnpm test +``` diff --git a/UPSTREAM.md b/UPSTREAM.md new file mode 100644 index 00000000..bf3e2130 --- /dev/null +++ b/UPSTREAM.md @@ -0,0 +1,239 @@ +# Reference Parity Map + +Target reference release: `v0.3.18` + +Tree: `a19af51bb144bf87f08397921fa619f85c7d4e72` + +This port keeps reference behavior as the source of truth while using idiomatic +TypeScript and OpenTUI internals. + +## Implemented In Phase 1 + +| Reference area | TypeScript area | +| --- | --- | +| Helper functions | `packages/tui/src/index.ts` | +| Prompt lifecycle and fallback behavior | `packages/tui/src/prompt.ts`, `packages/tui/src/environment.ts`, `packages/tui/src/environment/*` | +| Prompt fallback reset, predicate condition validation, scoped lifecycle, and predicate condition lifecycle | `packages/tui/src/prompt/fallback.ts`, `packages/tui/src/prompt/validators/fallback.ts`, `packages/tui/src/environment/scope.ts`, `packages/tui/src/prompt.ts`, `packages/tui/src/index.ts` | +| Prompt cancellation callback scoped lifecycle | `packages/tui/src/prompt/cancel.ts`, `packages/tui/src/environment/scope.ts`, `packages/tui/tests/prompt.test.ts` | +| Prompt revert rejection lifecycle | `packages/tui/src/prompt/revert.ts`, `packages/tui/src/form/builder/revert.ts`, `packages/tui/src/prompts/select/read-confirm.ts`, `packages/tui/tests/form.test.ts` | +| Prompt validation retry default lifecycle and parser-error submitted-value preservation | `packages/tui/src/prompt/lifecycle.ts`, `packages/tui/src/prompt/lifecycle/*`, `packages/tui/src/prompt/error.ts`, `packages/tui/tests/text.test.ts` | +| Prompt post-validation submitted-frame lifecycle | `packages/tui/src/prompt/lifecycle.ts`, `packages/tui/src/prompt/lifecycle/*`, `packages/tui/src/prompts/text.ts`, `packages/tui/src/prompts/text/*`, `packages/tui/src/prompts/number.ts`, `packages/tui/tests/text.test.ts`, `packages/tui/tests/number.test.ts` | +| Prompt invalid-attempt active-frame cleanup lifecycle | `packages/tui/src/prompt/lifecycle.ts`, `packages/tui/src/prompt/lifecycle/*`, `packages/tui/src/prompt/active-frame.ts`, `packages/tui/src/prompts/*`, `packages/tui/src/output/data-table.ts` | +| Prompt non-interactive raw-default validation lifecycle | `packages/tui/src/prompt/lifecycle.ts`, `packages/tui/src/prompt/lifecycle/*` | +| Prompt validation error styling | `packages/tui/src/theme.ts`, `packages/tui/tests/prompt.test.ts` | +| Prompt validation warning line styling | `packages/tui/src/theme.ts`, `packages/tui/src/prompt/lifecycle.ts` | +| Prompt local and scoped global validation composition | `packages/tui/src/prompt/validation.ts`, `packages/tui/src/prompt/validate-using.ts`, `packages/tui/src/environment/scope.ts` | +| Key constants, value validation, and event mapping | `packages/tui/src/key.ts`, `packages/tui/src/key/*` | +| Terminal helpers | `packages/tui/src/terminal.ts` | +| Text input | `packages/tui/src/prompts/text.ts`, `packages/tui/src/typed-value.ts` | +| Text input defaults, transforms, validation, editing keys, non-interactive behavior, and failed reads | `packages/tui/src/prompts/text.ts`, `packages/tui/src/prompts/text/*`, `packages/tui/src/prompt/lifecycle.ts`, `packages/tui/src/typed-value.ts`, `packages/tui/src/typed-value/read/*`, `packages/tui/tests/text.test.ts` | +| Text active, submitted, cancelled, placeholder, and validation-safe rendering | `packages/tui/src/prompt/submission.ts`, `packages/tui/src/prompts/text.ts`, `packages/tui/src/prompts/text/*`, `packages/tui/src/typed-value.ts`, `packages/tui/src/typed-value/read/*`, `packages/tui/src/typed-value/render.ts`, `packages/tui/tests/text.test.ts` | +| Text active-frame redraw and submitted-frame replacement | `packages/tui/src/typed-value.ts`, `packages/tui/src/typed-value/render.ts`, `packages/tui/src/prompts/text.ts`, `packages/tui/src/prompts/text/*` | +| Text transformed fallback value, invalid retry default preservation, overload validation, and plain placeholder rendering | `packages/tui/src/prompts/text.ts`, `packages/tui/src/prompts/text/*`, `packages/tui/src/prompts/text-default.ts`, `packages/tui/src/concerns/validators/text-options.ts`, `packages/tui/src/typed-value/render.ts` | +| Textarea input | `packages/tui/src/prompts/textarea.ts`, `packages/tui/src/typed-value.ts` | +| Textarea defaults, transforms, validation, editing keys, line navigation, and non-interactive behavior | `packages/tui/src/prompts/textarea.ts`, `packages/tui/src/prompt/lifecycle.ts`, `packages/tui/src/typed-value.ts`, `packages/tui/src/typed-value/read/*`, `packages/tui/tests/textarea.test.ts` | +| Textarea active, submitted, cancelled, scrollbar, placeholder, and validation-safe rendering | `packages/tui/src/prompt/submission.ts`, `packages/tui/src/prompts/textarea.ts`, `packages/tui/src/typed-value.ts`, `packages/tui/src/typed-value/read/*`, `packages/tui/src/typed-value/textarea-frame.ts`, `packages/tui/tests/textarea.test.ts` | +| Textarea active-frame redraw and cursor-aware visible window rendering | `packages/tui/src/typed-value.ts`, `packages/tui/src/typed-value/lines.ts`, `packages/tui/src/typed-value/lines/*`, `packages/tui/src/typed-value/line-ranges.ts`, `packages/tui/src/typed-value/textarea-frame.ts`, `packages/tui/src/prompts/textarea.ts` | +| Textarea transformed fallback value, invalid retry default preservation, overload validation, and plain placeholder rendering | `packages/tui/src/prompts/textarea.ts`, `packages/tui/src/prompts/text-default.ts`, `packages/tui/src/concerns/validators/text-options.ts`, `packages/tui/src/typed-value/textarea-frame.ts` | +| Textarea control-key line navigation and empty-line movement | `packages/tui/src/typed-value/edit/navigation.ts`, `packages/tui/tests/textarea.test.ts` | +| Textarea boxed renderer and placeholder styling | `packages/tui/src/typed-value/textarea-frame.ts`, `packages/tui/tests/textarea.test.ts` | +| Textarea scrollbar rendering | `packages/tui/src/typed-value/textarea-frame.ts`, `packages/tui/src/typed-value/lines.ts`, `packages/tui/src/typed-value/lines/*`, `packages/tui/src/typed-value/line-ranges.ts`, `packages/tui/tests/textarea.test.ts` | +| Textarea wrapped frame line windows | `packages/tui/src/typed-value/textarea-frame.ts`, `packages/tui/src/typed-value/lines.ts`, `packages/tui/src/typed-value/lines/*`, `packages/tui/src/typed-value/line-ranges.ts`, `packages/tui/tests/textarea.test.ts`, `packages/tui/tests/typed-value.test.ts` | +| Textarea wrapped-line cursor navigation | `packages/tui/src/typed-value.ts`, `packages/tui/src/typed-value/edit.ts`, `packages/tui/src/typed-value/edit/navigation.ts`, `packages/tui/src/typed-value/lines.ts`, `packages/tui/src/typed-value/lines/*`, `packages/tui/src/typed-value/line-ranges.ts`, `packages/tui/tests/textarea.test.ts`, `packages/tui/tests/typed-value.test.ts` | +| Password input | `packages/tui/src/prompts/password.ts`, `packages/tui/src/typed-value.ts` | +| Password input transforms, validation retry, editing keys, cancellation, masking, and non-interactive behavior | `packages/tui/src/prompts/password.ts`, `packages/tui/src/prompts/password/options.ts`, `packages/tui/src/prompts/password/run.ts`, `packages/tui/src/prompts/password/input.ts`, `packages/tui/src/prompts/password/fallback.ts`, `packages/tui/src/prompts/password/session.ts`, `packages/tui/src/prompt/lifecycle.ts`, `packages/tui/tests/password.test.ts` | +| Password active, submitted, cancelled, placeholder, masked, and validation-safe rendering | `packages/tui/src/prompt/submission.ts`, `packages/tui/src/prompts/password.ts`, `packages/tui/src/prompts/password/run.ts`, `packages/tui/src/prompts/password/input.ts`, `packages/tui/src/prompts/password/session.ts`, `packages/tui/src/prompts/password/render.ts`, `packages/tui/tests/password.test.ts` | +| Password active-frame redraw and masked cursor rendering | `packages/tui/src/prompts/password.ts`, `packages/tui/src/prompts/password/run.ts`, `packages/tui/src/prompts/password/input.ts`, `packages/tui/src/prompts/password/session.ts`, `packages/tui/src/prompts/password/render.ts` | +| Password transformed fallback value, invalid retry default preservation, overload validation, and plain placeholder rendering | `packages/tui/src/prompts/password.ts`, `packages/tui/src/prompts/password/options.ts`, `packages/tui/src/prompts/password/run.ts`, `packages/tui/src/prompts/text-default.ts`, `packages/tui/src/concerns/validators/text-options.ts`, `packages/tui/src/prompts/password/render.ts` | +| Number input | `packages/tui/src/prompts/number.ts`, `packages/tui/src/typed-value.ts` | +| Number active, submitted, cancelled, arrow, placeholder, default, and validation-safe rendering | `packages/tui/src/prompt/submission.ts`, `packages/tui/src/prompts/number.ts`, `packages/tui/src/prompts/number/input.ts`, `packages/tui/src/prompts/number/interactive.ts`, `packages/tui/src/prompts/number/session.ts`, `packages/tui/src/prompts/number/session/*`, `packages/tui/src/prompts/number/render.ts`, `packages/tui/tests/number.test.ts` | +| Number default renderer inline arrow controls | `packages/tui/src/prompts/number/render.ts` | +| Number active-frame redraw and cursor rendering | `packages/tui/src/prompts/number.ts`, `packages/tui/src/prompts/number/input.ts`, `packages/tui/src/prompts/number/interactive.ts`, `packages/tui/src/prompts/number/session.ts`, `packages/tui/src/prompts/number/session/*`, `packages/tui/src/prompts/number/render.ts` | +| Number explicit-default lifecycle, invalid retry default preservation, parser-error retry preservation, overload validation, and transformed fallback value | `packages/tui/src/prompts/number.ts`, `packages/tui/src/prompts/number/defaults.ts`, `packages/tui/src/prompts/number/input.ts`, `packages/tui/src/prompts/number/fallback.ts`, `packages/tui/src/prompts/number/validators/overload.ts`, `packages/tui/src/validators/default.ts` | +| Confirm, select, multiselect | `packages/tui/src/prompts/select/*` | +| Confirm active, submitted, cancelled, custom-label, fallback, and non-interactive behavior | `packages/tui/src/prompts/select/read-confirm.ts`, `packages/tui/src/prompts/select/read-confirm/*`, `packages/tui/src/prompts/select/render-confirm.ts`, `packages/tui/tests/choices.test.ts` | +| Confirm submitted frame renders only after validation passes | `packages/tui/src/prompt/submission.ts`, `packages/tui/src/prompts/select/confirm.ts`, `packages/tui/src/prompts/select/confirm-run.ts`, `packages/tui/src/prompts/select/read-confirm.ts`, `packages/tui/src/prompts/select/read-confirm/*`, `packages/tui/tests/choices.test.ts` | +| Confirm explicit-default lifecycle, invalid retry default preservation, overload validation, and transformed fallback value | `packages/tui/src/prompts/select/confirm.ts`, `packages/tui/src/prompts/select/confirm-options.ts`, `packages/tui/src/prompts/select/confirm-run.ts`, `packages/tui/src/prompts/select/read-confirm.ts`, `packages/tui/src/prompts/select/read-confirm/*`, `packages/tui/src/prompts/select/validators/overload.ts`, `packages/tui/src/validators/default.ts` | +| Confirm active-frame redraw and submitted-frame replacement | `packages/tui/src/prompts/select/confirm.ts`, `packages/tui/src/prompts/select/confirm-run.ts`, `packages/tui/src/prompts/select/read-confirm.ts`, `packages/tui/src/prompts/select/read-confirm/*`, `packages/tui/src/prompts/select/render-confirm.ts` | +| Confirm active and cancelled body styling | `packages/tui/src/prompts/select/render-confirm.ts` | +| Select active boxed layout and row styling | `packages/tui/src/concerns/choice-label.ts`, `packages/tui/src/theme/choice-disabled.ts`, `packages/tui/src/theme/validators/disabled.ts`, `packages/tui/src/prompts/select/render.ts`, `packages/tui/src/prompts/select/render-rows.ts`, `packages/tui/src/prompts/select/render-rows/*`, `packages/tui/tests/choices.test.ts` | +| Select and multi-select label-first helper argument surface with info | `packages/tui/src/prompts/select/index.ts`, `packages/tui/tests/choices.test.ts` | +| Select required-option validation | `packages/tui/src/prompts/select/validators/options.ts`, `packages/tui/src/prompts/select/index.ts`, `packages/tui/src/prompts/select/select.ts`, `packages/tui/src/prompts/select/options.ts`, `packages/tui/src/prompts/select/run.ts`, `packages/tui/tests/choices.test.ts` | +| Select non-interactive default validation | `packages/tui/src/prompts/select/index.ts`, `packages/tui/src/prompts/select/select.ts`, `packages/tui/src/prompts/select/options.ts`, `packages/tui/src/prompts/select/run.ts`, `packages/tui/tests/choices.test.ts` | +| Select explicit-default lifecycle, invalid retry default preservation, overload validation, and transformed fallback value | `packages/tui/src/prompts/select/index.ts`, `packages/tui/src/prompts/select/select.ts`, `packages/tui/src/prompts/select/defaults.ts`, `packages/tui/src/prompts/select/read-selected.ts`, `packages/tui/src/prompts/select/read-selected/interactive.ts`, `packages/tui/src/prompts/select/read-selected/result.ts`, `packages/tui/src/prompts/select/validators/overload.ts`, `packages/tui/src/concerns/choices.ts`, `packages/tui/src/concerns/choice-match.ts`, `packages/tui/src/validators/default.ts` | +| Select line-mode and exhausted-input default fallback with validation retry | `packages/tui/src/prompts/select/index.ts`, `packages/tui/src/prompts/select/select.ts`, `packages/tui/src/prompts/select/read-selected.ts`, `packages/tui/src/prompts/select/read-selected/interactive.ts`, `packages/tui/src/prompts/select/read-selected/result.ts`, `packages/tui/tests/choices.test.ts` | +| Select submitted final frame | `packages/tui/src/prompts/select/render.ts`, `packages/tui/src/prompts/select/read-selected.ts`, `packages/tui/src/prompts/select/read-selected/interactive.ts`, `packages/tui/src/prompts/select/read-selected/interactive/*`, `packages/tui/tests/choices.test.ts` | +| Select submitted frame renders only after validation passes | `packages/tui/src/prompt/submission.ts`, `packages/tui/src/prompts/select/index.ts`, `packages/tui/src/prompts/select/select.ts`, `packages/tui/src/prompts/select/run.ts`, `packages/tui/src/prompts/select/read-selected.ts`, `packages/tui/src/prompts/select/read-selected/interactive.ts`, `packages/tui/src/prompts/select/read-selected/interactive/*`, `packages/tui/src/prompts/select/read-selected/result.ts`, `packages/tui/tests/choices.test.ts` | +| Select active-frame redraw and submitted-frame replacement | `packages/tui/src/prompts/select/index.ts`, `packages/tui/src/prompts/select/select.ts`, `packages/tui/src/prompts/select/run.ts`, `packages/tui/src/prompts/select/read-selected.ts`, `packages/tui/src/prompts/select/read-selected/interactive.ts`, `packages/tui/src/prompts/select/read-selected/interactive/*`, `packages/tui/src/prompts/select/render.ts` | +| Select cancel final frame | `packages/tui/src/prompts/select/render.ts`, `packages/tui/src/prompts/select/read-selected.ts`, `packages/tui/src/prompts/select/read-selected/interactive.ts`, `packages/tui/src/prompts/select/read-selected/interactive/*`, `packages/tui/tests/choices.test.ts` | +| Select cancelled frame row styling | `packages/tui/src/prompts/select/render.ts`, `packages/tui/src/prompts/select/render-rows.ts`, `packages/tui/src/prompts/select/render-rows/*` | +| Multi-select active boxed layout and row styling | `packages/tui/src/prompts/select/render.ts`, `packages/tui/src/prompts/select/render-rows.ts`, `packages/tui/src/prompts/select/render-rows/*`, `packages/tui/tests/choices.test.ts` | +| Multi-select transformed fallback value and invalid retry default preservation | `packages/tui/src/prompts/select/index.ts`, `packages/tui/src/prompts/select/multiselect.ts`, `packages/tui/src/prompts/select/multiselect/*`, `packages/tui/src/prompts/select/defaults.ts`, `packages/tui/src/prompts/select/validators/overload.ts` | +| Multi-select submitted final frame | `packages/tui/src/prompts/select/render.ts`, `packages/tui/src/prompts/select/read-multiple.ts`, `packages/tui/src/prompts/select/read-multiple/*`, `packages/tui/src/prompts/select/read-multiple/session/*`, `packages/tui/src/prompts/select/read-multiple/result.ts`, `packages/tui/tests/choices.test.ts` | +| Multi-select empty submitted final frame | `packages/tui/src/prompts/select/render.ts` | +| Multi-select submitted frame renders only after validation passes | `packages/tui/src/prompt/submission.ts`, `packages/tui/src/prompts/select/index.ts`, `packages/tui/src/prompts/select/multiselect.ts`, `packages/tui/src/prompts/select/multiselect/*`, `packages/tui/src/prompts/select/read-multiple.ts`, `packages/tui/src/prompts/select/read-multiple/*`, `packages/tui/src/prompts/select/read-multiple/session/*`, `packages/tui/src/prompts/select/read-multiple/result.ts`, `packages/tui/tests/choices.test.ts` | +| Multi-select active-frame redraw and submitted-frame replacement | `packages/tui/src/prompts/select/index.ts`, `packages/tui/src/prompts/select/multiselect.ts`, `packages/tui/src/prompts/select/multiselect/*`, `packages/tui/src/prompts/select/read-multiple.ts`, `packages/tui/src/prompts/select/read-multiple/*`, `packages/tui/src/prompts/select/read-multiple/session/*`, `packages/tui/src/prompts/select/render.ts` | +| Multi-select cancel final frame | `packages/tui/src/prompts/select/render.ts`, `packages/tui/src/prompts/select/read-multiple.ts`, `packages/tui/src/prompts/select/read-multiple/*`, `packages/tui/src/prompts/select/read-multiple/session/*`, `packages/tui/src/prompts/select/read-multiple/result.ts`, `packages/tui/tests/choices.test.ts` | +| Multi-select cancelled frame row styling | `packages/tui/src/prompts/select/render.ts`, `packages/tui/src/prompts/select/render-rows.ts`, `packages/tui/src/prompts/select/render-rows/*` | +| Suggest, autocomplete | `packages/tui/src/prompts/suggest/*` | +| Suggest active, submitted, cancelled, placeholder, info, scrolling, and validation-safe rendering | `packages/tui/src/prompt/submission.ts`, `packages/tui/src/prompts/suggest/suggest.ts`, `packages/tui/src/prompts/suggest/lifecycle.ts`, `packages/tui/src/prompts/suggest/read.ts`, `packages/tui/src/prompts/suggest/read/*`, `packages/tui/src/prompts/suggest/read/state/*`, `packages/tui/src/prompts/suggest/state/*`, `packages/tui/src/prompts/suggest/read-result.ts`, `packages/tui/src/prompts/suggest/render.ts`, `packages/tui/src/prompts/suggest/render/*`, `packages/tui/tests/suggest.test.ts` | +| Suggest active-frame redraw and cursor rendering | `packages/tui/src/prompts/suggest/suggest.ts`, `packages/tui/src/prompts/suggest/read.ts`, `packages/tui/src/prompts/suggest/read/*`, `packages/tui/src/prompts/suggest/read/state/*`, `packages/tui/src/prompts/suggest/state/*`, `packages/tui/src/prompts/suggest/read-result.ts`, `packages/tui/src/prompts/suggest/render.ts`, `packages/tui/src/prompts/suggest/render/*` | +| Suggest empty-result rendering for empty and typed queries | `packages/tui/src/prompts/suggest/render.ts`, `packages/tui/src/prompts/suggest/render/*` | +| Suggest and autocomplete transformed fallback value, invalid retry default preservation, source validation, and overload validation | `packages/tui/src/prompts/suggest/suggest.ts`, `packages/tui/src/prompts/suggest/autocomplete-prompt.ts`, `packages/tui/src/prompts/suggest/lifecycle.ts`, `packages/tui/src/prompts/suggest/validators/overload.ts`, `packages/tui/src/prompts/suggest/validators/source.ts`, `packages/tui/src/prompts/text-default.ts` | +| Suggest and autocomplete plain placeholder rendering | `packages/tui/src/prompts/suggest/render.ts`, `packages/tui/src/prompts/suggest/render-autocomplete.ts` | +| Suggest and autocomplete label-first info rendering | `packages/tui/src/prompts/suggest/suggest.ts`, `packages/tui/src/prompts/suggest/autocomplete-prompt.ts`, `packages/tui/src/prompts/suggest/options.ts`, `packages/tui/src/prompts/suggest/read-autocomplete.ts`, `packages/tui/src/prompts/suggest/read-result.ts`, `packages/tui/src/prompts/suggest/render-autocomplete.ts`, `packages/tui/tests/suggest.test.ts` | +| Autocomplete styled cursor-aware ghost text and Unicode-safe completion acceptance | `packages/tui/src/prompts/suggest/ghost-text.ts`, `packages/tui/src/prompts/suggest/autocomplete.ts`, `packages/tui/src/theme/styles.ts`, `packages/tui/tests/suggest.test.ts` | +| Autocomplete active, submitted, cancelled, placeholder, ghost-text, and validation-safe rendering | `packages/tui/src/prompt/submission.ts`, `packages/tui/src/prompts/suggest/autocomplete-prompt.ts`, `packages/tui/src/prompts/suggest/lifecycle.ts`, `packages/tui/src/prompts/suggest/read-autocomplete.ts`, `packages/tui/src/prompts/suggest/read-autocomplete/*`, `packages/tui/src/prompts/suggest/read-autocomplete/state/*`, `packages/tui/src/prompts/suggest/state/*`, `packages/tui/src/prompts/suggest/read-result.ts`, `packages/tui/src/prompts/suggest/render-autocomplete.ts`, `packages/tui/tests/suggest.test.ts` | +| Autocomplete active-frame redraw and cursor fallback rendering | `packages/tui/src/prompts/suggest/autocomplete-prompt.ts`, `packages/tui/src/prompts/suggest/read-autocomplete.ts`, `packages/tui/src/prompts/suggest/read-autocomplete/*`, `packages/tui/src/prompts/suggest/read-autocomplete/state/*`, `packages/tui/src/prompts/suggest/state/*`, `packages/tui/src/prompts/suggest/read-result.ts`, `packages/tui/src/prompts/suggest/render-autocomplete.ts`, `packages/tui/src/prompts/suggest/ghost-text.ts` | +| Search, multisearch | `packages/tui/src/prompts/search/*` | +| Search and multi-search label-first helper argument surface with info | `packages/tui/src/prompts/search/index.ts`, `packages/tui/tests/search.test.ts` | +| Search active boxed layout and row styling | `packages/tui/src/concerns/choice-label.ts`, `packages/tui/src/theme/choice-disabled.ts`, `packages/tui/src/theme/validators/disabled.ts`, `packages/tui/src/prompts/search/render.ts`, `packages/tui/src/prompts/search/render-body.ts`, `packages/tui/src/prompts/search/body/*`, `packages/tui/tests/search.test.ts` | +| Search active-frame redraw and cursor rendering | `packages/tui/src/prompts/search/index.ts`, `packages/tui/src/prompts/search/search.ts`, `packages/tui/src/prompts/search/run.ts`, `packages/tui/src/prompts/search/keys.ts`, `packages/tui/src/prompts/search/keys/*`, `packages/tui/src/prompts/search/read-single.ts`, `packages/tui/src/prompts/search/read-single/*`, `packages/tui/src/prompts/search/read-single/interactive/*`, `packages/tui/src/prompts/search/read-single/session/*`, `packages/tui/src/prompts/search/render.ts` | +| Search retry initial highlight lifecycle | `packages/tui/src/prompts/search/read-single.ts`, `packages/tui/src/prompts/search/read-single/*`, `packages/tui/src/prompts/search/read-single/state/*`, `packages/tui/src/prompts/search/navigation.ts` | +| Search and multisearch empty-result rendering | `packages/tui/src/prompts/search/render.ts`, `packages/tui/src/prompts/search/render-body.ts`, `packages/tui/src/prompts/search/body/*`, `packages/tui/tests/search.test.ts` | +| Search highlighted-result row styling | `packages/tui/src/prompts/search/render.ts`, `packages/tui/src/prompts/search/render-body.ts`, `packages/tui/src/prompts/search/body/*`, `packages/tui/src/theme/styles.ts`, `packages/tui/tests/search.test.ts` | +| Search explicit-default lifecycle, invalid retry default preservation, source validation, overload validation, and transformed fallback value | `packages/tui/src/prompts/search/index.ts`, `packages/tui/src/prompts/search/search.ts`, `packages/tui/src/prompts/search/options.ts`, `packages/tui/src/prompts/search/run.ts`, `packages/tui/src/prompts/search/defaults.ts`, `packages/tui/src/prompts/search/read-single.ts`, `packages/tui/src/prompts/search/read-single/*`, `packages/tui/src/prompts/search/read-single/state/*`, `packages/tui/src/prompts/search/read-single/result.ts`, `packages/tui/src/prompts/search/line-mode.ts`, `packages/tui/src/prompts/search/validators/overload.ts`, `packages/tui/src/prompts/search/validators/source.ts`, `packages/tui/src/concerns/choices.ts`, `packages/tui/src/concerns/choice-match.ts`, `packages/tui/src/validators/default.ts` | +| Search submitted final frame | `packages/tui/src/prompts/search/render.ts`, `packages/tui/src/prompts/search/read-single.ts`, `packages/tui/src/prompts/search/read-single/*`, `packages/tui/src/prompts/search/read-single/interactive/*`, `packages/tui/tests/search.test.ts` | +| Search submitted frame renders only after validation passes | `packages/tui/src/prompt/submission.ts`, `packages/tui/src/prompts/search/index.ts`, `packages/tui/src/prompts/search/search.ts`, `packages/tui/src/prompts/search/run.ts`, `packages/tui/src/prompts/search/read-single.ts`, `packages/tui/src/prompts/search/read-single/*`, `packages/tui/src/prompts/search/read-single/interactive/*`, `packages/tui/src/prompts/search/read-single/result.ts`, `packages/tui/tests/search.test.ts` | +| Search cancel final frame | `packages/tui/src/prompts/search/render.ts`, `packages/tui/src/prompts/search/read-single.ts`, `packages/tui/src/prompts/search/read-single/interactive/*`, `packages/tui/tests/search.test.ts` | +| Multi-search active boxed layout and selected-count footer | `packages/tui/src/prompts/search/render.ts`, `packages/tui/src/prompts/search/render-body.ts`, `packages/tui/src/prompts/search/body/*`, `packages/tui/src/prompts/search/read-multi/state/*`, `packages/tui/tests/search.test.ts` | +| Multi-search transformed fallback value and invalid retry default preservation | `packages/tui/src/prompts/search/index.ts`, `packages/tui/src/prompts/search/multisearch.ts`, `packages/tui/src/prompts/search/multi-options.ts`, `packages/tui/src/prompts/search/multi-run.ts`, `packages/tui/src/prompts/search/defaults.ts`, `packages/tui/src/prompts/search/validators/overload.ts` | +| Multi-search submitted final frame | `packages/tui/src/prompts/search/render.ts`, `packages/tui/src/prompts/search/multi-run.ts`, `packages/tui/src/prompts/search/read-multi.ts`, `packages/tui/src/prompts/search/read-multi/*`, `packages/tui/src/prompts/search/read-multi/state/*`, `packages/tui/tests/search.test.ts` | +| Multi-search empty submitted final frame | `packages/tui/src/prompts/search/render.ts` | +| Multi-search submitted frame renders only after validation passes | `packages/tui/src/prompt/submission.ts`, `packages/tui/src/prompts/search/index.ts`, `packages/tui/src/prompts/search/multisearch.ts`, `packages/tui/src/prompts/search/multi-run.ts`, `packages/tui/src/prompts/search/read-multi.ts`, `packages/tui/src/prompts/search/read-multi/*`, `packages/tui/src/prompts/search/read-multi/state/*`, `packages/tui/src/prompts/search/read-multi/result.ts`, `packages/tui/tests/search.test.ts` | +| Multi-search active-frame redraw and cursor rendering | `packages/tui/src/prompts/search/index.ts`, `packages/tui/src/prompts/search/multisearch.ts`, `packages/tui/src/prompts/search/multi-run.ts`, `packages/tui/src/prompts/search/keys.ts`, `packages/tui/src/prompts/search/keys/*`, `packages/tui/src/prompts/search/read-multi.ts`, `packages/tui/src/prompts/search/read-multi/*`, `packages/tui/src/prompts/search/read-multi/state/*`, `packages/tui/src/prompts/search/render.ts` | +| Multi-search cancel final frame | `packages/tui/src/prompts/search/render.ts`, `packages/tui/src/prompts/search/read-multi.ts`, `packages/tui/src/prompts/search/read-multi/*`, `packages/tui/src/prompts/search/read-multi/state/*`, `packages/tui/tests/search.test.ts` | +| Pause | `packages/tui/src/prompts/pause.ts`, `packages/tui/src/prompts/pause/*` | +| Pause themed key-driven frame | `packages/tui/src/prompts/pause.ts`, `packages/tui/src/prompts/pause/read.ts`, `packages/tui/src/prompts/pause/render.ts`, `packages/tui/tests/pause.test.ts` | +| Pause non-interactive no-render behavior | `packages/tui/src/prompts/pause.ts`, `packages/tui/src/prompts/pause/read.ts`, `packages/tui/tests/pause.test.ts` | +| Choice normalization, matching, navigation, and list rendering concerns | `packages/tui/src/concerns/choices.ts`, `packages/tui/src/concerns/choice-normalize.ts`, `packages/tui/src/concerns/choice-match.ts`, `packages/tui/src/concerns/choice-navigation.ts`, `packages/tui/src/concerns/choice-render.ts` | +| Choice and search scrollbar rendering | `packages/tui/src/concerns/scrollbar.ts`, `packages/tui/src/concerns/choice-navigation.ts`, `packages/tui/src/concerns/choice-render.ts`, `packages/tui/src/prompts/search/render.ts` | +| Form builder | `packages/tui/src/form.ts`, `packages/tui/src/form/builder/*`, `packages/tui/src/form/builder/responses.ts` | +| Form builder nested prompt revert short-circuiting | `packages/tui/src/form/builder/revert.ts`, `packages/tui/src/form/builder/submit.ts`, `packages/tui/tests/form.test.ts` | +| Form builder first-step and conditional revert feature coverage | `packages/tui/src/form/builder/conditions.ts`, `packages/tui/src/form/builder/submit.ts`, `packages/tui/tests/form.test.ts` | +| Form builder asynchronous condition lifecycle | `packages/tui/src/form/builder/conditions.ts`, `packages/tui/src/form/builder/submit.ts`, `packages/tui/src/form/types.ts`, `packages/tui/tests/form.test.ts` | +| Form builder side-effect step surface and revert skipping | `packages/tui/src/form/builder/index.ts`, `packages/tui/src/form/builder/methods.ts`, `packages/tui/src/form/builder/step.ts`, `packages/tui/src/form/builder/output.ts`, `packages/tui/src/form/builder/status.ts` | +| Notes and alert-style output | `packages/tui/src/output/notes.ts`, `packages/tui/src/output/notes/*` | +| Note multiline and typed theme rendering | `packages/tui/src/output/notes.ts`, `packages/tui/src/output/notes/render.ts`, `packages/tui/src/theme/styles.ts`, `packages/tui/tests/output.test.ts` | +| Note intro/outro visible-width padding | `packages/tui/src/output/notes.ts`, `packages/tui/src/output/notes/render.ts`, `packages/tui/tests/output.test.ts` | +| Native notifications | `packages/tui/src/output/notify.ts` | +| Notification command construction, executable discovery, and process result reporting | `packages/tui/src/output/notify.ts`, `packages/tui/src/output/notify/*` | +| Tables, data tables, and grids | `packages/tui/src/output/table.ts`, `packages/tui/src/output/table-output.ts`, `packages/tui/src/output/grid-output.ts`, `packages/tui/src/output/data-table.ts` | +| Data table label-first helper argument surface | `packages/tui/src/output/data-table.ts`, `packages/tui/tests/table.test.ts` | +| Data table transform callback lifecycle | `packages/tui/src/contracts/output.ts`, `packages/tui/src/output/data-table.ts`, `packages/tui/src/output/data-table/run.ts`, `packages/tui/src/output/data-table/options.ts`, `packages/tui/tests/table.test.ts` | +| Data table interactive default selection | `packages/tui/src/output/data-table/read.ts`, `packages/tui/src/output/data-table/reader/result.ts`, `packages/tui/tests/table.test.ts` | +| Data table exhausted-input default selection | `packages/tui/src/output/data-table/read.ts`, `packages/tui/src/output/data-table/reader/result.ts`, `packages/tui/tests/table.test.ts` | +| Data table line-input fallback filtering | `packages/tui/src/output/data-table/read.ts`, `packages/tui/src/output/data-table/reader/fallback.ts`, `packages/tui/src/prompt/ask.ts` | +| Data table no-results search rendering | `packages/tui/src/output/data-table/render.ts`, `packages/tui/src/output/data-table/render-body.ts`, `packages/tui/src/output/data-table/body/*`, `packages/tui/tests/table.test.ts` | +| Data table viewing-info rendering | `packages/tui/src/output/data-table/render.ts`, `packages/tui/src/output/data-table/render-body.ts`, `packages/tui/src/output/data-table/body/*`, `packages/tui/tests/table.test.ts` | +| Data table submitted and cancelled final frames | `packages/tui/src/output/data-table/render.ts`, `packages/tui/src/output/data-table/render-body.ts`, `packages/tui/src/output/data-table/body/*`, `packages/tui/src/output/data-table/read.ts`, `packages/tui/src/output/data-table/reader/interactive.ts`, `packages/tui/src/output/data-table/reader/cancel.ts`, `packages/tui/tests/table.test.ts` | +| Data table submitted frame renders only after validation passes | `packages/tui/src/prompt/submission.ts`, `packages/tui/src/output/data-table.ts`, `packages/tui/src/output/data-table/run.ts`, `packages/tui/src/output/data-table/read.ts`, `packages/tui/src/output/data-table/reader/interactive.ts`, `packages/tui/src/output/data-table/reader/result.ts`, `packages/tui/src/output/data-table/types.ts`, `packages/tui/tests/table.test.ts` | +| Data table active-frame redraw and cursor-aware search rendering | `packages/tui/src/output/data-table.ts`, `packages/tui/src/output/data-table/run.ts`, `packages/tui/src/output/data-table/read.ts`, `packages/tui/src/output/data-table/reader/interactive.ts`, `packages/tui/src/output/data-table/reader/session.ts`, `packages/tui/src/output/data-table/reader/session/*`, `packages/tui/src/output/data-table/render.ts`, `packages/tui/src/output/data-table/types.ts` | +| Data table explicit-default lifecycle, invalid retry default preservation, and transformed fallback value | `packages/tui/src/output/data-table.ts`, `packages/tui/src/output/data-table/options.ts`, `packages/tui/src/output/data-table/read.ts`, `packages/tui/src/output/data-table/reader/result.ts`, `packages/tui/src/validators/default.ts` | +| Data table themed cancellation warning | `packages/tui/src/output/data-table/render.ts`, `packages/tui/src/output/data-table/render-body.ts`, `packages/tui/src/output/data-table/body/*`, `packages/tui/src/output/data-table/reader/cancel.ts`, `packages/tui/tests/table.test.ts` | +| Data table multiline cell rendering | `packages/tui/src/output/data-table/multiline.ts`, `packages/tui/src/output/data-table/render.ts`, `packages/tui/src/output/data-table/render-body.ts`, `packages/tui/src/output/data-table/body/*`, `packages/tui/tests/table.test.ts` | +| Data table scrollbar rendering | `packages/tui/src/output/data-table/scrollbar.ts`, `packages/tui/src/output/data-table/render.ts`, `packages/tui/src/output/data-table/render-body.ts`, `packages/tui/src/output/data-table/body/*`, `packages/tui/tests/table.test.ts` | +| Data table no-header and blank-cell rendering | `packages/tui/src/output/data-table/render.ts`, `packages/tui/src/output/data-table/render-body.ts`, `packages/tui/src/output/data-table/body/*`, `packages/tui/src/output/data-table/rows.ts`, `packages/tui/src/output/data-table/rows/*`, `packages/tui/tests/table.test.ts` | +| Data table row-shape validation and normalization | `packages/tui/src/output/validators/cells.ts`, `packages/tui/src/output/validators/data-table.ts`, `packages/tui/src/output/data-table/rows.ts`, `packages/tui/src/output/data-table/rows/*`, `packages/tui/tests/table.test.ts` | +| Static table row-shape validation and header inference | `packages/tui/src/output/validators/cells.ts`, `packages/tui/src/output/validators/table.ts`, `packages/tui/src/output/validators/table/*`, `packages/tui/tests/table.test.ts` | +| Data table fixed visual height | `packages/tui/src/output/data-table/visual-window.ts`, `packages/tui/src/output/data-table/render.ts`, `packages/tui/src/output/data-table/render-body.ts`, `packages/tui/src/output/data-table/body/*`, `packages/tui/tests/table.test.ts` | +| Data table comfortable width and outlier truncation | `packages/tui/src/output/data-table/widths.ts`, `packages/tui/src/output/data-table/widths/*`, `packages/tui/src/output/data-table/render.ts`, `packages/tui/src/output/data-table/render-body.ts`, `packages/tui/src/output/data-table/body/*`, `packages/tui/tests/table.test.ts` | +| Boxed balanced grid rendering | `packages/tui/src/output/grid.ts`, `packages/tui/src/output/grid/*`, `packages/tui/tests/output.test.ts` | +| Terminal title and clear output | `packages/tui/src/output/terminal.ts` | +| Spinner, progress, task, stream | `packages/tui/src/status/*`, `packages/tui/src/status.ts` | +| Form builder label-first search and multisearch methods | `packages/tui/src/form/builder/prompts/choices.ts`, `packages/tui/src/form/builder/prompts/choice/*`, `packages/tui/tests/form.test.ts` | +| Form builder label-first choice and search info rendering | `packages/tui/src/form/builder/prompts/choices.ts`, `packages/tui/src/form/builder/prompts/choice/*`, `packages/tui/src/form/builder/prompts/contracts/choices.ts`, `packages/tui/tests/form.test.ts` | +| Form builder previous-response defaults | `packages/tui/src/form/builder/previous.ts`, `packages/tui/src/form/builder/validators/previous.ts`, `packages/tui/tests/form.test.ts` | +| Form builder basic prompt object-option method surface with validator-backed names | `packages/tui/src/form/builder/prompts/basic.ts`, `packages/tui/src/form/builder/prompts/basic/*`, `packages/tui/src/form/builder/prompts/contracts/basic.ts`, `packages/tui/src/form/builder/prompts/validators/basic.ts`, `packages/tui/tests/form.test.ts` | +| Form builder choice prompt object-option method surface with validator-backed overload parsing | `packages/tui/src/form/builder/prompts/choices.ts`, `packages/tui/src/form/builder/prompts/choice/*`, `packages/tui/src/form/builder/prompts/contracts/choices.ts`, `packages/tui/src/form/builder/prompts/validators/select.ts`, `packages/tui/tests/form.test.ts` | +| Form builder suggest and autocomplete placeholder/default arguments | `packages/tui/src/form/builder/prompts/choices.ts`, `packages/tui/src/form/builder/prompts/choice/*`, `packages/tui/src/form/builder/prompts/contracts/choices.ts`, `packages/tui/tests/form.test.ts` | +| Form builder suggest and autocomplete object-option method surface with validator-backed overload parsing | `packages/tui/src/form/builder/prompts/choices.ts`, `packages/tui/src/form/builder/prompts/choice/*`, `packages/tui/src/form/builder/prompts/contracts/choices.ts`, `packages/tui/src/form/builder/prompts/validators/suggest.ts`, `packages/tui/tests/form.test.ts` | +| Form builder suggest and autocomplete label-first info rendering | `packages/tui/src/form/builder/prompts/choices.ts`, `packages/tui/src/form/builder/prompts/choice/*`, `packages/tui/src/form/builder/prompts/contracts/choices.ts`, `packages/tui/tests/form.test.ts` | +| Form builder number transform argument surface | `packages/tui/src/form/builder/prompts/basic.ts`, `packages/tui/src/form/builder/prompts/basic/*`, `packages/tui/src/form/builder/prompts/basic/number/*`, `packages/tui/src/form/builder/prompts/contracts/basic.ts`, `packages/tui/src/prompts/number.ts`, `packages/tui/src/prompts/number/defaults.ts`, `packages/tui/tests/form.test.ts`, `packages/tui/tests/number.test.ts` | +| Form builder password previous-response defaults | `packages/tui/src/form/builder/prompts/basic.ts`, `packages/tui/src/form/builder/prompts/basic/*`, `packages/tui/tests/form.test.ts` | +| Form builder search and multisearch previous-response defaults | `packages/tui/src/form/builder/prompts/choices.ts`, `packages/tui/src/form/builder/prompts/choice/*`, `packages/tui/tests/form.test.ts` | +| Form builder named prompt defaults after skipped conditional responses | `packages/tui/src/form/builder/previous.ts`, `packages/tui/src/form/builder/prompts/choices.ts`, `packages/tui/src/form/builder/prompts/choice/*`, `packages/tui/src/form/builder/validators/previous.ts`, `packages/tui/tests/form.test.ts` | +| Form builder label-first data table method surface | `packages/tui/src/form/builder/output.ts`, `packages/tui/src/form/builder/output/*`, `packages/tui/src/form/builder/output/contracts.ts`, `packages/tui/tests/form.test.ts` | +| Form builder label-first data table previous-response defaults | `packages/tui/src/form/builder/output.ts`, `packages/tui/src/form/builder/output/*`, `packages/tui/src/form/builder/output/contracts.ts`, `packages/tui/tests/form.test.ts` | +| Form builder interactive data table object-option method surface | `packages/tui/src/form/builder/output.ts`, `packages/tui/src/form/builder/output/*`, `packages/tui/src/form/builder/output/contracts.ts`, `packages/tui/src/output/validators/data-table.ts`, `packages/tui/tests/form.test.ts` | +| Form builder output overload and row validator layer | `packages/tui/src/form/builder/output.ts`, `packages/tui/src/form/builder/output/*`, `packages/tui/src/form/builder/output/contracts.ts`, `packages/tui/src/form/builder/validators/output.ts` | +| Form builder object-option table output method surface | `packages/tui/src/form/builder/output.ts`, `packages/tui/src/form/builder/output/*`, `packages/tui/src/form/builder/output/contracts.ts`, `packages/tui/src/output/validators/table.ts`, `packages/tui/src/output/validators/table/*`, `packages/tui/tests/form.test.ts` | +| Form builder output method surface for grid, display data table, terminal helpers, and notifications | `packages/tui/src/form/builder/index.ts`, `packages/tui/src/form/builder/methods.ts`, `packages/tui/src/form/builder/output.ts`, `packages/tui/src/form/builder/output/*`, `packages/tui/tests/form.test.ts` | +| Display-only output helper return values and form responses | `packages/tui/src/output/notes.ts`, `packages/tui/src/output/table.ts`, `packages/tui/src/form/builder/output.ts`, `packages/tui/src/form/builder/output/*`, `packages/tui/tests/output.test.ts`, `packages/tui/tests/form.test.ts` | +| Progress boxed frame rendering, clamped fill rendering, and formatted fractions | `packages/tui/src/status/progress/render.ts`, `packages/tui/src/status/progress/state/*`, `packages/tui/src/theme/box.ts` | +| Spinner static and deterministic animated frame rendering | `packages/tui/src/status/spinner.ts`, `packages/tui/src/status/spinner/lifecycle.ts`, `packages/tui/src/status/spinner/render.ts` | +| Spinner empty default message | `packages/tui/src/status/spinner.ts`, `packages/tui/src/status/spinner/options.ts`, `packages/tui/src/status/spinner/validators/*`, `packages/tui/src/form/builder/status.ts`, `packages/tui/src/form/builder/status/*`, `packages/tui/tests/spinner.test.ts`, `packages/tui/tests/form.test.ts` | +| Task static and deterministic animated frame rendering, active redraw, bounded logs, and stable summary symbols | `packages/tui/src/status/task.ts`, `packages/tui/src/status/task/definition.ts`, `packages/tui/src/status/task/validators/*`, `packages/tui/src/status/task/lifecycle.ts`, `packages/tui/src/status/task/lifecycle/*`, `packages/tui/src/status/task/frame.ts`, `packages/tui/src/status/task/*` | +| Task non-positive log limit defaulting | `packages/tui/src/status/validators/limit.ts`, `packages/tui/src/status/task/logger.ts`, `packages/tui/src/status/task/logger/*`, `packages/tui/tests/task.test.ts` | +| Form builder root-aligned spinner and task overloads with validator-backed parsing | `packages/tui/src/form/builder/status.ts`, `packages/tui/src/form/builder/status/*`, `packages/tui/src/form/builder/validators/status.ts`, `packages/tui/tests/form.test.ts` | +| Form builder root-aligned manual progress side-effect lifecycle and validator-backed overloads | `packages/tui/src/form/builder/status.ts`, `packages/tui/src/form/builder/status/*`, `packages/tui/src/form/builder/validators/status.ts`, `packages/tui/tests/form.test.ts` | +| Task process output capture, writer validation, and restore-before-flush lifecycle | `packages/tui/src/status/task/process-output.ts`, `packages/tui/src/status/task/process-output/*`, `packages/tui/tests/task.test.ts` | +| Stream frame rendering, active redraw, wrapping, fade styles, chunk validation, lifecycle cleanup, burst coalescing, and close flushing | `packages/tui/src/status/stream.ts`, `packages/tui/src/status/stream/output.ts`, `packages/tui/src/status/stream/output/*`, `packages/tui/src/status/stream/lifecycle.ts`, `packages/tui/src/status/stream/renderer.ts`, `packages/tui/src/status/stream/scheduler.ts`, `packages/tui/src/status/stream/validators/*`, `packages/tui/src/status/stream/*` | +| Form builder root-aligned manual stream named-response overload | `packages/tui/src/form/builder/status.ts`, `packages/tui/src/form/builder/status/*`, `packages/tui/src/form/builder/validators/status.ts`, `packages/tui/src/validators/iterable.ts`, `packages/tui/tests/form.test.ts` | +| Status cursor visibility cleanup | `packages/tui/src/status/spinner.ts`, `packages/tui/src/status/stream.ts`, `packages/tui/src/status/task.ts` | +| Status signal cleanup for cursor and rendered-frame restoration | `packages/tui/src/status/signals.ts`, `packages/tui/src/status/spinner.ts`, `packages/tui/src/status/stream.ts`, `packages/tui/src/status/task.ts` | +| Status rendered-frame cleanup | `packages/tui/src/status/frame.ts`, `packages/tui/src/status/spinner.ts`, `packages/tui/src/status/task.ts` | +| Progress cursor visibility and rendered-frame redraw cleanup | `packages/tui/src/status/progress/progress.ts`, `packages/tui/src/status/progress/controller.ts`, `packages/tui/src/status/progress/renderer.ts`, `packages/tui/src/status/progress/state.ts`, `packages/tui/src/status/progress/state/*`, `packages/tui/src/status/progress/terminal.ts`, `packages/tui/src/status/frame.ts` | +| Progress signal cleanup | `packages/tui/src/status/progress/progress.ts`, `packages/tui/src/status/progress/terminal.ts`, `packages/tui/src/status/progress/terminal/*`, `packages/tui/tests/progress.test.ts` | +| Progress prompt-disable and manual advance semantics | `packages/tui/src/status/progress.ts`, `packages/tui/src/status/progress/progress.ts`, `packages/tui/src/status/progress/prompt.ts`, `packages/tui/src/status/progress/controller.ts`, `packages/tui/src/status/progress/validators/*`, `packages/tui/src/validators/iterable.ts` | +| Progress integer step parsing and clamped manual advance range | `packages/tui/src/status/progress.ts`, `packages/tui/src/status/progress/progress.ts`, `packages/tui/src/status/progress/state.ts`, `packages/tui/src/status/progress/state/*`, `packages/tui/src/status/progress/validators/*`, `packages/tui/src/status/validators/progress.ts` | +| Terminal clear/title, cursor visibility, true-color detection, default colors, and raw input restoration | `packages/tui/src/terminal.ts`, `packages/tui/src/terminal/*`, `packages/tui/src/environment/raw-key.ts`, `packages/tui/src/environment/raw-key/mode.ts`, `packages/tui/src/environment/raw-key/types.ts` | +| Terminal clear/title and notification helper return values | `packages/tui/src/output/terminal.ts`, `packages/tui/src/output/notify.ts`, `packages/tui/tests/terminal.test.ts`, `packages/tui/tests/notify.test.ts` | +| Terminal raw key alias normalization | `packages/tui/src/environment/raw-key/normalize.ts`, `packages/tui/tests/environment.test.ts` | +| Terminal split escape-sequence buffering | `packages/tui/src/environment/raw-key.ts`, `packages/tui/src/environment/raw-key/session.ts`, `packages/tui/src/environment/raw-key/normalize.ts`, `packages/tui/tests/environment.test.ts` | +| Red, strikethrough, and border-styled frames | `packages/tui/src/theme/styles.ts`, `packages/tui/src/theme/box.ts` | +| Default box renderer terminal-width cap | `packages/tui/src/theme/box.ts` | +| ANSI segment parsing, ANSI-aware wrapping, truncation, Unicode width | `packages/tui/src/strings.ts`, `packages/tui/src/string-utils/*`, `packages/tui/src/string-utils/ansi/*`, `packages/tui/tests/strings.test.ts` | +| ANSI truncation style cleanup and visible-width markers | `packages/tui/src/string-utils/truncate.ts`, `packages/tui/src/string-utils/width.ts`, `packages/tui/tests/strings.test.ts` | +| Key-mode search default submission | `packages/tui/src/prompts/search/read-single.ts`, `packages/tui/src/prompts/search/read-single/state/*`, `packages/tui/src/prompts/search/read-single/result.ts`, `packages/tui/tests/search.test.ts` | +| OpenTUI adapter | `packages/tui/src/opentui.ts` | +| Type contracts | `packages/tui/src/types.ts`, `packages/tui/src/contracts/*` | +| Public ESM package runtime, type-consumption surface, and domain subpath exports | `packages/tui/package.json`, `packages/tui/src/index.ts`, `packages/tui/src/prompts.ts`, `packages/tui/src/prompts/*`, `packages/tui/src/form/builder/index.ts`, `packages/tui/src/output.ts`, `packages/tui/src/output/*`, `packages/tui/src/status.ts`, `packages/tui/src/status/*`, `packages/tui/src/output/notify.ts`, `packages/tui/src/output/notify/*`, `packages/acceptance/tests/package.test.ts` | +| Provision workspace and tool cache isolation | `provision/go`, `provision/ts`, `provision/.cache`, `package.json`, `pnpm-workspace.yaml`, `turbo.json`, `.npmrc` | +| Data table prompt argument validation layer | `packages/tui/src/output/validators/data-table.ts`, `packages/tui/src/output/data-table.ts`, `packages/tui/src/output/data-table/reader/types.ts`, `packages/tui/tests/table.test.ts` | +| Data table filter callback lifecycle | `packages/tui/src/contracts/output.ts`, `packages/tui/src/output/data-table/rows.ts`, `packages/tui/src/output/data-table/rows/*`, `packages/tui/src/output/data-table/reader/state.ts`, `packages/tui/src/output/data-table/reader/state/*`, `packages/tui/src/output/data-table.ts` | +| Data table search title and non-empty query filter lifecycle | `packages/tui/src/output/data-table/render.ts`, `packages/tui/src/output/data-table/rows.ts`, `packages/tui/src/output/data-table/rows/*`, `packages/tui/src/output/data-table/reader/state.ts`, `packages/tui/src/output/data-table/reader/state/*`, `packages/tui/src/output/data-table/reader/session.ts`, `packages/tui/src/output/data-table/reader/session/*`, `packages/tui/tests/form.test.ts` | +| Table row cell normalization validation layer | `packages/tui/src/output/validators/table.ts`, `packages/tui/src/output/validators/table/*`, `packages/tui/src/output/table.ts`, `packages/tui/tests/table.test.ts` | +| Choice option shape validation layer | `packages/tui/src/concerns/validators/choice.ts`, `packages/tui/src/concerns/choice-normalize.ts`, `packages/tui/src/concerns/choices.ts`, `packages/tui/tests/choices.test.ts` | +| Required value and message validation layer | `packages/tui/src/validators/required.ts`, `packages/tui/tests/prompt.test.ts` | +| Keyboard event normalization and key aliases | `packages/tui/src/key/event.ts`, `packages/tui/src/key/event/*`, `packages/tui/tests/key.test.ts` | +| Key match validation layer | `packages/tui/src/key/validators/match.ts`, `packages/tui/src/key/match.ts`, `packages/tui/tests/key.test.ts` | +| Form builder number empty-default lifecycle | `packages/tui/src/form/builder/prompts/basic.ts`, `packages/tui/src/form/builder/prompts/basic/*`, `packages/tui/src/form/builder/prompts/basic/number/*`, `packages/tui/tests/form.test.ts` | +| Prompt fallback and global validation lifecycle hooks | `packages/tui/src/prompt/fallback.ts`, `packages/tui/src/prompt/validate-using.ts`, `packages/tui/src/prompt/lifecycle.ts`, `packages/tui/src/prompts/*`, `packages/tui/src/output/data-table.ts` | +| Pipe-style default table rendering | `packages/tui/src/theme.ts`, `packages/tui/src/theme/*`, `packages/tui/src/output/table.ts`, `packages/tui/src/output/data-table/render.ts` | +| Default theme symbol, prompt, validation, choice, and table rendering concerns | `packages/tui/src/theme.ts`, `packages/tui/src/theme/*` | + +## Completion Audit + +Current implementation evidence: + +- Public runtime helpers are exported from `packages/tui/src/index.ts` and + verified by `packages/acceptance/tests/package.test.ts`. +- Prompt, form, output, status, terminal, string, key, typed-value, and package + consumption behavior is covered by the local test suites under + `packages/tui/tests` and `packages/acceptance/tests`. +- Form builder method-surface and validation concerns are split under + `packages/tui/src/form/builder/*` with validator layers under + `packages/tui/src/form/builder/**/validators/*`. +- Prompt implementations are split by concern under `packages/tui/src/prompts/*` + and shared validation layers live under local `validators/*` directories. +- Source imports use package aliases instead of relative paths. +- The package is ESM-only through `packages/tui/package.json` `type` and + `exports`. +- CI is configured in `.github/workflows/ci.yml` as a manual-only + `workflow_dispatch` workflow while validation is being confirmed locally. + +Latest local verification: + +- `pnpm test` +- `pnpm typecheck` +- `pnpm build` +- `make format-all` + +## Documented Deviations + +- Console output classes are represented by a TypeScript `PromptEnvironment`. +- Package-manager runtime checks are not applicable to this npm package. +- External validator integration is represented by typed synchronous or asynchronous + validator callbacks. +- Process-signal spinner behavior is represented by TypeScript timers. diff --git a/package.json b/package.json new file mode 100644 index 00000000..b3f39def --- /dev/null +++ b/package.json @@ -0,0 +1,29 @@ +{ + "name": "ollin-tui", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build": "XDG_CACHE_HOME=provision/.cache/xdg PLAYWRIGHT_BROWSERS_PATH=provision/.cache/playwright turbo run build --cache-dir provision/.cache/turbo --log-file provision/.cache/turbo/logs/build.json --output-logs=none", + "dev": "make dev", + "format": "make format", + "format-all": "make format-all", + "test": "XDG_CACHE_HOME=provision/.cache/xdg PLAYWRIGHT_BROWSERS_PATH=provision/.cache/playwright turbo run test --cache-dir provision/.cache/turbo --log-file provision/.cache/turbo/logs/test.json --output-logs=full", + "typecheck": "XDG_CACHE_HOME=provision/.cache/xdg PLAYWRIGHT_BROWSERS_PATH=provision/.cache/playwright turbo run typecheck --cache-dir provision/.cache/turbo --log-file provision/.cache/turbo/logs/typecheck.json --output-logs=none" + }, + "devDependencies": { + "@ollin/artefacts": "workspace:*", + "@ollin/tsconfig": "workspace:*", + "@types/node": "^25.6.2", + "turbo": "^2.9.12", + "typescript": "^6.0.3", + "vitest": "^4.1.5" + }, + "packageManager": "pnpm@11.1.1", + "pnpm": { + "onlyBuiltDependencies": [ + "@opentui/core", + "esbuild" + ] + } +} diff --git a/packages/acceptance/package.json b/packages/acceptance/package.json new file mode 100644 index 00000000..7a184d1f --- /dev/null +++ b/packages/acceptance/package.json @@ -0,0 +1,21 @@ +{ + "name": "@ollin/acceptance", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "test": "XDG_CACHE_HOME=../../provision/.cache/xdg PLAYWRIGHT_BROWSERS_PATH=../../provision/.cache/playwright vitest run --config vitest.config.ts", + "typecheck": "XDG_CACHE_HOME=../../provision/.cache/xdg PLAYWRIGHT_BROWSERS_PATH=../../provision/.cache/playwright tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@ollin/tui": "workspace:*", + "@opentui/core": "^0.4.0" + }, + "devDependencies": { + "@ollin/artefacts": "workspace:*", + "@ollin/tsconfig": "workspace:*", + "@types/node": "^25.6.2", + "typescript": "^6.0.3", + "vitest": "^4.1.5" + } +} diff --git a/packages/acceptance/tests/helpers.test.ts b/packages/acceptance/tests/helpers.test.ts new file mode 100644 index 00000000..9b7bd211 --- /dev/null +++ b/packages/acceptance/tests/helpers.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from 'vitest'; + +import { + confirm, + createMemoryOutput, + createScriptedInput, + form, + multiselect, + note, + error, + info, + intro, + outro, + number, + progress, + select, + spin, + stream, + table, + text, + title, + withPromptEnvironment, +} from '@ollin/tui'; + +const scripted = async (lines: string[], callback: () => Promise): Promise<{ output: string; result: T }> => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(lines), + output, + error: output, + interactive: true, + }, + callback, + ); + + return { output: output.text(), result }; +}; + +describe('prompt helpers', () => { + it('reads text and number prompts', async () => { + await expect(scripted(['A', 'd', 'a', '\n'], () => text('Name'))).resolves.toMatchObject({ result: 'Ada' }); + + await expect(scripted(['4', '2', '\n'], () => number({ message: 'Age', integer: true }))).resolves.toMatchObject({ result: 42 }); + }); + + it('reads confirm and choice prompts', async () => { + await expect(scripted(['n'], () => confirm('Continue'))).resolves.toMatchObject({ result: false }); + + await expect( + scripted(['2'], () => + select({ + message: 'Framework', + options: ['Ollin', 'Vue'], + }), + ), + ).resolves.toMatchObject({ result: 'Vue' }); + + await expect( + scripted(['1,2'], () => + multiselect({ + message: 'Tools', + options: ['Prompts', 'OpenTUI'], + }), + ), + ).resolves.toMatchObject({ result: ['Prompts', 'OpenTUI'] }); + }); + + it('runs form builders', async () => { + const response = await scripted(['A', 'd', 'a', '\n', 'y'], () => + form().text('Name', '', '', false, undefined, '', 'name').confirm('Active', true, 'Yes', 'No', false, undefined, '', 'active').submit(), + ); + + expect(response.result.name).toBe('Ada'); + expect(response.result.active).toBe(true); + }); + + it('writes output helpers', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + note('Hello'); + error('Nope'); + info('Facts'); + intro('Start'); + outro('Done'); + table({ headers: ['Name'], rows: [['Ollin']] }); + title('Demo'); + }); + + expect(output.text()).toContain('Hello'); + expect(output.text()).toContain('Nope'); + expect(output.text()).toContain('Facts'); + expect(output.text()).toContain('Start'); + expect(output.text()).toContain('Done'); + expect(output.text()).toContain('| Name'); + expect(output.text()).toContain('\u001B]0;Demo\u0007'); + }); + + it('runs status helpers', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + await spin(async () => 'done', { message: 'Working' }); + + const bar = progress(2, 'Files'); + + bar.advance(); + bar.advance(); + bar.finish(); + + await stream(['a', 'b']); + }); + + expect(output.text()).toContain('Working'); + expect(output.text()).toContain('┌ Files '); + expect(output.text()).toContain('2 / 2'); + expect(output.text()).toContain('ab'); + }); +}); diff --git a/packages/acceptance/tests/opentui.test.ts b/packages/acceptance/tests/opentui.test.ts new file mode 100644 index 00000000..8dd293f1 --- /dev/null +++ b/packages/acceptance/tests/opentui.test.ts @@ -0,0 +1,42 @@ +import { Text } from '@opentui/core'; +import { createTestRenderer } from '@opentui/core/testing'; +import { describe, expect, it } from 'vitest'; +import { renderOpenTuiTextFrame } from '@ollin/tui'; + +describe('OpenTUI memory renderer', () => { + it('captures rendered text without writing to the real terminal', async () => { + let testRenderer: Awaited>; + + try { + testRenderer = await createTestRenderer({ width: 20, height: 4 }); + } catch (error) { + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain('native FFI is not available'); + + return; + } + + const { renderer, renderOnce, captureCharFrame } = testRenderer; + + try { + renderer.root.add(Text({ content: 'Prompt' })); + + await renderOnce(); + + expect(captureCharFrame()).toContain('Prompt'); + } finally { + renderer.destroy(); + } + }); + + it('renders through the package OpenTUI adapter', async () => { + try { + const frame = await renderOpenTuiTextFrame('Prompt', { width: 20, height: 4 }); + + expect(frame.text).toContain('Prompt'); + } catch (error) { + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain('native FFI is not available'); + } + }); +}); diff --git a/packages/acceptance/tests/package.test.ts b/packages/acceptance/tests/package.test.ts new file mode 100644 index 00000000..53d40884 --- /dev/null +++ b/packages/acceptance/tests/package.test.ts @@ -0,0 +1,231 @@ +import { execFileSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +const testPath = dirname(fileURLToPath(import.meta.url)); +const acceptancePath = dirname(testPath); +const packagesPath = dirname(acceptancePath); +const workspacePath = dirname(packagesPath); +const acceptanceCachePath = join(workspacePath, 'provision', '.cache', 'vitest', 'acceptance'); + +describe('package consumption', () => { + it('imports the built public entrypoint through ESM package resolution', () => { + execFileSync('pnpm', ['--filter', '@ollin/tui', 'build'], { + cwd: workspacePath, + stdio: 'pipe', + }); + + const output = execFileSync( + process.execPath, + ['--input-type=module', '-e', "const module = await import('@ollin/tui'); console.log([typeof module.text, typeof module.table, typeof module.stream].join(','));"], + { + cwd: acceptancePath, + encoding: 'utf8', + }, + ); + + expect(output.trim()).toBe('function,function,function'); + }, 30_000); + + it('exports the complete runtime helper surface from the root entrypoint', () => { + execFileSync('pnpm', ['--filter', '@ollin/tui', 'build'], { + cwd: workspacePath, + stdio: 'pipe', + }); + + const expectedExports = [ + 'FormBuilder', + 'Key', + 'Logger', + 'Progress', + 'PromptValidationError', + 'Stream', + 'alert', + 'applyTypedKey', + 'autocomplete', + 'backgroundColor', + 'cancelUsing', + 'clear', + 'clearTerminal', + 'commandExists', + 'configurePrompts', + 'confirm', + 'createMemoryOutput', + 'createScriptedInput', + 'cursorToStart', + 'dataTable', + 'datatable', + 'eraseLine', + 'erasePreviousLines', + 'error', + 'executeNotificationCommand', + 'fallbackUsing', + 'fallbackWhen', + 'foregroundColor', + 'form', + 'grid', + 'hideCursor', + 'info', + 'intro', + 'keyFromEvent', + 'multisearch', + 'multiselect', + 'note', + 'notificationCommand', + 'notificationCommands', + 'notify', + 'notifyForPlatform', + 'number', + 'oneOf', + 'outro', + 'parseAnsiSegments', + 'parseAnsiText', + 'password', + 'pause', + 'progress', + 'promptEnvironment', + 'readTypedValue', + 'renderOpenTuiTextFrame', + 'search', + 'select', + 'setTerminalTitle', + 'showCursor', + 'spin', + 'stream', + 'suggest', + 'supportsTrueColor', + 'table', + 'task', + 'terminalSize', + 'text', + 'textarea', + 'title', + 'truncate', + 'validateUsing', + 'visibleWidth', + 'warning', + 'withPromptEnvironment', + 'wrap', + ]; + + const output = execFileSync( + process.execPath, + [ + '--input-type=module', + '-e', + ` + const module = await import('@ollin/tui'); + const expected = ${JSON.stringify(expectedExports)}; + const missing = expected.filter((name) => typeof module[name] === 'undefined'); + console.log(JSON.stringify({ missing, count: Object.keys(module).length })); + `, + ], + { + cwd: acceptancePath, + encoding: 'utf8', + }, + ); + + expect(JSON.parse(output) as { missing: string[] }).toEqual({ missing: [], count: expectedExports.length }); + }, 30_000); + + it('exports public helper types from the built root entrypoint', () => { + execFileSync('pnpm', ['--filter', '@ollin/tui', 'build'], { + cwd: workspacePath, + stdio: 'pipe', + }); + + const typecheckCachePath = join(acceptanceCachePath, 'types'); + + mkdirSync(typecheckCachePath, { recursive: true }); + + const consumerDirectory = mkdtempSync(join(typecheckCachePath, 'consumer-')); + const packageScopeDirectory = join(consumerDirectory, 'node_modules', '@ollin'); + const consumerPath = join(consumerDirectory, 'consumer.ts'); + + try { + mkdirSync(packageScopeDirectory, { recursive: true }); + symlinkSync(join(packagesPath, 'tui'), join(packageScopeDirectory, 'tui'), 'dir'); + writeFileSync( + consumerPath, + ` + import type { + ChoiceOptions, + ConfirmPromptOptions, + FormResponses, + FormStep, + FormStepCondition, + KeyboardEventLike, + KeyName, + KeyValue, + NoteType, + PromptCancelHandler, + NotificationPlatform, + SelectPromptOptions, + SuggestOptions, + TaskDefinition, + TerminalSize, + TypedValueOptions, + TypedValueReadResult, + TypedValueState + } from '@ollin/tui'; + + const choices: ChoiceOptions = ['one', { label: 'Two', value: 'two' }]; + const confirmOptions: ConfirmPromptOptions = { message: 'Continue?', default: true }; + const selectOptions: SelectPromptOptions = { message: 'Pick', options: choices }; + const suggestOptions: SuggestOptions = { message: 'Suggest', options: ['one'] }; + const formResponses: FormResponses = [] as unknown as FormResponses; + const formStep: FormStep = { condition: true, ignoreWhenReverting: false, run: () => null }; + const formStepCondition: FormStepCondition = async () => true; + const event: KeyboardEventLike = { name: 'return' }; + const keyName: KeyName = '\\n'; + const keyValue: KeyValue = keyName; + const noteType: NoteType = 'info'; + const notificationPlatform: NotificationPlatform = 'linux'; + const cancel: PromptCancelHandler = () => null; + const taskDefinition: TaskDefinition = { title: 'Build', task: () => 'done' }; + const terminalSize: TerminalSize = { columns: 80, rows: 24 }; + const typedOptions: TypedValueOptions = { default: 'value' }; + const typedState: TypedValueState = { cursor: 0, value: '' }; + const typedResult: TypedValueReadResult = { cancelled: false, value: 'value' }; + + void [ + confirmOptions, + selectOptions, + suggestOptions, + formResponses, + formStep, + formStepCondition, + event, + keyValue, + noteType, + notificationPlatform, + cancel, + taskDefinition, + terminalSize, + typedOptions, + typedState, + typedResult + ]; + `, + ); + + try { + execFileSync('pnpm', ['exec', 'tsc', '--ignoreConfig', '--module', 'NodeNext', '--moduleResolution', 'NodeNext', '--target', 'ES2022', '--strict', '--noEmit', consumerPath], { + cwd: acceptancePath, + stdio: 'pipe', + }); + } catch (error) { + const result = error as { stderr?: Buffer | string; stdout?: Buffer | string }; + const stderr = result.stderr?.toString() ?? ''; + const stdout = result.stdout?.toString() ?? ''; + + throw new Error([stdout, stderr].filter(Boolean).join('\n')); + } + } finally { + rmSync(consumerDirectory, { force: true, recursive: true }); + } + }, 30_000); +}); diff --git a/packages/acceptance/tsconfig.json b/packages/acceptance/tsconfig.json new file mode 100644 index 00000000..e10cbc7c --- /dev/null +++ b/packages/acceptance/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": ["@ollin/tsconfig/base.json", "@ollin/artefacts/tsconfig/acceptance.json"], + "compilerOptions": { + "baseUrl": "..", + "paths": { + "@ollin/tui": ["tui/src/index.ts"], + "#tui/*": ["tui/src/*"], + "#tui/prompts/*": ["tui/src/prompts/*"] + } + }, + "include": ["tests/**/*.ts", "vitest.config.ts", "../tui/src/**/*.ts"] +} diff --git a/packages/acceptance/vitest.config.ts b/packages/acceptance/vitest.config.ts new file mode 100644 index 00000000..17427c87 --- /dev/null +++ b/packages/acceptance/vitest.config.ts @@ -0,0 +1,30 @@ +import { dirname, join } from 'node:path'; +import { defineConfig } from 'vitest/config'; +import { fileURLToPath } from 'node:url'; + +const packagePath = dirname(fileURLToPath(import.meta.url)); +const packagesPath = dirname(packagePath); +const workspacePath = dirname(packagesPath); +const cachePath = join(workspacePath, 'provision', '.cache', 'vitest', 'acceptance'); +const sourcePath = join(packagesPath, 'tui', 'src', 'index.ts'); +const internalSourcePath = join(packagesPath, 'tui', 'src', '$1'); + +export default defineConfig({ + cacheDir: cachePath, + resolve: { + alias: [ + { + find: '@ollin/tui', + replacement: sourcePath, + }, + { + find: /^#tui\/(.+)$/u, + replacement: internalSourcePath, + }, + ], + }, + test: { + environment: 'node', + globals: false, + }, +}); diff --git a/packages/artefacts/package.json b/packages/artefacts/package.json new file mode 100644 index 00000000..af007a96 --- /dev/null +++ b/packages/artefacts/package.json @@ -0,0 +1,21 @@ +{ + "name": "@ollin/artefacts", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./tsconfig/*.json": "./tsconfig/*.json" + }, + "scripts": { + "typecheck": "XDG_CACHE_HOME=../../provision/.cache/xdg PLAYWRIGHT_BROWSERS_PATH=../../provision/.cache/playwright tsc -p tsconfig.json --noEmit" + }, + "devDependencies": { + "@ollin/tsconfig": "workspace:*", + "@types/node": "^25.6.2", + "typescript": "^6.0.3" + } +} diff --git a/packages/artefacts/src/index.ts b/packages/artefacts/src/index.ts new file mode 100644 index 00000000..740a539e --- /dev/null +++ b/packages/artefacts/src/index.ts @@ -0,0 +1,34 @@ +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const artefactsRoot = dirname(dirname(fileURLToPath(import.meta.url))); + +export const workspaceRoot = dirname(dirname(artefactsRoot)); + +export const provisionRoot = resolve(workspaceRoot, 'provision'); + +export const artefactsPath = (...segments: string[]): string => resolve(artefactsRoot, ...segments); + +export const cacheDir = (...segments: string[]): string => resolve(provisionRoot, '.cache', ...segments); + +export const toolBinCacheDir = (...segments: string[]): string => cacheDir('bin', ...segments); + +export const goBuildCacheDir = (...segments: string[]): string => cacheDir('go-build', ...segments); + +export const goModCacheDir = (...segments: string[]): string => cacheDir('go-mod', ...segments); + +export const goTmpCacheDir = (...segments: string[]): string => cacheDir('go-tmp', ...segments); + +export const playwrightCacheDir = (...segments: string[]): string => cacheDir('playwright', ...segments); + +export const tsBuildCacheDir = (...segments: string[]): string => cacheDir('tsbuild', ...segments); + +export const turboCacheDir = (...segments: string[]): string => cacheDir('turbo', ...segments); + +export const vitestCacheDir = (...segments: string[]): string => cacheDir('vitest', ...segments); + +export const xdgCacheDir = (...segments: string[]): string => cacheDir('xdg', ...segments); + +export const distDir = (...segments: string[]): string => artefactsPath('dist', ...segments); + +export const logDir = (...segments: string[]): string => artefactsPath('.logs', ...segments); diff --git a/packages/artefacts/tsconfig.json b/packages/artefacts/tsconfig.json new file mode 100644 index 00000000..7519ca98 --- /dev/null +++ b/packages/artefacts/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "@ollin/tsconfig/base.json", + "compilerOptions": { + "baseUrl": ".", + "tsBuildInfoFile": "../../provision/.cache/tsbuild/artefacts.tsbuildinfo", + "paths": { + "#artefacts/*": ["./src/*"] + } + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/artefacts/tsconfig/acceptance.json b/packages/artefacts/tsconfig/acceptance.json new file mode 100644 index 00000000..7de753e2 --- /dev/null +++ b/packages/artefacts/tsconfig/acceptance.json @@ -0,0 +1,5 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "../../../provision/.cache/tsbuild/acceptance.tsbuildinfo" + } +} diff --git a/packages/artefacts/tsconfig/tui.json b/packages/artefacts/tsconfig/tui.json new file mode 100644 index 00000000..058cde9e --- /dev/null +++ b/packages/artefacts/tsconfig/tui.json @@ -0,0 +1,5 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "../../../provision/.cache/tsbuild/tui.tsbuildinfo" + } +} diff --git a/packages/docs/components.json b/packages/docs/components.json new file mode 100644 index 00000000..acd2bf12 --- /dev/null +++ b/packages/docs/components.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://shadcn-vue.com/schema.json", + "style": "new-york", + "typescript": true, + "tailwind": { + "config": "", + "css": "vitepress/theme/styles.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@docs-components", + "composables": "@docs-composables", + "lib": "@docs-lib", + "ui": "@docs-ui", + "utils": "@docs-lib/utils" + } +} diff --git a/packages/docs/examples/basic-prompts.ts b/packages/docs/examples/basic-prompts.ts new file mode 100644 index 00000000..9a659750 --- /dev/null +++ b/packages/docs/examples/basic-prompts.ts @@ -0,0 +1,42 @@ +import { number, password, text, textarea } from '@ollin/tui'; + +export async function basicPromptExamples(): Promise { + const name = await text({ + message: 'What is your name?', + placeholder: 'Ada Lovelace', + required: 'A name is required.', + validate: (value) => (value.length < 2 ? 'Enter at least two characters.' : null), + }); + + const biography = await textarea({ + message: 'Tell us about your project.', + placeholder: 'This project helps...', + rows: 5, + required: true, + }); + + const copies = await number({ + message: 'How many copies?', + default: 1, + integer: true, + min: 1, + max: 100, + validate: (value) => (Number(value) > 100 ? 'Choose 100 or fewer copies.' : null), + }); + + const token = await password({ + message: 'Enter your API token.', + hint: 'Input is masked while typing.', + required: true, + }); + + void [name, biography, copies, token]; +} + +export async function labelFirstPromptExamples(): Promise { + const name = await text('What is your name?', 'Ada Lovelace', '', true); + + const copies = await number('How many copies?', '5', 1, true); + + void [name, copies]; +} diff --git a/packages/docs/examples/choice-prompts.ts b/packages/docs/examples/choice-prompts.ts new file mode 100644 index 00000000..f8a01cec --- /dev/null +++ b/packages/docs/examples/choice-prompts.ts @@ -0,0 +1,51 @@ +import { autocomplete, confirm, multiselect, select, suggest } from '@ollin/tui'; + +const roles = { + member: 'Member', + contributor: 'Contributor', + owner: 'Owner', +}; + +export async function choicePromptExamples(): Promise { + const accepted = await confirm({ + message: 'Do you accept the terms?', + default: false, + yes: 'I accept', + no: 'I decline', + required: 'Accept the terms to continue.', + }); + + const role = await select({ + message: 'What role should the user have?', + options: roles, + default: 'member', + info: (value) => (value === 'owner' ? 'Full access to the project.' : null), + }); + + const permissions = await multiselect({ + message: 'What permissions should be assigned?', + options: { + read: 'Read', + create: 'Create', + update: 'Update', + delete: 'Delete', + }, + default: ['read'], + required: 'Select at least one permission.', + validate: (values) => (values.includes('read') ? null : 'Every user needs read access.'), + }); + + const author = await suggest({ + message: 'Author name', + options: (query) => ['Ada', 'Grace', 'Lin'].filter((name) => name.toLowerCase().includes(query.toLowerCase())), + placeholder: 'Ada', + }); + + const runtime = await autocomplete({ + message: 'Runtime', + options: ['Node.js', 'Bun', 'Deno'], + default: 'Node.js', + }); + + void [accepted, role, permissions, author, runtime]; +} diff --git a/packages/docs/examples/forms.ts b/packages/docs/examples/forms.ts new file mode 100644 index 00000000..fb0ecab4 --- /dev/null +++ b/packages/docs/examples/forms.ts @@ -0,0 +1,28 @@ +import { form } from '@ollin/tui'; + +export async function formExample(): Promise { + const responses = await form() + .intro('Create an account') + .text({ message: 'Name', required: true }, 'name') + .confirm({ message: 'Enable notifications?', default: true }, 'notifications') + .addIf( + (responses) => responses.notifications === true, + async () => 'email', + 'notificationChannel', + ) + .outro('Account configured') + .submit(); + + void responses; +} + +export async function callbackFormExample(): Promise { + const responses = await form(async (builder) => + builder + .text({ message: 'Project name', required: true }, 'project') + .select({ message: 'Visibility', options: ['Private', 'Public'], default: 'Private' }, 'visibility') + .submit(), + ); + + void responses; +} diff --git a/packages/docs/examples/output.ts b/packages/docs/examples/output.ts new file mode 100644 index 00000000..d1421886 --- /dev/null +++ b/packages/docs/examples/output.ts @@ -0,0 +1,31 @@ +import { alert, clear, dataTable, error, grid, info, intro, note, outro, table, title, warning } from '@ollin/tui'; + +export function outputExamples(): void { + intro('Deployment'); + info('Preparing release artefacts.'); + warning('Two optional checks were skipped.'); + error('A required check failed.'); + alert('Manual review required.'); + note('Use output helpers for durable terminal messages.', 'info'); + + table( + ['Name', 'Runtime'], + [ + ['Prompts', 'TypeScript'], + ['Renderer', 'OpenTUI'], + ], + ); + + dataTable({ + headers: ['Name', 'Role'], + rows: [ + ['Ada', 'Admin'], + ['Grace', 'Operator'], + ], + }); + + grid(['text', 'select', 'search', 'task']); + title('Ollin TUI'); + clear(); + outro('Done'); +} diff --git a/packages/docs/examples/search-prompts.ts b/packages/docs/examples/search-prompts.ts new file mode 100644 index 00000000..bcdc20de --- /dev/null +++ b/packages/docs/examples/search-prompts.ts @@ -0,0 +1,28 @@ +import { multisearch, search } from '@ollin/tui'; + +const users = new Map([ + [1, 'Lin Chen'], + [2, 'Ada Lovelace'], + [3, 'Grace Hopper'], +]); + +const matchingUsers = (query: string): Record => Object.fromEntries([...users].filter(([, name]) => name.toLowerCase().includes(query.toLowerCase()))); + +export async function searchPromptExamples(): Promise { + const userId = await search({ + message: 'Search for the user receiving the email.', + placeholder: 'Ada', + options: matchingUsers, + required: true, + info: (value) => `User id ${value}`, + }); + + const userIds = await multisearch({ + message: 'Search for users receiving the email.', + placeholder: 'Ada', + options: matchingUsers, + required: 'Select at least one user.', + }); + + void [userId, userIds]; +} diff --git a/packages/docs/examples/status.ts b/packages/docs/examples/status.ts new file mode 100644 index 00000000..4b49da2a --- /dev/null +++ b/packages/docs/examples/status.ts @@ -0,0 +1,25 @@ +import { progress, spin, stream, task } from '@ollin/tui'; + +export async function statusExamples(): Promise { + await spin('Installing dependencies', async () => 'installed'); + + await progress('Processing files', ['one.ts', 'two.ts'], async (file) => String(file).toUpperCase()); + + await task('Publishing', async (logger) => { + logger.info('Uploading assets'); + logger.success('Assets uploaded'); + + return 'published'; + }); + + await stream(['Preparing release\n', 'Release ready\n']); +} + +export function manualProgressExample(): void { + const bar = progress(3, 'Manual steps'); + + bar.start(); + bar.advance(); + bar.advance(); + bar.finish(); +} diff --git a/packages/docs/examples/testing.ts b/packages/docs/examples/testing.ts new file mode 100644 index 00000000..9c1fb8c4 --- /dev/null +++ b/packages/docs/examples/testing.ts @@ -0,0 +1,15 @@ +import { Key, createMemoryOutput, createScriptedInput, text, withPromptEnvironment } from '@ollin/tui'; + +export async function testingExample(): Promise { + const output = createMemoryOutput(); + const input = createScriptedInput(['Ada', Key.enter]); + + const name = await withPromptEnvironment({ input, output }, () => + text({ + message: 'Name', + required: true, + }), + ); + + return output.text() + name; +} diff --git a/packages/docs/package.json b/packages/docs/package.json new file mode 100644 index 00000000..5ea6d6c4 --- /dev/null +++ b/packages/docs/package.json @@ -0,0 +1,38 @@ +{ + "name": "docs", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "api": "XDG_CACHE_HOME=../../provision/.cache/xdg PLAYWRIGHT_BROWSERS_PATH=../../provision/.cache/playwright typedoc --options typedoc.json && cp src/api/README.md src/api/index.md", + "build": "XDG_CACHE_HOME=../../provision/.cache/xdg PLAYWRIGHT_BROWSERS_PATH=../../provision/.cache/playwright pnpm api && XDG_CACHE_HOME=../../provision/.cache/xdg PLAYWRIGHT_BROWSERS_PATH=../../provision/.cache/playwright vitepress build src --config vitepress/config.ts", + "dev": "XDG_CACHE_HOME=../../provision/.cache/xdg PLAYWRIGHT_BROWSERS_PATH=../../provision/.cache/playwright pnpm api && XDG_CACHE_HOME=../../provision/.cache/xdg PLAYWRIGHT_BROWSERS_PATH=../../provision/.cache/playwright vitepress dev src --config vitepress/config.ts --host 127.0.0.1", + "preview": "XDG_CACHE_HOME=../../provision/.cache/xdg PLAYWRIGHT_BROWSERS_PATH=../../provision/.cache/playwright vitepress preview src --config vitepress/config.ts --host 127.0.0.1", + "test": "XDG_CACHE_HOME=../../provision/.cache/xdg PLAYWRIGHT_BROWSERS_PATH=../../provision/.cache/playwright vitest run --config vitest.config.ts", + "typecheck": "XDG_CACHE_HOME=../../provision/.cache/xdg PLAYWRIGHT_BROWSERS_PATH=../../provision/.cache/playwright tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@fontsource/geist-mono": "^5.2.8", + "@fontsource/geist-sans": "^5.2.5", + "@ollin/tui": "workspace:*", + "@vueuse/core": "^14.3.0", + "@xterm/xterm": "^6.0.0", + "reka-ui": "^2.9.10" + }, + "devDependencies": { + "@ollin/tsconfig": "workspace:*", + "@tailwindcss/vite": "^4.3.0", + "@types/node": "^25.6.2", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "tailwind-merge": "^3.6.0", + "tailwindcss": "^4.3.0", + "typedoc": "^0.28.19", + "typedoc-plugin-markdown": "^4.12.0", + "typescript": "^6.0.3", + "vite": "^8.0.16", + "vitepress": "^1.6.4", + "vitest": "^4.1.5", + "vue": "^3.5.38" + } +} diff --git a/packages/docs/src/guide/autocomplete.md b/packages/docs/src/guide/autocomplete.md new file mode 100644 index 00000000..54b475df --- /dev/null +++ b/packages/docs/src/guide/autocomplete.md @@ -0,0 +1,51 @@ +# Autocomplete + +The `autocomplete` helper completes the current input from known options. + +```ts +import { autocomplete } from '@ollin/tui'; + +const runtime = await autocomplete({ + message: 'Runtime', + options: ['Node.js', 'Bun', 'Deno'], + default: 'Node.js', +}); +``` + +Use `autocomplete` when users should enter one value and suggestions should guide them toward a known set. + +Use `suggest` instead when arbitrary values are common and suggestions are only hints. + +## Complete Usage + +```ts +import { autocomplete } from '@ollin/tui'; + +const runtime = await autocomplete({ + message: 'Runtime', + options: ['Node.js', 'Bun', 'Deno'], + placeholder: 'Node.js', + default: 'Node.js', + required: true, + info: (value) => (value === 'Bun' ? 'Fast JavaScript runtime.' : null), + validate: (value) => (value === 'Deno' ? 'Deno is not enabled in this project.' : null), +}); + +const packageManager = await autocomplete('Package manager', ['pnpm', 'npm', 'yarn'], 'pnpm'); + +void [runtime, packageManager]; +``` + +## Consumer Call And Output + +```ts +import { autocomplete } from '@ollin/tui'; + +const runtime = await autocomplete({ + message: 'Runtime', + options: ['Node.js', 'Bun', 'Deno'], + default: 'Node.js', +}); +``` + + diff --git a/packages/docs/src/guide/available-prompts.md b/packages/docs/src/guide/available-prompts.md new file mode 100644 index 00000000..5929217b --- /dev/null +++ b/packages/docs/src/guide/available-prompts.md @@ -0,0 +1,54 @@ +# Available Prompts + +`@ollin/tui` provides prompt helpers for text input, choice selection, search flows, confirmation, pausing, and autocomplete. + +| Helper | Purpose | +| --- | --- | +| `text` | Read a single-line string. | +| `textarea` | Read multi-line text. | +| `number` | Read numeric input with validation helpers. | +| `password` | Read masked text input. | +| `confirm` | Ask for a boolean decision. | +| `select` | Choose one item from a list. | +| `multiselect` | Choose multiple items from a list. | +| `suggest` | Offer suggestions while still allowing free input. | +| `search` | Resolve one selected item from filtered options. | +| `multisearch` | Resolve multiple selected items from filtered options. | +| `pause` | Wait for the user to continue. | +| `autocomplete` | Complete an input from a known option list. | + +Object options use `message` as the visible prompt text. Label-first overloads are available for many helpers when a terse call site is useful. + +## Complete Prompt Tour + +```ts +import { autocomplete, confirm, multiselect, multisearch, number, password, pause, search, select, suggest, text, textarea } from '@ollin/tui'; + +const name = await text({ message: 'Name', required: true }); +const description = await textarea({ message: 'Description', rows: 4 }); +const copies = await number({ message: 'Copies', default: 1, integer: true, min: 1 }); +const secret = await password({ message: 'Token', required: true }); +const accepted = await confirm({ message: 'Continue?', default: true }); +const role = await select({ message: 'Role', options: ['Member', 'Owner'], default: 'Member' }); +const permissions = await multiselect({ message: 'Permissions', options: ['Read', 'Write', 'Delete'], default: ['Read'] }); +const author = await suggest({ message: 'Author', options: ['Ada', 'Grace', 'Lin'] }); +const runtime = await autocomplete({ message: 'Runtime', options: ['Node.js', 'Bun', 'Deno'] }); +const user = await search({ message: 'User', options: ['Ada Lovelace', 'Grace Hopper'], required: true }); +const reviewers = await multisearch({ message: 'Reviewers', options: ['Ada Lovelace', 'Grace Hopper'], required: true }); + +await pause('Review your selections, then press enter.'); + +void [name, description, copies, secret, accepted, role, permissions, author, runtime, user, reviewers]; +``` + +## Consumer Call And Output + +```ts +import { confirm, select, text } from '@ollin/tui'; + +const name = await text({ message: 'Name', required: true }); +const template = await select({ message: 'Template', options: ['cli', 'library', 'docs'] }); +const install = await confirm({ message: 'Install dependencies?', default: true }); +``` + + diff --git a/packages/docs/src/guide/clearing-terminal.md b/packages/docs/src/guide/clearing-terminal.md new file mode 100644 index 00000000..9ca67804 --- /dev/null +++ b/packages/docs/src/guide/clearing-terminal.md @@ -0,0 +1,51 @@ +# Clearing the Terminal + +Use `clear` to clear the configured prompt output. + +```ts +import { clear } from '@ollin/tui'; + +clear(); +``` + +The package also exports lower-level helpers such as `clearTerminal`, `eraseLine`, and `erasePreviousLines` for direct terminal behaviour. + +```ts +import { clearTerminal } from '@ollin/tui'; + +clearTerminal(); +``` + +## When To Use It + +Use `clear` sparingly for full-screen command flows where previous output would distract from the current step. For status renderers, prefer `spin`, `progress`, `task`, or `stream`; they handle their own redraw cleanup. + +## Complete Usage + +```ts +import { clear, clearTerminal, eraseLine, erasePreviousLines, form } from '@ollin/tui'; + +clear(); +clearTerminal(); +eraseLine(); +erasePreviousLines(2); + +const responses = await form().clear('clearStep').text({ message: 'Continue with command?', default: 'yes' }, 'continue').submit(); + +void responses; +``` + +## Behaviour + +`clear` and `clearTerminal` write terminal control sequences. `eraseLine` clears the current line, and `erasePreviousLines` removes a known number of previously rendered lines. + +## Consumer Call And Output + +```ts +import { clear, info } from '@ollin/tui'; + +clear(); +info('Fresh screen ready.'); +``` + + diff --git a/packages/docs/src/guide/confirm.md b/packages/docs/src/guide/confirm.md new file mode 100644 index 00000000..1ecf8512 --- /dev/null +++ b/packages/docs/src/guide/confirm.md @@ -0,0 +1,50 @@ +# Confirm + +The `confirm` helper asks for a boolean response. + +```ts +import { confirm } from '@ollin/tui'; + +const accepted = await confirm({ + message: 'Do you accept the terms?', + default: false, + yes: 'I accept', + no: 'I decline', + required: 'Accept the terms to continue.', +}); +``` + +`confirm` returns `true` or `false`. When `required` is enabled, a negative answer is rejected with the default or custom message. + +## Complete Usage + +```ts +import { confirm } from '@ollin/tui'; + +const accepted = await confirm({ + message: 'Do you accept the terms?', + default: false, + yes: 'I accept', + no: 'I decline', + hint: 'Required before the command can continue.', + required: 'Accept the terms to continue.', + transform: (value) => value, +}); + +const overwrite = await confirm('Overwrite existing files?', false, 'Overwrite', 'Cancel'); + +void [accepted, overwrite]; +``` + +## Consumer Call And Output + +```ts +import { confirm } from '@ollin/tui'; + +const publish = await confirm({ + message: 'Publish release?', + default: false, +}); +``` + + diff --git a/packages/docs/src/guide/fallbacks.md b/packages/docs/src/guide/fallbacks.md new file mode 100644 index 00000000..ff932c2e --- /dev/null +++ b/packages/docs/src/guide/fallbacks.md @@ -0,0 +1,61 @@ +# Custom Environments + +`@ollin/tui` uses `PromptEnvironment` as the boundary for input, output, and interactivity. + +Use `withPromptEnvironment` when a command needs custom streams or deterministic input. + +```ts +import { createMemoryOutput, createScriptedInput, text, withPromptEnvironment } from '@ollin/tui'; + +const output = createMemoryOutput(); +const input = createScriptedInput(['Ada']); + +const name = await withPromptEnvironment({ input, output }, () => + text({ + message: 'Name', + required: true, + }), +); +``` + +Keep environment-specific behaviour near the command entrypoint so prompt code can stay portable. + +## Complete Usage + +```ts +import { createMemoryOutput, createScriptedInput, select, text, withPromptEnvironment } from '@ollin/tui'; + +const output = createMemoryOutput(); +const input = createScriptedInput(['Ada', 'Member']); + +const result = await withPromptEnvironment({ input, interactive: false, output }, async () => { + const name = await text({ + message: 'Name', + default: 'Ada', + required: true, + }); + + const role = await select({ + message: 'Role', + options: ['Member', 'Owner'], + default: 'Member', + }); + + return { name, role }; +}); + +void [result, output.text()]; +``` + +## Consumer Call And Output + +```ts +import { createMemoryOutput, createScriptedInput, text, withPromptEnvironment } from '@ollin/tui'; + +const output = createMemoryOutput(); +const input = createScriptedInput(['Ada']); + +const name = await withPromptEnvironment({ input, output }, () => text({ message: 'Name' })); +``` + + diff --git a/packages/docs/src/guide/forms.md b/packages/docs/src/guide/forms.md new file mode 100644 index 00000000..174dbc6e --- /dev/null +++ b/packages/docs/src/guide/forms.md @@ -0,0 +1,83 @@ +# Forms + +The `form` helper composes prompt, output, and status steps into one submission flow. + +```ts +import { form } from '@ollin/tui'; + +const responses = await form() + .intro('Create an account') + .text({ message: 'Name', required: true }, 'name') + .confirm({ message: 'Enable notifications?', default: true }, 'notifications') + .outro('Account configured') + .submit(); +``` + +Named steps are written into the response object. The builder also supports conditional steps with `addIf`. + +```ts +import { form } from '@ollin/tui'; + +await form() + .confirm({ message: 'Enable notifications?', default: true }, 'notifications') + .addIf( + (responses) => responses.notifications === true, + async () => 'email', + 'notificationChannel', + ) + .submit(); +``` + +You can also pass an async callback to `form` and submit from inside the callback. + +## Complete Usage + +```ts +import { form } from '@ollin/tui'; + +const responses = await form(async (builder) => + builder + .intro('Create a project') + .text({ message: 'Project name', required: true }, 'project') + .select( + { + message: 'Visibility', + options: ['Private', 'Public'], + default: 'Private', + }, + 'visibility', + ) + .multiselect( + { + message: 'Features', + options: ['docs', 'tests', 'release'], + default: ['docs'], + required: true, + }, + 'features', + ) + .addIf( + (values) => Array.isArray(values.features) && values.features.includes('release'), + async () => 'manual-approval', + 'releasePolicy', + ) + .table(['Field', 'Value'], [['Status', 'Configured']], 'summary') + .outro('Project configured') + .submit(), +); + +void responses; +``` + +## Consumer Call And Output + +```ts +import { form } from '@ollin/tui'; + +const responses = await form() + .text({ message: 'Project name', required: true }, 'project') + .select({ message: 'Visibility', options: ['Private', 'Public'], default: 'Private' }, 'visibility') + .submit(); +``` + + diff --git a/packages/docs/src/guide/informational-messages.md b/packages/docs/src/guide/informational-messages.md new file mode 100644 index 00000000..3e73bf2d --- /dev/null +++ b/packages/docs/src/guide/informational-messages.md @@ -0,0 +1,50 @@ +# Informational Messages + +Use informational helpers for durable terminal messages that are not prompts. + +```ts +import { alert, error, info, intro, note, outro, warning } from '@ollin/tui'; + +intro('Deployment'); +info('Preparing release artefacts.'); +warning('Two optional checks were skipped.'); +error('A required check failed.'); +alert('Manual review required.'); +note('Use output helpers for durable terminal messages.', 'info'); +outro('Done'); +``` + +These helpers write to the configured prompt output. In tests, configure a memory output and assert against the captured text. + +## Complete Usage + +```ts +import { alert, error, form, info, intro, note, outro, warning } from '@ollin/tui'; + +intro('Deployment'); +info('Preparing release artefacts.'); +warning('Two optional checks were skipped.'); +error('A required check failed.'); +alert('Manual review required.'); +note('Use notes for longer terminal messages.', 'info'); +outro('Done'); + +await form() + .info('Fetching workspace metadata') + .warning('Using local defaults') + .note('Review the generated files before publishing.', 'info') + .submit(); +``` + +## Consumer Call And Output + +```ts +import { info, intro, outro, warning } from '@ollin/tui'; + +intro('Release'); +info('Preparing release artefacts.'); +warning('Using local defaults.'); +outro('Ready'); +``` + + diff --git a/packages/docs/src/guide/installation.md b/packages/docs/src/guide/installation.md new file mode 100644 index 00000000..664f391f --- /dev/null +++ b/packages/docs/src/guide/installation.md @@ -0,0 +1,73 @@ +# Installation + +Install the package from the workspace or package registry that contains `@ollin/tui`. + +```sh +pnpm add @ollin/tui +``` + +Then import helpers from the public package entrypoint. + +```ts +import { text } from '@ollin/tui'; + +const name = await text({ + message: 'What is your name?', + required: true, +}); +``` + +The package is ESM-first. The workspace acceptance tests verify that the built root entrypoint can be consumed through ESM package resolution. + +## Local Workspace + +Inside this repository, `@ollin/tui` is a workspace package. The docs package depends on it with `workspace:*`, so examples compile against the package source. + +## Common Imports + +Import only the helpers your command needs. + +```ts +import { + confirm, + createMemoryOutput, + createScriptedInput, + form, + multiselect, + progress, + search, + select, + spin, + table, + task, + text, + withPromptEnvironment, +} from '@ollin/tui'; + +const project = await text({ message: 'Project name', required: true }); +const runtime = await select({ message: 'Runtime', options: ['Node.js', 'Bun', 'Deno'] }); +const features = await multiselect({ message: 'Features', options: ['docs', 'tests', 'release'], required: true }); + +if (await confirm({ message: 'Create project?', default: true })) { + await spin('Scaffolding project', async () => project); + await progress('Enabling features', features, async (feature) => String(feature)); + await task('Writing summary', async (logger) => logger.success(`Created ${project} for ${runtime}`)); +} + +table(['Project', 'Runtime'], [[project, runtime]]); + +const output = createMemoryOutput(); +const input = createScriptedInput(['Ada']); + +await withPromptEnvironment({ input, output }, () => search({ message: 'Owner', options: ['Ada', 'Grace'] })); +``` + +## Consumer Call And Output + +```ts +import { text } from '@ollin/tui'; + +const project = await text({ message: 'Project name', required: true }); +``` + + diff --git a/packages/docs/src/guide/introduction.md b/packages/docs/src/guide/introduction.md new file mode 100644 index 00000000..61b69e75 --- /dev/null +++ b/packages/docs/src/guide/introduction.md @@ -0,0 +1,74 @@ +# Introduction + +`@ollin/tui` is a prompt toolkit for command-line applications. It exposes prompt helpers, form composition, terminal output helpers, status indicators, and a testable prompt environment from the public `@ollin/tui` entrypoint. + +This guide documents the current `@ollin/tui` package surface with examples you can adapt directly in commands. + +## What Is Included + +The package exports prompt helpers such as `text`, `textarea`, `number`, `password`, `confirm`, `select`, `multiselect`, `suggest`, `autocomplete`, `search`, `multisearch`, and `pause`. + +It also exports `form`, output helpers such as `info`, `warning`, `table`, and `dataTable`, status helpers such as `spin`, `progress`, `task`, and `stream`, and terminal helpers such as `title` and `clear`. + +## Package Overview + +The package is implemented as TypeScript-first ESM and exposes its supported runtime surface through the public `@ollin/tui` entrypoint. The current implementation includes prompt helpers, form composition, output and status helpers, terminal controls, string and key utilities, OpenTUI text-frame rendering, and deterministic environments for tests or non-standard runtimes. + +The documentation follows that public package surface. Use the guide for task-oriented examples and the API reference for generated type and overload details. Runtime behavior and package consumption are covered by local tests, with the detailed reference parity map and completion audit tracked in `UPSTREAM.md`. + +## Complete CLI Flow + +This is a complete workflow using prompts, forms, output, and status helpers from `@ollin/tui`. + +```ts +import { form, progress, table, task } from '@ollin/tui'; + +const responses = await form() + .intro('Create a release') + .text({ message: 'Release name', required: true }, 'name') + .select( + { + message: 'Channel', + options: ['stable', 'beta', 'nightly'], + default: 'stable', + }, + 'channel', + ) + .multiselect( + { + message: 'Checks to run', + options: ['typecheck', 'build', 'smoke'], + default: ['typecheck', 'build'], + required: true, + }, + 'checks', + ) + .confirm({ message: 'Publish after checks pass?', default: false }, 'publish') + .submit(); + +table(['Field', 'Value'], [ + ['Name', String(responses.name)], + ['Channel', String(responses.channel)], + ['Publish', String(responses.publish)], +]); + +await progress('Running checks', responses.checks as string[], async (check) => String(check).toUpperCase()); + +await task('Finalising release', async (logger) => { + logger.info('Writing release metadata'); + logger.success('Release workflow complete'); +}); +``` + +The same helpers can be used one at a time, chained in `form()`, or wrapped with a custom `PromptEnvironment` for tests and non-standard runtimes. + +## Consumer Call And Output + +```ts +import { confirm, text } from '@ollin/tui'; + +const release = await text({ message: 'Release name', required: true }); +const publish = await confirm({ message: 'Publish now?', default: false }); +``` + + diff --git a/packages/docs/src/guide/multisearch.md b/packages/docs/src/guide/multisearch.md new file mode 100644 index 00000000..c2e796ec --- /dev/null +++ b/packages/docs/src/guide/multisearch.md @@ -0,0 +1,63 @@ +# Multi-search + +The `multisearch` helper combines search input with multiple selected results. + +```ts +import { multisearch } from '@ollin/tui'; + +const userIds = await multisearch({ + message: 'Search for users receiving the email.', + placeholder: 'Ada', + options: (query) => ({ + 1: 'Lin Chen', + 2: 'Ada Lovelace', + 3: 'Grace Hopper', + }), + required: 'Select at least one user.', +}); +``` + +Use this helper when the option set is too large for `multiselect` and should be narrowed by typed input. + +## Complete Usage + +```ts +import { multisearch } from '@ollin/tui'; + +const users = { + 1: 'Lin Chen', + 2: 'Ada Lovelace', + 3: 'Grace Hopper', +}; + +const reviewers = await multisearch({ + message: 'Search for reviewers.', + placeholder: 'Ada', + options: (query) => Object.fromEntries(Object.entries(users).filter(([, name]) => name.toLowerCase().includes(query.toLowerCase()))), + scroll: 10, + required: 'Select at least one reviewer.', + validate: (values) => (values.length > 3 ? 'Select no more than three reviewers.' : null), +}); + +const labels = await multisearch({ + message: 'Labels', + options: ['bug', 'docs', 'feature'], + default: ['docs'], +}); + +void [reviewers, labels]; +``` + +## Consumer Call And Output + +```ts +import { multisearch } from '@ollin/tui'; + +const reviewers = await multisearch({ + message: 'Reviewers', + options: ['Ada Lovelace', 'Grace Hopper', 'Lin Chen'], + required: true, +}); +``` + + diff --git a/packages/docs/src/guide/multiselect.md b/packages/docs/src/guide/multiselect.md new file mode 100644 index 00000000..6801cc26 --- /dev/null +++ b/packages/docs/src/guide/multiselect.md @@ -0,0 +1,62 @@ +# Multi-select + +The `multiselect` helper asks the user to choose multiple values. + +```ts +import { multiselect } from '@ollin/tui'; + +const permissions = await multiselect({ + message: 'What permissions should be assigned?', + options: { + read: 'Read', + create: 'Create', + update: 'Update', + delete: 'Delete', + }, + default: ['read'], + required: 'Select at least one permission.', + validate: (values) => (values.includes('read') ? null : 'Every user needs read access.'), +}); +``` + +By default, multiple-selection prompts can return an empty array. Use `required` to require at least one selected item. + +## Complete Usage + +```ts +import { multiselect } from '@ollin/tui'; + +const permissions = await multiselect({ + message: 'What permissions should be assigned?', + options: { + read: 'Read', + create: 'Create', + update: 'Update', + delete: 'Delete', + }, + default: ['read'], + scroll: 5, + required: 'Select at least one permission.', + info: (value) => (value === 'delete' ? 'Allows permanent deletion.' : null), + validate: (values) => (values.includes('read') ? null : 'Every user needs read access.'), +}); + +const checks = await multiselect('Checks to run', ['typecheck', 'build', 'smoke'], ['typecheck']); + +void [permissions, checks]; +``` + +## Consumer Call And Output + +```ts +import { multiselect } from '@ollin/tui'; + +const checks = await multiselect({ + message: 'Checks to run', + options: ['typecheck', 'build', 'smoke'], + default: ['typecheck', 'build'], + required: true, +}); +``` + + diff --git a/packages/docs/src/guide/number.md b/packages/docs/src/guide/number.md new file mode 100644 index 00000000..b197d9a4 --- /dev/null +++ b/packages/docs/src/guide/number.md @@ -0,0 +1,64 @@ +# Number + +The `number` helper reads numeric input and supports numeric constraints. + +```ts +import { number } from '@ollin/tui'; + +const copies = await number({ + message: 'How many copies?', + default: 1, + integer: true, + min: 1, + max: 100, +}); +``` + +## Validation + +Use `min`, `max`, and `integer` for common numeric rules. Use `validate` when the rule depends on application state. + +```ts +import { number } from '@ollin/tui'; + +const amount = await number({ + message: 'How many seats?', + validate: (value) => (Number(value) < 1 ? 'Choose at least one seat.' : null), +}); +``` + +## Complete Usage + +```ts +import { number } from '@ollin/tui'; + +const seats = await number({ + message: 'How many seats?', + placeholder: '5', + default: 1, + integer: true, + min: 1, + max: 100, + hint: 'Seat count controls billing.', + validate: (value) => (Number(value) % 1 === 0 ? null : 'Use a whole number.'), +}); + +const retries = await number('Retry attempts', '3', 3, true); + +void [seats, retries]; +``` + +## Consumer Call And Output + +```ts +import { number } from '@ollin/tui'; + +const seats = await number({ + message: 'Seats', + default: 3, + min: 1, + integer: true, +}); +``` + + diff --git a/packages/docs/src/guide/password.md b/packages/docs/src/guide/password.md new file mode 100644 index 00000000..4cb8bf94 --- /dev/null +++ b/packages/docs/src/guide/password.md @@ -0,0 +1,56 @@ +# Password + +The `password` helper reads masked text input for sensitive values. + +```ts +import { password } from '@ollin/tui'; + +const token = await password({ + message: 'Enter your API token.', + hint: 'Input is masked while typing.', + required: true, +}); +``` + +`password` shares the text prompt validation model. Use a typed callback for length, format, or application-specific checks. + +```ts +import { password } from '@ollin/tui'; + +const secret = await password({ + message: 'Secret', + validate: (value) => (value.length < 8 ? 'Use at least eight characters.' : null), +}); +``` + +## Complete Usage + +```ts +import { password } from '@ollin/tui'; + +const token = await password({ + message: 'API token', + placeholder: 'token', + hint: 'The token is not echoed to the terminal.', + required: 'An API token is required.', + validate: (value) => (value.length < 16 ? 'Tokens must be at least 16 characters.' : null), + transform: (value) => value.trim(), +}); + +const confirmation = await password('Confirm API token', 'token', true); + +void [token, confirmation]; +``` + +## Consumer Call And Output + +```ts +import { password } from '@ollin/tui'; + +const token = await password({ + message: 'API token', + required: true, +}); +``` + + diff --git a/packages/docs/src/guide/pause.md b/packages/docs/src/guide/pause.md new file mode 100644 index 00000000..53d602e4 --- /dev/null +++ b/packages/docs/src/guide/pause.md @@ -0,0 +1,49 @@ +# Pause + +The `pause` helper waits for the user to continue. + +```ts +import { pause } from '@ollin/tui'; + +await pause('Press enter to continue.'); +``` + +Use `pause` between groups of prompts or before a destructive step that should be reviewed by the user. + +`pause` is also available on `form()`. + +```ts +import { form } from '@ollin/tui'; + +await form().pause('Review the summary before continuing.').submit(); +``` + +## Return Value + +`pause` resolves to `true` after a user continues in an interactive environment. In a non-interactive environment it resolves to `false`, so command code can skip interactive-only review screens. + +## Complete Usage + +```ts +import { form, pause } from '@ollin/tui'; + +const continued = await pause('Press enter to continue.'); + +const responses = await form() + .text({ message: 'Release name', required: true }, 'name') + .pause('Review the release name before continuing.') + .confirm({ message: 'Publish?', default: false }, 'publish') + .submit(); + +void [continued, responses]; +``` + +## Consumer Call And Output + +```ts +import { pause } from '@ollin/tui'; + +await pause('Review the summary, then press enter.'); +``` + + diff --git a/packages/docs/src/guide/progress.md b/packages/docs/src/guide/progress.md new file mode 100644 index 00000000..43341b65 --- /dev/null +++ b/packages/docs/src/guide/progress.md @@ -0,0 +1,62 @@ +# Progress Bar + +The `progress` helper supports iterable work and manual progress control. + +```ts +import { progress } from '@ollin/tui'; + +await progress('Processing files', ['one.ts', 'two.ts'], async (file) => file.toUpperCase()); +``` + +For manual control, create a `Progress` instance and call `start`, `advance`, and `finish`. + +```ts +import { progress } from '@ollin/tui'; + +const bar = progress(3, 'Manual steps'); + +bar.start(); +bar.advance(); +bar.finish(); +``` + +## Complete Usage + +```ts +import { form, progress } from '@ollin/tui'; + +const files = await progress('Processing files', ['one.ts', 'two.ts'], async (file, bar) => { + bar.hint(`Current file: ${String(file)}`); + + return String(file).toUpperCase(); +}); + +const bar = progress(3, 'Manual steps'); + +bar.start(); +bar.advance(); +bar.label('Almost done'); +bar.advance(2); +bar.finish(); + +const responses = await form().progress('Form progress', ['docs', 'api'], async (step) => String(step), '', 'steps').submit(); + +void [files, responses]; +``` + +## Consumer Call And Output + +```ts +import { progress } from '@ollin/tui'; + +await progress('Building packages', ['tui', 'docs'], async (pkg) => String(pkg)); +``` + + diff --git a/packages/docs/src/guide/search.md b/packages/docs/src/guide/search.md new file mode 100644 index 00000000..9360c246 --- /dev/null +++ b/packages/docs/src/guide/search.md @@ -0,0 +1,61 @@ +# Search + +The `search` helper filters options as the user types and returns one selected value. + +```ts +import { search } from '@ollin/tui'; + +const userId = await search({ + message: 'Search for the user receiving the email.', + placeholder: 'Ada', + options: (query) => ({ + 1: 'Ada Lovelace', + 2: 'Ada Lovelace', + 3: 'Grace Hopper', + }), + required: true, + info: (value) => `User id ${value}`, +}); +``` + +When `options` returns an object, the selected key is returned. When it returns an array, the selected value is returned. + +## Complete Usage + +```ts +import { search } from '@ollin/tui'; + +const users = new Map([ + [1, 'Lin Chen'], + [2, 'Ada Lovelace'], + [3, 'Grace Hopper'], +]); + +const userId = await search({ + message: 'Search for the user receiving the email.', + placeholder: 'Ada', + options: (query) => Object.fromEntries([...users].filter(([, name]) => name.toLowerCase().includes(query.toLowerCase()))), + scroll: 10, + required: true, + info: (value) => `User id ${value}`, + validate: (value) => (Number(value) === 1 ? 'Lin is unavailable.' : null), +}); + +const userName = await search('Pick a reviewer', ['Ada Lovelace', 'Grace Hopper']); + +void [userId, userName]; +``` + +## Consumer Call And Output + +```ts +import { search } from '@ollin/tui'; + +const reviewer = await search({ + message: 'Reviewer', + options: ['Ada Lovelace', 'Grace Hopper', 'Lin Chen'], + required: true, +}); +``` + + diff --git a/packages/docs/src/guide/select.md b/packages/docs/src/guide/select.md new file mode 100644 index 00000000..204da382 --- /dev/null +++ b/packages/docs/src/guide/select.md @@ -0,0 +1,72 @@ +# Select + +The `select` helper asks the user to choose one item. + +```ts +import { select } from '@ollin/tui'; + +const role = await select({ + message: 'What role should the user have?', + options: { + member: 'Member', + contributor: 'Contributor', + owner: 'Owner', + }, + default: 'member', + info: (value) => (value === 'owner' ? 'Full access to the project.' : null), +}); +``` + +Associative options return the selected key. Array options return the selected value. + +## Validation + +`select` always returns one value. Use `validate` if a visible option may be unavailable. + +```ts +import { select } from '@ollin/tui'; + +const role = await select({ + message: 'Role', + options: ['Member', 'Owner'], + validate: (value) => (value === 'Owner' ? 'An owner already exists.' : null), +}); +``` + +## Complete Usage + +```ts +import { select } from '@ollin/tui'; + +const role = await select({ + message: 'What role should the user have?', + options: { + member: 'Member', + contributor: 'Contributor', + owner: 'Owner', + }, + default: 'member', + scroll: 5, + hint: 'The role can be changed later.', + info: (value) => (value === 'owner' ? 'Full access to all resources.' : null), + validate: (value) => (value === 'owner' ? 'Owner access requires a separate approval.' : null), +}); + +const channel = await select('Release channel', ['stable', 'beta', 'nightly'], 'stable'); + +void [role, channel]; +``` + +## Consumer Call And Output + +```ts +import { select } from '@ollin/tui'; + +const channel = await select({ + message: 'Release channel', + options: ['stable', 'beta', 'nightly'], + default: 'stable', +}); +``` + + diff --git a/packages/docs/src/guide/spin.md b/packages/docs/src/guide/spin.md new file mode 100644 index 00000000..786805b0 --- /dev/null +++ b/packages/docs/src/guide/spin.md @@ -0,0 +1,55 @@ +# Spin + +The `spin` helper runs async work while rendering a spinner. + +```ts +import { spin } from '@ollin/tui'; + +const result = await spin('Installing dependencies', async () => 'installed'); +``` + +The callback result is returned after the spinner is cleared and terminal cursor state is restored. + +## When To Use It + +Use `spin` for one async operation where users only need to know that work is in progress. Use `progress` when the work has countable steps, and use `task` when the work should emit log lines. + +## Complete Usage + +```ts +import { form, spin } from '@ollin/tui'; + +const result = await spin('Installing dependencies', async () => { + return 'installed'; +}); + +const responses = await form() + .spin('Loading workspace', async () => 'loaded', 'workspace') + .text({ message: 'Next command', default: 'pnpm build' }, 'command') + .submit(); + +void [result, responses]; +``` + +## Behaviour + +The callback return value is preserved, so `spin` can wrap existing application functions without changing their result type. If the callback throws, the error is rethrown after terminal cleanup. + +## Consumer Call And Output + +```ts +import { spin } from '@ollin/tui'; + +const result = await spin('Installing dependencies', async () => 'installed'); +``` + + diff --git a/packages/docs/src/guide/stream.md b/packages/docs/src/guide/stream.md new file mode 100644 index 00000000..c354f11e --- /dev/null +++ b/packages/docs/src/guide/stream.md @@ -0,0 +1,55 @@ +# Stream + +The `stream` helper renders streaming text from an iterable or async iterable. + +```ts +import { stream } from '@ollin/tui'; + +await stream(['Preparing release\n', 'Release ready\n']); +``` + +Calling `stream()` without a source returns a `Stream` instance for manual writes. + +```ts +import { stream } from '@ollin/tui'; + +const output = stream(); + +output.write('Preparing release\n'); +output.close(); +``` + +## Complete Usage + +```ts +import { form, stream } from '@ollin/tui'; + +await stream(['Preparing release\n', 'Release ready\n']); + +const output = stream(); + +output.write('Writing files\n'); +output.append('Done\n'); +output.close(); + +const responses = await form().stream(['Line one\n', 'Line two\n'], 'streamedOutput').submit(); + +void responses; +``` + +## Consumer Call And Output + +```ts +import { stream } from '@ollin/tui'; + +await stream(['Preparing release\n', 'Release ready\n']); +``` + + diff --git a/packages/docs/src/guide/suggest.md b/packages/docs/src/guide/suggest.md new file mode 100644 index 00000000..f8a5800f --- /dev/null +++ b/packages/docs/src/guide/suggest.md @@ -0,0 +1,54 @@ +# Suggest + +The `suggest` helper offers completions while still allowing any typed value. + +```ts +import { suggest } from '@ollin/tui'; + +const author = await suggest({ + message: 'Author name', + options: (query) => ['Ada', 'Grace', 'Lin'].filter((name) => name.toLowerCase().includes(query.toLowerCase())), + placeholder: 'Ada', +}); +``` + +`options` can be an array or a callback that receives the current query. + +Use `required`, `validate`, and `transform` the same way you would on `text`. + +## Complete Usage + +```ts +import { suggest } from '@ollin/tui'; + +const names = ['Ada', 'Grace', 'Lin', 'Katherine']; + +const author = await suggest({ + message: 'Author name', + options: (query) => names.filter((name) => name.toLowerCase().includes(query.toLowerCase())), + placeholder: 'Ada', + default: 'Ada', + scroll: 5, + required: true, + info: (value) => (value === 'Ada' ? 'Default maintainer.' : null), + validate: (value) => (value.length < 2 ? 'Enter at least two characters.' : null), +}); + +const reviewer = await suggest('Reviewer', names, 'Grace'); + +void [author, reviewer]; +``` + +## Consumer Call And Output + +```ts +import { suggest } from '@ollin/tui'; + +const maintainer = await suggest({ + message: 'Maintainer', + options: ['Ada', 'Grace', 'Lin'], + default: 'Ada', +}); +``` + + diff --git a/packages/docs/src/guide/tables.md b/packages/docs/src/guide/tables.md new file mode 100644 index 00000000..3d4ea08d --- /dev/null +++ b/packages/docs/src/guide/tables.md @@ -0,0 +1,87 @@ +# Tables + +Use `table` or `dataTable` to render tabular output. + +```ts +import { dataTable, table } from '@ollin/tui'; + +table(['Name', 'Runtime'], [ + ['Prompts', 'TypeScript'], + ['Renderer', 'OpenTUI'], +]); + +dataTable({ + headers: ['Name', 'Role'], + rows: [ + ['Ada', 'Admin'], + ['Grace', 'Operator'], + ], +}); +``` + +`dataTable` is a display alias for `table`. For an interactive selectable table, use `datatable`. + +```ts +import { datatable } from '@ollin/tui'; + +const selected = await datatable({ + message: 'Select a user', + headers: ['Name', 'Role'], + rows: [ + ['Ada', 'Admin'], + ['Grace', 'Operator'], + ], + required: true, +}); +``` + +## Complete Usage + +```ts +import { dataTable, datatable, form, grid, table } from '@ollin/tui'; + +table({ + headers: ['Package', 'Purpose'], + rows: [ + { Package: '@ollin/tui', Purpose: 'Runtime prompts' }, + { Package: 'docs', Purpose: 'Documentation site' }, + ], +}); + +dataTable(['Helper', 'Kind'], [ + ['text', 'prompt'], + ['task', 'status'], +]); + +grid(['text', 'select', 'search', 'form', 'task', 'stream'], 60); + +const selected = await datatable({ + message: 'Select a package', + headers: ['Package', 'Purpose'], + rows: [ + ['@ollin/tui', 'Runtime prompts'], + ['docs', 'Documentation site'], + ], + required: true, +}); + +const responses = await form() + .table(['Step', 'State'], [['Docs', 'Ready']], 'table') + .datatable({ message: 'Choose action', rows: [['Build'], ['Preview']], required: true }, 'action') + .submit(); + +void [selected, responses]; +``` + +## Consumer Call And Output + +```ts +import { table } from '@ollin/tui'; + +table(['Package', 'Purpose'], [ + ['@ollin/tui', 'Prompts'], + ['docs', 'Documentation'], +]); +``` + + diff --git a/packages/docs/src/guide/task.md b/packages/docs/src/guide/task.md new file mode 100644 index 00000000..4d53f390 --- /dev/null +++ b/packages/docs/src/guide/task.md @@ -0,0 +1,54 @@ +# Task + +The `task` helper runs work with structured task logging. + +```ts +import { task } from '@ollin/tui'; + +const result = await task('Publishing', async (logger) => { + logger.info('Uploading assets'); + logger.success('Assets uploaded'); + + return 'published'; +}); +``` + +Use `logger.info`, `logger.line`, `logger.success`, `logger.warning`, and `logger.error` to emit task output while the task runs. + +## Complete Usage + +```ts +import { form, task } from '@ollin/tui'; + +const result = await task('Publishing', async (logger) => { + logger.info('Uploading assets'); + logger.line('Writing manifest'); + logger.success('Assets uploaded'); + + return 'published'; +}); + +const responses = await form() + .task('Checking package', async (logger) => { + logger.info('Reading package.json'); + logger.success('Package looks valid'); + + return true; + }, 10, true, 'metadata', 'packageCheck') + .submit(); + +void [result, responses]; +``` + +## Consumer Call And Output + +```ts +import { task } from '@ollin/tui'; + +await task('Publishing package', async (logger) => { + logger.info('Uploading files'); + logger.success('Published'); +}); +``` + + diff --git a/packages/docs/src/guide/terminal-considerations.md b/packages/docs/src/guide/terminal-considerations.md new file mode 100644 index 00000000..5bf6576a --- /dev/null +++ b/packages/docs/src/guide/terminal-considerations.md @@ -0,0 +1,56 @@ +# Terminal Considerations + +`@ollin/tui` represents terminal IO through `PromptEnvironment`. The default environment reads from the process input and writes to process output. Tests and non-standard runtimes can replace those streams. + +```ts +import { promptEnvironment, terminalSize } from '@ollin/tui'; + +const environment = promptEnvironment(); +const size = terminalSize(); + +void [environment, size]; +``` + +Terminal helpers restore cursor state around status renderers such as `spin`, `progress`, `task`, and `stream`. + +## Prompt Environment + +`PromptEnvironment` is the main portability boundary. It allows application code to use the same `text`, `select`, `task`, and output helpers in process terminals, scripted tests, or custom runtimes. + +## Complete Usage + +```ts +import { createMemoryOutput, createScriptedInput, promptEnvironment, terminalSize, text, withPromptEnvironment } from '@ollin/tui'; + +const current = promptEnvironment(); +const size = terminalSize(); + +const output = createMemoryOutput(); +const input = createScriptedInput(['Ada']); + +const name = await withPromptEnvironment({ input, interactive: false, output }, () => + text({ + message: 'Name', + default: 'Ada', + }), +); + +void [current, size, name, output.text()]; +``` + +## Practical Guidance + +Keep process-specific assumptions near your application entrypoint. Library code should accept dependencies or run under `withPromptEnvironment` when it needs deterministic input/output behaviour. + +## Consumer Call And Output + +```ts +import { createMemoryOutput, createScriptedInput, text, withPromptEnvironment } from '@ollin/tui'; + +const output = createMemoryOutput(); +const input = createScriptedInput(['Ada']); + +const name = await withPromptEnvironment({ input, output }, () => text({ message: 'Name' })); +``` + + diff --git a/packages/docs/src/guide/terminal-title.md b/packages/docs/src/guide/terminal-title.md new file mode 100644 index 00000000..3753952f --- /dev/null +++ b/packages/docs/src/guide/terminal-title.md @@ -0,0 +1,48 @@ +# Terminal Title + +Use `title` to set the terminal title through the configured prompt output. + +```ts +import { title } from '@ollin/tui'; + +title('Ollin TUI'); +``` + +The lower-level terminal helper `setTerminalTitle` is also exported for direct terminal control. + +```ts +import { setTerminalTitle } from '@ollin/tui'; + +setTerminalTitle('Ollin TUI'); +``` + +## When To Use It + +Use `title` when you want the same output boundary as the rest of the prompt helpers. Use `setTerminalTitle` when lower-level terminal control is more appropriate. + +## Complete Usage + +```ts +import { form, setTerminalTitle, title } from '@ollin/tui'; + +title('Ollin TUI'); +setTerminalTitle('Ollin TUI: Build'); + +const responses = await form().title('Ollin TUI: Form').text({ message: 'Command', default: 'build' }, 'command').submit(); + +void responses; +``` + +## Behaviour + +Terminal title support depends on the terminal emulator. Terminals that ignore the title escape sequence do not affect the rest of the command. + +## Consumer Call And Output + +```ts +import { title } from '@ollin/tui'; + +title('Ollin TUI: Release'); +``` + + diff --git a/packages/docs/src/guide/testing.md b/packages/docs/src/guide/testing.md new file mode 100644 index 00000000..76a86ed2 --- /dev/null +++ b/packages/docs/src/guide/testing.md @@ -0,0 +1,68 @@ +# Testing + +Use prompt environment helpers to exercise prompt flows without process input. + +```ts +import { Key, createMemoryOutput, createScriptedInput, text, withPromptEnvironment } from '@ollin/tui'; + +const output = createMemoryOutput(); +const input = createScriptedInput(['Ada', Key.enter]); + +const name = await withPromptEnvironment({ input, output }, () => + text({ + message: 'Name', + required: true, + }), +); + +const rendered = output.text(); +``` + +This uses the same exported helpers as application code. The scripted input supplies keys and lines, while memory output captures rendered terminal text. + +## Complete Usage + +```ts +import { Key, confirm, createMemoryOutput, createScriptedInput, select, text, withPromptEnvironment } from '@ollin/tui'; + +const output = createMemoryOutput(); +const input = createScriptedInput(['Ada', Key.enter, Key.down, Key.enter, 'y']); + +const result = await withPromptEnvironment({ input, output }, async () => { + const name = await text({ + message: 'Name', + required: true, + }); + + const role = await select({ + message: 'Role', + options: ['Member', 'Owner'], + default: 'Member', + }); + + const active = await confirm({ + message: 'Active?', + default: true, + }); + + return { active, name, role }; +}); + +const rendered = output.text(); + +void [result, rendered]; +``` + +## Consumer Call And Output + +```ts +import { createMemoryOutput, createScriptedInput, text, withPromptEnvironment } from '@ollin/tui'; + +const output = createMemoryOutput(); +const input = createScriptedInput(['Ada']); + +const name = await withPromptEnvironment({ input, output }, () => text({ message: 'Name' })); +const rendered = output.text(); +``` + + diff --git a/packages/docs/src/guide/text.md b/packages/docs/src/guide/text.md new file mode 100644 index 00000000..7eee9dc7 --- /dev/null +++ b/packages/docs/src/guide/text.md @@ -0,0 +1,62 @@ +# Text + +The `text` helper reads a single-line string. + +```ts +import { text } from '@ollin/tui'; + +const name = await text({ + message: 'What is your name?', + placeholder: 'Ada Lovelace', + required: 'A name is required.', + validate: (value) => (value.length < 2 ? 'Enter at least two characters.' : null), +}); +``` + +## Options + +`text` accepts `message`, `placeholder`, `default`, `required`, `hint`, `validate`, and `transform`. + +The `validate` callback returns a string error message or `null`. Use callbacks when a prompt needs validation. + +## Label-First Form + +```ts +import { text } from '@ollin/tui'; + +const name = await text('What is your name?', 'Ada Lovelace', '', true); +``` + +## Complete Usage + +```ts +import { text } from '@ollin/tui'; + +const project = await text({ + message: 'Project name', + placeholder: 'acme-console', + default: 'ollin-console', + hint: 'Used in generated filenames.', + required: 'A project name is required.', + transform: (value) => value.trim().toLowerCase(), + validate: (value) => (/^[a-z0-9-]+$/u.test(value) ? null : 'Use lowercase letters, numbers, and dashes.'), +}); + +const owner = await text('Owner name', 'Ada Lovelace', '', true); + +void [project, owner]; +``` + +## Consumer Call And Output + +```ts +import { text } from '@ollin/tui'; + +const project = await text({ + message: 'Project name', + default: 'ollin-console', + required: true, +}); +``` + + diff --git a/packages/docs/src/guide/textarea.md b/packages/docs/src/guide/textarea.md new file mode 100644 index 00000000..a25b73f5 --- /dev/null +++ b/packages/docs/src/guide/textarea.md @@ -0,0 +1,54 @@ +# Textarea + +The `textarea` helper reads multi-line input. + +```ts +import { textarea } from '@ollin/tui'; + +const biography = await textarea({ + message: 'Tell us about your project.', + placeholder: 'This project helps...', + rows: 5, + required: true, +}); +``` + +## Options + +`textarea` supports the text prompt options plus `rows`. + +Use `validate` for custom checks and `transform` to normalise the submitted string before validation. + +## Complete Usage + +```ts +import { textarea } from '@ollin/tui'; + +const summary = await textarea({ + message: 'Release notes', + placeholder: 'Added a new prompt flow...', + rows: 6, + hint: 'Markdown is accepted.', + required: 'Release notes are required.', + transform: (value) => value.trim(), + validate: (value) => (value.length < 20 ? 'Write at least 20 characters.' : null), +}); + +const supportMessage = await textarea('Support message', 'Describe the issue', '', true); + +void [summary, supportMessage]; +``` + +## Consumer Call And Output + +```ts +import { textarea } from '@ollin/tui'; + +const notes = await textarea({ + message: 'Release notes', + rows: 4, + required: true, +}); +``` + + diff --git a/packages/docs/src/guide/transforms.md b/packages/docs/src/guide/transforms.md new file mode 100644 index 00000000..f6a477c9 --- /dev/null +++ b/packages/docs/src/guide/transforms.md @@ -0,0 +1,61 @@ +# Transforming Input Before Validation + +Prompt options can include `transform`. The transform runs on the submitted value before validation completes. + +```ts +import { text } from '@ollin/tui'; + +const slug = await text({ + message: 'Project slug', + transform: (value) => value.trim().toLowerCase().replaceAll(' ', '-'), + validate: (value) => (/^[a-z0-9-]+$/u.test(value) ? null : 'Use letters, numbers, and dashes.'), +}); +``` + +Transforms can be synchronous or asynchronous. Use them for normalisation that belongs to the prompt boundary. + +Use callbacks for `validate` and `transform`. + +## Complete Usage + +```ts +import { confirm, multiselect, number, text } from '@ollin/tui'; + +const slug = await text({ + message: 'Project slug', + transform: (value) => value.trim().toLowerCase().replaceAll(' ', '-'), + validate: (value) => (/^[a-z0-9-]+$/u.test(value) ? null : 'Use letters, numbers, and dashes.'), +}); + +const seats = await number({ + message: 'Seats', + transform: (value) => Number(value), + validate: (value) => (Number(value) >= 1 ? null : 'Choose at least one seat.'), +}); + +const permissions = await multiselect({ + message: 'Permissions', + options: ['Read', 'Write', 'Delete'], + transform: (values) => values.map((value) => value.toLowerCase()), +}); + +const publish = await confirm({ + message: 'Publish?', + transform: (value) => Boolean(value), +}); + +void [slug, seats, permissions, publish]; +``` + +## Consumer Call And Output + +```ts +import { text } from '@ollin/tui'; + +const slug = await text({ + message: 'Project slug', + transform: (value) => value.trim().toLowerCase().replaceAll(' ', '-'), +}); +``` + + diff --git a/packages/docs/src/index.md b/packages/docs/src/index.md new file mode 100644 index 00000000..d76ef722 --- /dev/null +++ b/packages/docs/src/index.md @@ -0,0 +1,53 @@ +# @ollin/tui + +Prompt and terminal helpers for command-line applications. + +Start with the [guide](/guide/introduction), or open the generated [API reference](/api/). + +These docs cover the public API and common command-line workflows. + +## Current Stage + +`@ollin/tui` is currently implemented as a TypeScript ESM package with a public root entrypoint for prompts, forms, output helpers, status indicators, terminal helpers, string and key utilities, and deterministic prompt environments. + +The guide pages describe the tracked public surface, while the generated API reference is built from the package types. Runtime and type-consumption behavior is covered by the workspace test suites, including the acceptance package. The detailed parity map and completion audit live in `UPSTREAM.md`. + +## Build a Prompted Command + +```ts +import { form, table, task } from '@ollin/tui'; + +const answers = await form() + .text({ message: 'Package name', required: true }, 'name') + .select({ message: 'Template', options: ['library', 'cli', 'docs'], default: 'cli' }, 'template') + .confirm({ message: 'Install dependencies?', default: true }, 'install') + .submit(); + +await task('Creating package', async (logger) => { + logger.info(`Template: ${answers.template}`); + logger.success('Package configured'); +}); + +table(['Field', 'Value'], [ + ['Package', String(answers.name)], + ['Template', String(answers.template)], + ['Install', String(answers.install)], +]); +``` + +## What To Read First + +- Use [Available Prompts](/guide/available-prompts) for the helper map. +- Use [Forms](/guide/forms) when a command needs multiple steps. +- Use [Testing](/guide/testing) when you need deterministic prompt input. +- Use [API Reference](/api/) for generated types and overloads. + +## Consumer Call And Output + +```ts +import { text } from '@ollin/tui'; + +const name = await text({ message: 'Package name', default: 'cli-tools' }); +``` + + diff --git a/packages/docs/tests/docs.test.ts b/packages/docs/tests/docs.test.ts new file mode 100644 index 00000000..caa2b7e7 --- /dev/null +++ b/packages/docs/tests/docs.test.ts @@ -0,0 +1,103 @@ +import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; +import { dirname, extname, join, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { guideSections } from '@docs-config'; + +const testsPath = dirname(fileURLToPath(import.meta.url)); +const packagePath = dirname(testsPath); +const sourcePath = join(packagePath, 'src'); +const examplesPath = join(packagePath, 'examples'); +const vitepressPath = join(sourcePath, '.vitepress'); +const blockedTokens = ['TODO', 'lorem', 'fake', 'mocked', 'stubbed']; +const importSpecifierPattern = /\b(?:import|export)\b(?:[\s\S]*?\bfrom\s*)?['"]([^'"]+)['"]|import\(\s*['"]([^'"]+)['"]\s*\)/gu; + +const expectedExports = [ + 'text', + 'textarea', + 'number', + 'password', + 'confirm', + 'select', + 'multiselect', + 'suggest', + 'autocomplete', + 'search', + 'multisearch', + 'pause', + 'form', + 'table', + 'dataTable', + 'spin', + 'progress', + 'task', + 'stream', +] as const; + +const walk = (directory: string): string[] => + readdirSync(directory).flatMap((entry) => { + const path = join(directory, entry); + + return statSync(path).isDirectory() ? walk(path) : [path]; + }); + +const markdownFiles = (): string[] => walk(sourcePath).filter((path) => extname(path) === '.md'); +const exampleFiles = (): string[] => walk(examplesPath).filter((path) => extname(path) === '.ts'); + +const docsCodeFiles = (): string[] => + [...walk(vitepressPath), ...walk(testsPath)] + .filter((path) => ['.ts', '.vue'].includes(extname(path))) + .filter((path) => !path.includes('/.vitepress/cache/') && !path.includes('/.vitepress/dist/')); + +describe('docs structure', () => { + it('has a markdown page for every guide section', () => { + for (const section of guideSections) { + const path = join(sourcePath, `${section.link.replace(/^\//u, '')}.md`); + + expect(existsSync(path), `${section.text} is missing at ${path}`).toBe(true); + } + }); + + it('documents every key public helper', () => { + const content = markdownFiles() + .map((path) => readFileSync(path, 'utf8')) + .join('\n'); + + for (const exportName of expectedExports) { + expect(content, `${exportName} is not documented`).toContain(`\`${exportName}\``); + } + }); + + it('keeps docs and examples free of placeholder language', () => { + const files = [...markdownFiles(), ...exampleFiles()]; + + for (const path of files) { + const content = readFileSync(path, 'utf8'); + + for (const token of blockedTokens) { + expect(content, `${relative(packagePath, path)} contains ${token}`).not.toContain(token); + } + } + }); + + it('keeps guide pages useful without leaving for example source files', () => { + for (const path of markdownFiles().filter((file) => file.includes('/guide/'))) { + const content = readFileSync(path, 'utf8'); + + expect(content, `${relative(packagePath, path)} needs inline TypeScript usage`).toContain('```ts'); + expect(content, `${relative(packagePath, path)} should not send readers to source fixtures`).not.toMatch(/\.\.\/\.\.\/examples\//u); + } + }); + + it('uses aliases instead of relative imports in docs code', () => { + for (const path of docsCodeFiles()) { + const content = readFileSync(path, 'utf8'); + + for (const match of content.matchAll(importSpecifierPattern)) { + const specifier = match[1] ?? match[2] ?? ''; + + expect(specifier, `${relative(packagePath, path)} imports ${specifier}`).not.toMatch(/^\.\.?(?:\/|$)/u); + } + } + }); +}); diff --git a/packages/docs/tsconfig.json b/packages/docs/tsconfig.json new file mode 100644 index 00000000..cc1e8a5b --- /dev/null +++ b/packages/docs/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "@ollin/tsconfig/base.json", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@docs/*": ["src/*"], + "@docs-components/*": ["vitepress/theme/components/*"], + "@docs-config": ["vitepress/config.ts"], + "@docs-lib/*": ["vitepress/theme/lib/*"], + "@docs-tests/*": ["tests/*"], + "@docs-theme/*": ["vitepress/theme/*"], + "@docs-ui/*": ["vitepress/theme/components/ui/*"], + "@ollin/tui": ["../tui/src/index.ts"], + "#tui/*": ["../tui/src/*"], + "#tui/prompts/*": ["../tui/src/prompts/*"] + }, + "tsBuildInfoFile": "../../provision/.cache/tsbuild/docs.tsbuildinfo" + }, + "include": ["examples/**/*.ts", "tests/**/*.ts", "vitepress/**/*.ts", "vitest.config.ts", "../tui/src/**/*.ts"] +} diff --git a/packages/docs/typedoc.json b/packages/docs/typedoc.json new file mode 100644 index 00000000..c163a56f --- /dev/null +++ b/packages/docs/typedoc.json @@ -0,0 +1,12 @@ +{ + "entryPoints": ["../tui/src/index.ts"], + "excludeInternal": true, + "excludePrivate": true, + "excludeProtected": true, + "hideBreadcrumbs": true, + "hidePageHeader": true, + "out": "src/api", + "plugin": ["typedoc-plugin-markdown"], + "readme": "none", + "tsconfig": "../tui/tsconfig.json" +} diff --git a/packages/docs/vitepress/config.ts b/packages/docs/vitepress/config.ts new file mode 100644 index 00000000..381e78ad --- /dev/null +++ b/packages/docs/vitepress/config.ts @@ -0,0 +1,84 @@ +import { fileURLToPath } from 'node:url'; +import tailwindcss from '@tailwindcss/vite'; +import { defineConfig } from 'vitepress'; + +const sourcePath = fileURLToPath(new URL('../src/', import.meta.url)); +const themePath = fileURLToPath(new URL('theme', import.meta.url)); +const themeComponentsPath = fileURLToPath(new URL('theme/components', import.meta.url)); +const themeLibPath = fileURLToPath(new URL('theme/lib', import.meta.url)); +const themeUiPath = fileURLToPath(new URL('theme/components/ui', import.meta.url)); +const viteCachePath = fileURLToPath(new URL('../../../provision/.cache/vitepress/docs', import.meta.url)); +const tailwindPlugin = tailwindcss() as never; + +export const guideSections = [ + { text: 'Introduction', link: '/guide/introduction' }, + { text: 'Installation', link: '/guide/installation' }, + { text: 'Available Prompts', link: '/guide/available-prompts' }, + { text: 'Text', link: '/guide/text' }, + { text: 'Textarea', link: '/guide/textarea' }, + { text: 'Number', link: '/guide/number' }, + { text: 'Password', link: '/guide/password' }, + { text: 'Confirm', link: '/guide/confirm' }, + { text: 'Select', link: '/guide/select' }, + { text: 'Multi-select', link: '/guide/multiselect' }, + { text: 'Suggest', link: '/guide/suggest' }, + { text: 'Search', link: '/guide/search' }, + { text: 'Multi-search', link: '/guide/multisearch' }, + { text: 'Pause', link: '/guide/pause' }, + { text: 'Autocomplete', link: '/guide/autocomplete' }, + { text: 'Transforming Input Before Validation', link: '/guide/transforms' }, + { text: 'Forms', link: '/guide/forms' }, + { text: 'Informational Messages', link: '/guide/informational-messages' }, + { text: 'Tables', link: '/guide/tables' }, + { text: 'Spin', link: '/guide/spin' }, + { text: 'Progress Bar', link: '/guide/progress' }, + { text: 'Task', link: '/guide/task' }, + { text: 'Stream', link: '/guide/stream' }, + { text: 'Terminal Title', link: '/guide/terminal-title' }, + { text: 'Clearing the Terminal', link: '/guide/clearing-terminal' }, + { text: 'Terminal Considerations', link: '/guide/terminal-considerations' }, + { text: 'Custom Environments', link: '/guide/fallbacks' }, + { text: 'Testing', link: '/guide/testing' }, +] as const; + +export default defineConfig({ + title: '@ollin/tui', + description: 'Documentation for the Ollin TUI prompt toolkit.', + cleanUrls: true, + lastUpdated: true, + vite: { + cacheDir: viteCachePath, + plugins: [tailwindPlugin], + resolve: { + alias: [ + { find: '@docs', replacement: sourcePath }, + { find: '@docs-components', replacement: themeComponentsPath }, + { find: '@docs-lib', replacement: themeLibPath }, + { find: '@docs-theme', replacement: themePath }, + { find: '@docs-ui', replacement: themeUiPath }, + ], + }, + }, + themeConfig: { + nav: [ + { text: 'Guide', link: '/guide/introduction' }, + { text: 'API', link: '/api/' }, + ], + search: { + provider: 'local', + }, + sidebar: [ + { + text: 'Guide', + items: [...guideSections], + }, + { + text: 'Reference', + items: [{ text: 'API Reference', link: '/api/' }], + }, + ], + footer: { + message: 'Documentation for the @ollin/tui TypeScript package.', + }, + }, +}); diff --git a/packages/docs/vitepress/theme/components/TerminalOutput.vue b/packages/docs/vitepress/theme/components/TerminalOutput.vue new file mode 100644 index 00000000..87dc66ef --- /dev/null +++ b/packages/docs/vitepress/theme/components/TerminalOutput.vue @@ -0,0 +1,244 @@ + + + diff --git a/packages/docs/vitepress/theme/components/ui/badge/Badge.vue b/packages/docs/vitepress/theme/components/ui/badge/Badge.vue new file mode 100644 index 00000000..f054e0ee --- /dev/null +++ b/packages/docs/vitepress/theme/components/ui/badge/Badge.vue @@ -0,0 +1,24 @@ + + + diff --git a/packages/docs/vitepress/theme/components/ui/badge/index.ts b/packages/docs/vitepress/theme/components/ui/badge/index.ts new file mode 100644 index 00000000..426671ea --- /dev/null +++ b/packages/docs/vitepress/theme/components/ui/badge/index.ts @@ -0,0 +1,3 @@ +export { default as Badge } from '@docs-ui/badge/Badge.vue'; +export { badgeVariants } from '@docs-ui/badge/variants'; +export type { BadgeVariants } from '@docs-ui/badge/variants'; diff --git a/packages/docs/vitepress/theme/components/ui/badge/variants.ts b/packages/docs/vitepress/theme/components/ui/badge/variants.ts new file mode 100644 index 00000000..7775842b --- /dev/null +++ b/packages/docs/vitepress/theme/components/ui/badge/variants.ts @@ -0,0 +1,22 @@ +import { cva } from 'class-variance-authority'; +import type { VariantProps } from 'class-variance-authority'; + +export const badgeVariants = cva( + 'inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden', + { + variants: { + variant: { + default: 'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90', + secondary: 'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90', + destructive: + 'border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60', + outline: 'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground', + }, + }, + defaultVariants: { + variant: 'default', + }, + }, +); + +export type BadgeVariants = VariantProps; diff --git a/packages/docs/vitepress/theme/components/ui/card/Card.vue b/packages/docs/vitepress/theme/components/ui/card/Card.vue new file mode 100644 index 00000000..2d1cb120 --- /dev/null +++ b/packages/docs/vitepress/theme/components/ui/card/Card.vue @@ -0,0 +1,14 @@ + + + diff --git a/packages/docs/vitepress/theme/components/ui/card/CardAction.vue b/packages/docs/vitepress/theme/components/ui/card/CardAction.vue new file mode 100644 index 00000000..d6b1a8d5 --- /dev/null +++ b/packages/docs/vitepress/theme/components/ui/card/CardAction.vue @@ -0,0 +1,14 @@ + + + diff --git a/packages/docs/vitepress/theme/components/ui/card/CardContent.vue b/packages/docs/vitepress/theme/components/ui/card/CardContent.vue new file mode 100644 index 00000000..bb20eaf9 --- /dev/null +++ b/packages/docs/vitepress/theme/components/ui/card/CardContent.vue @@ -0,0 +1,14 @@ + + + diff --git a/packages/docs/vitepress/theme/components/ui/card/CardDescription.vue b/packages/docs/vitepress/theme/components/ui/card/CardDescription.vue new file mode 100644 index 00000000..cd41c75a --- /dev/null +++ b/packages/docs/vitepress/theme/components/ui/card/CardDescription.vue @@ -0,0 +1,14 @@ + + + diff --git a/packages/docs/vitepress/theme/components/ui/card/CardFooter.vue b/packages/docs/vitepress/theme/components/ui/card/CardFooter.vue new file mode 100644 index 00000000..2becef40 --- /dev/null +++ b/packages/docs/vitepress/theme/components/ui/card/CardFooter.vue @@ -0,0 +1,14 @@ + + + diff --git a/packages/docs/vitepress/theme/components/ui/card/CardHeader.vue b/packages/docs/vitepress/theme/components/ui/card/CardHeader.vue new file mode 100644 index 00000000..0462189d --- /dev/null +++ b/packages/docs/vitepress/theme/components/ui/card/CardHeader.vue @@ -0,0 +1,17 @@ + + + diff --git a/packages/docs/vitepress/theme/components/ui/card/CardTitle.vue b/packages/docs/vitepress/theme/components/ui/card/CardTitle.vue new file mode 100644 index 00000000..c179ac78 --- /dev/null +++ b/packages/docs/vitepress/theme/components/ui/card/CardTitle.vue @@ -0,0 +1,14 @@ + + + diff --git a/packages/docs/vitepress/theme/components/ui/card/index.ts b/packages/docs/vitepress/theme/components/ui/card/index.ts new file mode 100644 index 00000000..5a31d356 --- /dev/null +++ b/packages/docs/vitepress/theme/components/ui/card/index.ts @@ -0,0 +1,7 @@ +export { default as Card } from '@docs-ui/card/Card.vue'; +export { default as CardAction } from '@docs-ui/card/CardAction.vue'; +export { default as CardContent } from '@docs-ui/card/CardContent.vue'; +export { default as CardDescription } from '@docs-ui/card/CardDescription.vue'; +export { default as CardFooter } from '@docs-ui/card/CardFooter.vue'; +export { default as CardHeader } from '@docs-ui/card/CardHeader.vue'; +export { default as CardTitle } from '@docs-ui/card/CardTitle.vue'; diff --git a/packages/docs/vitepress/theme/components/ui/separator/Separator.vue b/packages/docs/vitepress/theme/components/ui/separator/Separator.vue new file mode 100644 index 00000000..2540c124 --- /dev/null +++ b/packages/docs/vitepress/theme/components/ui/separator/Separator.vue @@ -0,0 +1,22 @@ + + + diff --git a/packages/docs/vitepress/theme/components/ui/separator/index.ts b/packages/docs/vitepress/theme/components/ui/separator/index.ts new file mode 100644 index 00000000..b98245dd --- /dev/null +++ b/packages/docs/vitepress/theme/components/ui/separator/index.ts @@ -0,0 +1 @@ +export { default as Separator } from '@docs-ui/separator/Separator.vue'; diff --git a/packages/docs/vitepress/theme/index.ts b/packages/docs/vitepress/theme/index.ts new file mode 100644 index 00000000..68e369d3 --- /dev/null +++ b/packages/docs/vitepress/theme/index.ts @@ -0,0 +1,16 @@ +import DefaultTheme from 'vitepress/theme'; +import TerminalOutput from '@docs-components/TerminalOutput.vue'; +import '@fontsource/geist-sans/latin.css'; +import '@fontsource/geist-mono/latin.css'; +import '@xterm/xterm/css/xterm.css'; +import '@docs-theme/styles.css'; +import type { Theme } from 'vitepress'; + +const docsTheme: Theme = { + extends: DefaultTheme, + enhanceApp({ app }) { + app.component('TerminalOutput', TerminalOutput); + }, +}; + +export default docsTheme; diff --git a/packages/docs/vitepress/theme/lib/utils.ts b/packages/docs/vitepress/theme/lib/utils.ts new file mode 100644 index 00000000..b802bab4 --- /dev/null +++ b/packages/docs/vitepress/theme/lib/utils.ts @@ -0,0 +1,5 @@ +import { clsx } from 'clsx'; +import { twMerge } from 'tailwind-merge'; +import type { ClassValue } from 'clsx'; + +export const cn = (...inputs: ClassValue[]): string => twMerge(clsx(inputs)); diff --git a/packages/docs/vitepress/theme/styles.css b/packages/docs/vitepress/theme/styles.css new file mode 100644 index 00000000..2dd853f2 --- /dev/null +++ b/packages/docs/vitepress/theme/styles.css @@ -0,0 +1,92 @@ +@import "tailwindcss"; + +@source "../../**/*.{md,ts,vue}"; + +@custom-variant dark (&:is(.dark *)); + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --font-sans: var(--docs-font-sans); + --font-mono: var(--docs-font-mono); + --radius-sm: calc(var(--radius) * 0.6); + --radius-md: calc(var(--radius) * 0.8); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) * 1.4); +} + +:root { + --docs-font-sans: "Geist Sans", var(--vp-font-family-base); + --docs-font-mono: "Geist Mono", var(--vp-font-family-mono); + --vp-font-family-base: "Geist Sans", Inter, ui-sans-serif, system-ui, sans-serif; + --vp-font-family-mono: "Geist Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; + --radius: 0.5rem; + --background: var(--vp-c-bg); + --foreground: var(--vp-c-text-1); + --card: var(--vp-c-bg-soft); + --card-foreground: var(--vp-c-text-1); + --popover: var(--vp-c-bg-elv); + --popover-foreground: var(--vp-c-text-1); + --primary: var(--vp-c-brand-1); + --primary-foreground: #ffffff; + --secondary: var(--vp-c-bg-alt); + --secondary-foreground: var(--vp-c-text-1); + --muted: var(--vp-c-bg-soft); + --muted-foreground: var(--vp-c-text-2); + --accent: var(--vp-c-default-soft); + --accent-foreground: var(--vp-c-text-1); + --destructive: #ef4444; + --border: var(--vp-c-divider); + --input: var(--vp-c-divider); + --ring: var(--vp-c-brand-2); + --terminal-background: #0c0f14; + --terminal-danger: #ef4444; + --terminal-success: #22c55e; + --terminal-warning: #facc15; +} + +.dark { + --background: var(--vp-c-bg); + --foreground: var(--vp-c-text-1); + --card: var(--vp-c-bg-soft); + --card-foreground: var(--vp-c-text-1); + --popover: var(--vp-c-bg-elv); + --popover-foreground: var(--vp-c-text-1); + --primary: var(--vp-c-brand-1); + --primary-foreground: #ffffff; + --secondary: var(--vp-c-bg-alt); + --secondary-foreground: var(--vp-c-text-1); + --muted: var(--vp-c-bg-soft); + --muted-foreground: var(--vp-c-text-2); + --accent: var(--vp-c-default-soft); + --accent-foreground: var(--vp-c-text-1); + --destructive: #f87171; + --border: var(--vp-c-divider); + --input: var(--vp-c-divider); + --ring: var(--vp-c-brand-2); +} + +.xterm { + font-family: var(--docs-font-mono); + padding: 0; +} + +.xterm-viewport { + overflow: hidden !important; +} diff --git a/packages/docs/vitepress/vue.d.ts b/packages/docs/vitepress/vue.d.ts new file mode 100644 index 00000000..7ff84371 --- /dev/null +++ b/packages/docs/vitepress/vue.d.ts @@ -0,0 +1,9 @@ +declare module '*.vue' { + import type { DefineComponent } from 'vue'; + + const component: DefineComponent; + + export default component; +} + +declare module '*.css'; diff --git a/packages/docs/vitest.config.ts b/packages/docs/vitest.config.ts new file mode 100644 index 00000000..a6c77d2f --- /dev/null +++ b/packages/docs/vitest.config.ts @@ -0,0 +1,66 @@ +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { defineConfig } from 'vitest/config'; + +const packagePath = dirname(fileURLToPath(import.meta.url)); +const packagesPath = dirname(packagePath); +const workspacePath = dirname(packagesPath); +const docsSourcePath = join(packagePath, 'src'); +const docsVitepressPath = join(packagePath, 'vitepress'); +const docsThemePath = join(docsVitepressPath, 'theme'); +const docsComponentsPath = join(docsThemePath, 'components'); +const cachePath = join(workspacePath, 'provision', '.cache', 'vitest', 'docs'); +const tuiSourcePath = join(packagesPath, 'tui', 'src', 'index.ts'); +const internalSourcePath = join(packagesPath, 'tui', 'src', '$1'); +const docsConfigPath = join(docsVitepressPath, 'config.ts'); +const docsLibPath = join(docsThemePath, 'lib'); +const docsTestsPath = join(packagePath, 'tests'); +const docsUiPath = join(docsComponentsPath, 'ui'); + +export default defineConfig({ + cacheDir: cachePath, + resolve: { + alias: [ + { + find: '@docs', + replacement: docsSourcePath, + }, + { + find: '@docs-components', + replacement: docsComponentsPath, + }, + { + find: '@docs-config', + replacement: docsConfigPath, + }, + { + find: '@docs-lib', + replacement: docsLibPath, + }, + { + find: '@docs-tests', + replacement: docsTestsPath, + }, + { + find: '@docs-theme', + replacement: docsThemePath, + }, + { + find: '@docs-ui', + replacement: docsUiPath, + }, + { + find: '@ollin/tui', + replacement: tuiSourcePath, + }, + { + find: /^#tui\/(.+)$/u, + replacement: internalSourcePath, + }, + ], + }, + test: { + environment: 'node', + globals: false, + }, +}); diff --git a/packages/tsconfig/base.json b/packages/tsconfig/base.json new file mode 100644 index 00000000..1590d292 --- /dev/null +++ b/packages/tsconfig/base.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "composite": true, + "target": "ES2022", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "moduleResolution": "Bundler", + "moduleDetection": "force", + "isolatedModules": true, + "skipLibCheck": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "types": ["node"], + "ignoreDeprecations": "6.0" + } +} diff --git a/packages/tsconfig/package.json b/packages/tsconfig/package.json new file mode 100644 index 00000000..d224a6e4 --- /dev/null +++ b/packages/tsconfig/package.json @@ -0,0 +1,9 @@ +{ + "name": "@ollin/tsconfig", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": { + "./base.json": "./base.json" + } +} diff --git a/packages/tui/package.json b/packages/tui/package.json new file mode 100644 index 00000000..c85e8c6a --- /dev/null +++ b/packages/tui/package.json @@ -0,0 +1,209 @@ +{ + "name": "@ollin/tui", + "version": "0.1.0", + "type": "module", + "types": "./dist/index.d.ts", + "main": "./dist/index.js", + "module": "./dist/index.js", + "license": "MIT", + "sideEffects": false, + "files": [ + "dist" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + }, + "./environment": { + "types": "./dist/environment.d.ts", + "import": "./dist/environment.js", + "default": "./dist/environment.js" + }, + "./form": { + "types": "./dist/form.d.ts", + "import": "./dist/form.js", + "default": "./dist/form.js" + }, + "./key": { + "types": "./dist/key.d.ts", + "import": "./dist/key.js", + "default": "./dist/key.js" + }, + "./opentui": { + "types": "./dist/opentui.d.ts", + "import": "./dist/opentui.js", + "default": "./dist/opentui.js" + }, + "./output": { + "types": "./dist/output.d.ts", + "import": "./dist/output.js", + "default": "./dist/output.js" + }, + "./output/data-table": { + "types": "./dist/output/data-table.d.ts", + "import": "./dist/output/data-table.js", + "default": "./dist/output/data-table.js" + }, + "./output/grid": { + "types": "./dist/output/grid-output.d.ts", + "import": "./dist/output/grid-output.js", + "default": "./dist/output/grid-output.js" + }, + "./output/notes": { + "types": "./dist/output/notes.d.ts", + "import": "./dist/output/notes.js", + "default": "./dist/output/notes.js" + }, + "./output/notify": { + "types": "./dist/output/notify.d.ts", + "import": "./dist/output/notify.js", + "default": "./dist/output/notify.js" + }, + "./output/table": { + "types": "./dist/output/table.d.ts", + "import": "./dist/output/table.js", + "default": "./dist/output/table.js" + }, + "./output/terminal": { + "types": "./dist/output/terminal.d.ts", + "import": "./dist/output/terminal.js", + "default": "./dist/output/terminal.js" + }, + "./prompt": { + "types": "./dist/prompt.d.ts", + "import": "./dist/prompt.js", + "default": "./dist/prompt.js" + }, + "./prompts": { + "types": "./dist/prompts.d.ts", + "import": "./dist/prompts.js", + "default": "./dist/prompts.js" + }, + "./prompts/basic": { + "types": "./dist/prompts/basic.d.ts", + "import": "./dist/prompts/basic.js", + "default": "./dist/prompts/basic.js" + }, + "./prompts/choices": { + "types": "./dist/prompts/choices.d.ts", + "import": "./dist/prompts/choices.js", + "default": "./dist/prompts/choices.js" + }, + "./prompts/number": { + "types": "./dist/prompts/number.d.ts", + "import": "./dist/prompts/number.js", + "default": "./dist/prompts/number.js" + }, + "./prompts/password": { + "types": "./dist/prompts/password.d.ts", + "import": "./dist/prompts/password.js", + "default": "./dist/prompts/password.js" + }, + "./prompts/pause": { + "types": "./dist/prompts/pause.d.ts", + "import": "./dist/prompts/pause.js", + "default": "./dist/prompts/pause.js" + }, + "./prompts/search": { + "types": "./dist/prompts/search/index.d.ts", + "import": "./dist/prompts/search/index.js", + "default": "./dist/prompts/search/index.js" + }, + "./prompts/select": { + "types": "./dist/prompts/select/index.d.ts", + "import": "./dist/prompts/select/index.js", + "default": "./dist/prompts/select/index.js" + }, + "./prompts/suggest": { + "types": "./dist/prompts/suggest/index.d.ts", + "import": "./dist/prompts/suggest/index.js", + "default": "./dist/prompts/suggest/index.js" + }, + "./prompts/text": { + "types": "./dist/prompts/text.d.ts", + "import": "./dist/prompts/text.js", + "default": "./dist/prompts/text.js" + }, + "./prompts/textarea": { + "types": "./dist/prompts/textarea.d.ts", + "import": "./dist/prompts/textarea.js", + "default": "./dist/prompts/textarea.js" + }, + "./status": { + "types": "./dist/status.d.ts", + "import": "./dist/status.js", + "default": "./dist/status.js" + }, + "./status/progress": { + "types": "./dist/status/progress.d.ts", + "import": "./dist/status/progress.js", + "default": "./dist/status/progress.js" + }, + "./status/spinner": { + "types": "./dist/status/spinner.d.ts", + "import": "./dist/status/spinner.js", + "default": "./dist/status/spinner.js" + }, + "./status/stream": { + "types": "./dist/status/stream.d.ts", + "import": "./dist/status/stream.js", + "default": "./dist/status/stream.js" + }, + "./status/task": { + "types": "./dist/status/task.d.ts", + "import": "./dist/status/task.js", + "default": "./dist/status/task.js" + }, + "./strings": { + "types": "./dist/strings.d.ts", + "import": "./dist/strings.js", + "default": "./dist/strings.js" + }, + "./terminal": { + "types": "./dist/terminal.d.ts", + "import": "./dist/terminal.js", + "default": "./dist/terminal.js" + }, + "./typed-value": { + "types": "./dist/typed-value.d.ts", + "import": "./dist/typed-value.js", + "default": "./dist/typed-value.js" + }, + "./types": { + "types": "./dist/types.d.ts", + "import": "./dist/types.js", + "default": "./dist/types.js" + } + }, + "imports": { + "#tui/*": { + "types": "./dist/*.d.ts", + "default": "./dist/*.js" + }, + "#tui/prompts/*": { + "types": "./dist/prompts/*.d.ts", + "default": "./dist/prompts/*.js" + } + }, + "scripts": { + "build": "XDG_CACHE_HOME=../../provision/.cache/xdg PLAYWRIGHT_BROWSERS_PATH=../../provision/.cache/playwright tsc -p tsconfig.build.json", + "prepack": "pnpm build", + "test": "XDG_CACHE_HOME=../../provision/.cache/xdg PLAYWRIGHT_BROWSERS_PATH=../../provision/.cache/playwright vitest run --config vitest.config.ts", + "typecheck": "XDG_CACHE_HOME=../../provision/.cache/xdg PLAYWRIGHT_BROWSERS_PATH=../../provision/.cache/playwright tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@opentui/core": "^0.4.0", + "string-width": "^7.2.0", + "strip-ansi": "^7.1.2", + "zod": "^4.4.3" + }, + "devDependencies": { + "@ollin/artefacts": "workspace:*", + "@ollin/tsconfig": "workspace:*", + "@types/node": "^25.6.2", + "typescript": "^6.0.3", + "vitest": "^4.1.5" + } +} diff --git a/packages/tui/src/concerns/choice-label.ts b/packages/tui/src/concerns/choice-label.ts new file mode 100644 index 00000000..23b72cdb --- /dev/null +++ b/packages/tui/src/concerns/choice-label.ts @@ -0,0 +1,9 @@ +import { choiceDisabledSuffix } from '#tui/theme/choice-disabled'; +import type { Choice } from '#tui/types'; + +export const choiceLabel = (choice: Choice): string => { + const disabled = choiceDisabledSuffix(choice.disabled); + const hint = choice.hint ? ` ${choice.hint}` : ''; + + return `${choice.label}${hint}${disabled}`; +}; diff --git a/packages/tui/src/concerns/choice-match.ts b/packages/tui/src/concerns/choice-match.ts new file mode 100644 index 00000000..7617ab3e --- /dev/null +++ b/packages/tui/src/concerns/choice-match.ts @@ -0,0 +1,18 @@ +import { isDeepStrictEqual } from 'node:util'; +import { answerChoiceIndex } from '#tui/concerns/choice-normalize'; +import type { Choice } from '#tui/types'; + +export const choiceValueEquals = (left: unknown, right: unknown): boolean => Object.is(left, right) || isDeepStrictEqual(left, right); + +export const choiceByValue = (choices: Array>, value: unknown): Choice | undefined => choices.find((choice) => !choice.disabled && choiceValueEquals(choice.value, value)); + +export const findChoice = (choices: Array>, answer: string): Choice | undefined => { + const normalizedAnswer = answer.trim(); + const index = answerChoiceIndex(normalizedAnswer); + + if (index !== null) { + return choices[index - 1]; + } + + return choices.find((choice) => choice.label === normalizedAnswer || String(choice.value) === normalizedAnswer); +}; diff --git a/packages/tui/src/concerns/choice-navigation.ts b/packages/tui/src/concerns/choice-navigation.ts new file mode 100644 index 00000000..66ba87c7 --- /dev/null +++ b/packages/tui/src/concerns/choice-navigation.ts @@ -0,0 +1,39 @@ +import { parseOptionalScrollSize } from '#tui/concerns/validators/scroll'; +import type { Choice } from '#tui/types'; + +export const firstEnabledIndex = (choices: Array>): number => { + const index = choices.findIndex((choice) => !choice.disabled); + + return index === -1 ? 0 : index; +}; + +export const nextEnabledIndex = (choices: Array>, current: number, direction: 1 | -1): number => { + if (choices.length === 0) { + return 0; + } + + let index = current; + + for (let attempts = 0; attempts < choices.length; attempts += 1) { + index = (index + direction + choices.length) % choices.length; + + if (!choices[index]?.disabled) { + return index; + } + } + + return current; +}; + +export const choiceWindow = (total: number, selected: number, scroll?: number): { end: number; start: number } => { + const size = parseOptionalScrollSize(scroll); + + if (size === undefined || size >= total) { + return { end: total, start: 0 }; + } + + const before = Math.floor((size - 1) / 2); + const start = Math.max(0, Math.min(selected - before, total - size)); + + return { end: start + size, start }; +}; diff --git a/packages/tui/src/concerns/choice-normalize.ts b/packages/tui/src/concerns/choice-normalize.ts new file mode 100644 index 00000000..05e5c99f --- /dev/null +++ b/packages/tui/src/concerns/choice-normalize.ts @@ -0,0 +1,31 @@ +import { parseChoiceAnswerIndex, parseChoiceRecordValue } from '#tui/concerns/validators/choice-answer'; +import { parseChoice, parseChoiceOptions, parseChoiceRecordEntries, parseChoiceValue } from '#tui/concerns/validators/choice'; +import type { Choice, ChoiceOptions } from '#tui/types'; + +export const normalizeChoices = (options: ChoiceOptions): Array> => { + const parsed = parseChoiceOptions(options); + + if (parsed.kind === 'record') { + return parseChoiceRecordEntries(parsed.options).map(({ label, value }) => ({ + label, + value: parseChoiceRecordValue(value), + })); + } + + return parsed.options.map((choice) => { + const parsed = parseChoice(choice); + + if (parsed) { + return parsed; + } + + return { + label: String(choice), + value: parseChoiceValue(choice), + }; + }); +}; + +export const normalizeSearchChoices = (options: ChoiceOptions): Array> => normalizeChoices(options); + +export const answerChoiceIndex = (answer: string): number | null => parseChoiceAnswerIndex(answer.trim()); diff --git a/packages/tui/src/concerns/choice-render.ts b/packages/tui/src/concerns/choice-render.ts new file mode 100644 index 00000000..e69295a1 --- /dev/null +++ b/packages/tui/src/concerns/choice-render.ts @@ -0,0 +1,63 @@ +import { promptEnvironment } from '#tui/environment'; +import { choiceWindow } from '#tui/concerns/choice-navigation'; +import { renderScrollbarRows } from '#tui/concerns/scrollbar'; +import { choiceDisabledSuffix } from '#tui/theme/choice-disabled'; +import { cyan, dim } from '#tui/theme/styles'; +import type { Choice } from '#tui/types'; + +export const renderInteractiveChoices = (message: string, choices: Array>, selected: number, marked: Set = new Set(), scroll?: number): void => { + const environment = promptEnvironment(); + const window = choiceWindow(choices.length, selected, scroll); + + environment.output.write(`${message}\n`); + + const rows = choices.slice(window.start, window.end).map((choice, offset) => { + const index = window.start + offset; + const pointer = index === selected ? '›' : ' '; + const checked = marked.size > 0 ? (marked.has(index) ? '[x]' : '[ ]') : ' '; + const disabled = choiceDisabledSuffix(choice.disabled); + const hint = choice.hint ? ` ${choice.hint}` : ''; + + return `${pointer} ${checked} ${choice.label}${hint}${disabled}`; + }); + + for (const row of renderScrollbarRows(rows, window.start, window.end - window.start, choices.length)) { + environment.output.write(`${row}\n`); + } +}; + +export const renderInteractiveChecklist = (message: string, choices: Array>, selected: number, marked: Set, scroll?: number): void => { + const environment = promptEnvironment(); + const window = choiceWindow(choices.length, selected, scroll); + + environment.output.write(`${message}\n`); + + const rows = choices.slice(window.start, window.end).map((choice, offset) => { + const index = window.start + offset; + const active = index === selected; + const checked = marked.has(index); + const pointer = active ? '›' : ' '; + const marker = checked ? '◼' : '◻'; + const disabled = choiceDisabledSuffix(choice.disabled); + const hint = choice.hint ? ` ${choice.hint}` : ''; + const label = `${choice.label}${hint}${disabled}`; + + if (active && checked) { + return `${cyan(`${pointer} ${marker}`)} ${label}`; + } + + if (active) { + return `${cyan(pointer)} ${marker} ${label}`; + } + + if (checked) { + return ` ${cyan(marker)} ${dim(label)}`; + } + + return ` ${dim(marker)} ${dim(label)}`; + }); + + for (const row of renderScrollbarRows(rows, window.start, window.end - window.start, choices.length)) { + environment.output.write(`${row}\n`); + } +}; diff --git a/packages/tui/src/concerns/choices.ts b/packages/tui/src/concerns/choices.ts new file mode 100644 index 00000000..3d7b060f --- /dev/null +++ b/packages/tui/src/concerns/choices.ts @@ -0,0 +1,4 @@ +export { choiceByValue, choiceValueEquals, findChoice } from '#tui/concerns/choice-match'; +export { choiceWindow, firstEnabledIndex, nextEnabledIndex } from '#tui/concerns/choice-navigation'; +export { normalizeChoices, normalizeSearchChoices } from '#tui/concerns/choice-normalize'; +export { renderInteractiveChoices, renderInteractiveChecklist } from '#tui/concerns/choice-render'; diff --git a/packages/tui/src/concerns/info.ts b/packages/tui/src/concerns/info.ts new file mode 100644 index 00000000..cbd411c7 --- /dev/null +++ b/packages/tui/src/concerns/info.ts @@ -0,0 +1,15 @@ +import { isInfoCallback } from '#tui/concerns/validators/info'; + +export type InfoResolver = string | ((value: T | null) => string | null | undefined) | undefined; + +export const resolveInfo = (info: InfoResolver, value: T | null): string => { + if (isInfoCallback(info)) { + return info(value) ?? ''; + } + + return info ?? ''; +}; + +export const joinedInfoDetails = (...parts: string[]): string => { + return parts.filter((part) => part.length > 0).join(' · '); +}; diff --git a/packages/tui/src/concerns/scrollbar.ts b/packages/tui/src/concerns/scrollbar.ts new file mode 100644 index 00000000..4619cdbc --- /dev/null +++ b/packages/tui/src/concerns/scrollbar.ts @@ -0,0 +1,42 @@ +import { parseScrollbarState } from '#tui/concerns/validators/scrollbar'; +import { visibleWidth } from '#tui/strings'; +import { cyan, dim } from '#tui/theme/styles'; + +export const scrollbarPosition = (firstVisible: number, height: number, total: number): number => { + if (firstVisible === 0) { + return 0; + } + + const maxPosition = total - height; + + if (firstVisible === maxPosition) { + return height - 1; + } + + if (height <= 2) { + return -1; + } + + return Math.round((firstVisible / maxPosition) * (height - 3)) + 1; +}; + +export const renderScrollbarRows = (rows: string[], firstVisible: number, height: number, total: number, style = cyan): string[] => { + const state = parseScrollbarState({ firstVisible, height, total }); + + if (state === undefined || rows.length === 0) { + return rows; + } + + const position = scrollbarPosition(state.firstVisible, state.height, state.total); + const width = Math.max(...rows.map((row) => visibleWidth(row))); + + return rows.map((row, index) => { + const bar = index === position ? style('┃') : dim('│'); + + return `${padVisible(row, width)} ${bar}`; + }); +}; + +const padVisible = (value: string, width: number): string => { + return `${value}${' '.repeat(Math.max(0, width - visibleWidth(value)))}`; +}; diff --git a/packages/tui/src/concerns/text-options.ts b/packages/tui/src/concerns/text-options.ts new file mode 100644 index 00000000..afdf6055 --- /dev/null +++ b/packages/tui/src/concerns/text-options.ts @@ -0,0 +1,10 @@ +import { isTextPromptLabel } from '#tui/concerns/validators/text-options'; +import type { TextPromptOptions } from '#tui/types'; + +export const textOptions = (message: string | TextPromptOptions, fallback = ''): TextPromptOptions => { + if (isTextPromptLabel(message)) { + return { message, default: fallback }; + } + + return { ...message, default: message.default ?? fallback }; +}; diff --git a/packages/tui/src/concerns/validators/choice-answer.ts b/packages/tui/src/concerns/validators/choice-answer.ts new file mode 100644 index 00000000..1b02b7ad --- /dev/null +++ b/packages/tui/src/concerns/validators/choice-answer.ts @@ -0,0 +1,53 @@ +import { z } from 'zod'; + +const choiceAnswerIndexSchema = z + .string() + .trim() + .regex(/^\d+$/u) + .transform((value) => Number.parseInt(value, 10)); + +const choiceAnswerListSchema = z.string().transform((value) => + value + .split(',') + .map((part) => part.trim()) + .filter((part) => part.length > 0), +); + +const choiceRecordKeySchema = z + .string() + .regex(/^-?\d+$/u) + .transform((value) => Number.parseInt(value, 10)); + +const choiceRecordValueSchema = (): z.ZodType => z.unknown() as z.ZodType; + +export const parseChoiceAnswerIndex = (answer: string): number | null => { + const parsed = choiceAnswerIndexSchema.safeParse(answer); + + return parsed.success ? parsed.data : null; +}; + +export const parseChoiceAnswerList = (answer: unknown): string[] => { + const parsed = choiceAnswerListSchema.safeParse(answer); + + if (!parsed.success) { + throw new TypeError('Choice answers must be a comma-separated string.'); + } + + return parsed.data; +}; + +export const parseChoiceRecordKey = (key: string): string | number => { + const parsed = choiceRecordKeySchema.safeParse(key); + + return parsed.success ? parsed.data : key; +}; + +export const parseChoiceRecordValue = (key: string): T => { + const parsed = choiceRecordValueSchema().safeParse(parseChoiceRecordKey(key)); + + if (!parsed.success) { + throw new TypeError('Choice record keys must resolve to a choice value.'); + } + + return parsed.data; +}; diff --git a/packages/tui/src/concerns/validators/choice.ts b/packages/tui/src/concerns/validators/choice.ts new file mode 100644 index 00000000..bce1f185 --- /dev/null +++ b/packages/tui/src/concerns/validators/choice.ts @@ -0,0 +1,82 @@ +import { z } from 'zod'; +import type { Choice, ChoiceOptions } from '#tui/types'; + +const choiceSchema = z + .object({ + disabled: z.union([z.boolean(), z.string()]).optional(), + hint: z.string().optional(), + label: z.string(), + value: z.unknown(), + }) + .passthrough(); + +const choiceRecordSchema = z.record(z.string(), z.string()); +const choiceOptionsListSchema = z.array(z.unknown()); +const typedChoiceSchema = (): z.ZodType> => choiceSchema as z.ZodType>; +const typedChoiceOptionsListSchema = (): z.ZodType | T>> => choiceOptionsListSchema as z.ZodType | T>>; +const choiceValueSchema = (): z.ZodType => z.unknown() as z.ZodType; + +export const choiceOptionsSchema = (): z.ZodType> => z.union([choiceOptionsListSchema, choiceRecordSchema]) as z.ZodType>; + +export type ChoiceRecordEntry = { + label: string; + value: string; +}; + +export type ParsedChoiceOptions = + | { + kind: 'list'; + options: Array | T>; + } + | { + kind: 'record'; + options: Record; + }; + +export const parseChoice = (value: unknown): Choice | null => { + const parsed = typedChoiceSchema().safeParse(value); + + return parsed.success ? parsed.data : null; +}; + +export const parseChoiceRecord = (value: unknown): Record | null => { + const parsed = choiceRecordSchema.safeParse(value); + + return parsed.success ? parsed.data : null; +}; + +export const parseChoiceRecordEntries = (value: unknown): ChoiceRecordEntry[] => { + const parsed = choiceRecordSchema.safeParse(value); + + if (!parsed.success) { + throw new TypeError('Choice records must map string values to string labels.'); + } + + return Object.entries(parsed.data).map(([value, label]) => ({ label, value })); +}; + +export const parseChoiceOptions = (value: ChoiceOptions): ParsedChoiceOptions => { + const record = choiceRecordSchema.safeParse(value); + + if (record.success) { + return { kind: 'record', options: record.data }; + } + + const list = typedChoiceOptionsListSchema().safeParse(value); + + if (!list.success) { + throw new TypeError('Choice options must be an array or record.'); + } + + return { kind: 'list', options: list.data }; +}; + +export const parseChoiceValue = (value: unknown): T => { + const parsed = choiceValueSchema().safeParse(value); + + if (!parsed.success) { + throw new TypeError('Choice values must resolve to a typed value.'); + } + + return parsed.data; +}; diff --git a/packages/tui/src/concerns/validators/info.ts b/packages/tui/src/concerns/validators/info.ts new file mode 100644 index 00000000..3fcb3fa2 --- /dev/null +++ b/packages/tui/src/concerns/validators/info.ts @@ -0,0 +1,10 @@ +import { z } from 'zod'; +import type { InfoResolver } from '#tui/concerns/info'; + +type InfoCallback = Exclude, string | undefined>; + +const infoCallbackSchema = z.function(); + +export const isInfoCallback = (info: InfoResolver): info is InfoCallback => { + return infoCallbackSchema.safeParse(info).success; +}; diff --git a/packages/tui/src/concerns/validators/scroll.ts b/packages/tui/src/concerns/validators/scroll.ts new file mode 100644 index 00000000..89dcc20b --- /dev/null +++ b/packages/tui/src/concerns/validators/scroll.ts @@ -0,0 +1,13 @@ +import { z } from 'zod'; + +const scrollSchema = z.number().finite().positive(); + +export const parseOptionalScrollSize = (scroll: unknown): number | undefined => { + const parsed = scrollSchema.safeParse(scroll); + + return parsed.success ? Math.max(1, Math.trunc(parsed.data)) : undefined; +}; + +export const parseScrollSize = (scroll: unknown, defaultValue: number): number => { + return parseOptionalScrollSize(scroll) ?? defaultValue; +}; diff --git a/packages/tui/src/concerns/validators/scrollbar.ts b/packages/tui/src/concerns/validators/scrollbar.ts new file mode 100644 index 00000000..c76dad09 --- /dev/null +++ b/packages/tui/src/concerns/validators/scrollbar.ts @@ -0,0 +1,28 @@ +import { z } from 'zod'; + +const scrollbarStateSchema = z + .object({ + firstVisible: z.number().finite().nonnegative(), + height: z.number().finite().positive(), + total: z.number().finite().nonnegative(), + }) + .transform(({ firstVisible, height, total }) => ({ + firstVisible: Math.floor(firstVisible), + height: Math.floor(height), + total: Math.floor(total), + })); + +export type ScrollbarState = z.infer; + +export const parseScrollbarState = (state: unknown): ScrollbarState | undefined => { + const parsed = scrollbarStateSchema.safeParse(state); + + if (!parsed.success || parsed.data.total <= parsed.data.height) { + return undefined; + } + + return { + ...parsed.data, + firstVisible: Math.min(parsed.data.firstVisible, parsed.data.total - parsed.data.height), + }; +}; diff --git a/packages/tui/src/concerns/validators/text-options.ts b/packages/tui/src/concerns/validators/text-options.ts new file mode 100644 index 00000000..e9fa2ce2 --- /dev/null +++ b/packages/tui/src/concerns/validators/text-options.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; + +const textPromptLabelSchema = z.string(); + +export const isTextPromptLabel = (value: unknown): value is string => { + return textPromptLabelSchema.safeParse(value).success; +}; diff --git a/packages/tui/src/contracts/base.ts b/packages/tui/src/contracts/base.ts new file mode 100644 index 00000000..3c3cc357 --- /dev/null +++ b/packages/tui/src/contracts/base.ts @@ -0,0 +1,7 @@ +export type MaybePromise = T | Promise; + +export type ValidationResult = string | null | undefined; + +export type Validator = (value: T) => MaybePromise; + +export type PromptValue = string | number | boolean | string[] | number[]; diff --git a/packages/tui/src/contracts/choices.ts b/packages/tui/src/contracts/choices.ts new file mode 100644 index 00000000..14177a17 --- /dev/null +++ b/packages/tui/src/contracts/choices.ts @@ -0,0 +1,12 @@ +export type Choice = { + label: string; + value: T; + hint?: string; + disabled?: boolean | string; +}; + +export type ChoiceInput = Choice | T; + +export type ChoiceOptions = Array> | Record; + +export type PromptInfo = string | ((value: T | null) => string | null | undefined); diff --git a/packages/tui/src/contracts/environment.ts b/packages/tui/src/contracts/environment.ts new file mode 100644 index 00000000..fc0ea335 --- /dev/null +++ b/packages/tui/src/contracts/environment.ts @@ -0,0 +1,17 @@ +import type { MaybePromise } from '#tui/contracts/base'; + +export type PromptOutput = { + write(content: string): void; +}; + +export type PromptInput = { + readKey?(): MaybePromise; + readLine?(message: string): MaybePromise; +}; + +export type PromptEnvironment = { + input: PromptInput; + output: PromptOutput; + error: PromptOutput; + interactive: boolean; +}; diff --git a/packages/tui/src/contracts/options.ts b/packages/tui/src/contracts/options.ts new file mode 100644 index 00000000..2263fdaf --- /dev/null +++ b/packages/tui/src/contracts/options.ts @@ -0,0 +1,9 @@ +import type { Validator } from '#tui/contracts/base'; + +export type BasePromptOptions = { + message: string; + default?: T; + required?: boolean | string; + hint?: string; + validate?: Validator; +}; diff --git a/packages/tui/src/contracts/output.ts b/packages/tui/src/contracts/output.ts new file mode 100644 index 00000000..238beef4 --- /dev/null +++ b/packages/tui/src/contracts/output.ts @@ -0,0 +1,24 @@ +import type { BasePromptOptions } from '#tui/contracts/options'; +import type { MaybePromise } from '#tui/contracts/base'; + +export type TableCell = string | number | boolean | null | undefined; + +export type TableOptions = { + headers?: string[]; + rows: Array | Record>; +}; + +export type DataTablePromptOptions = BasePromptOptions & { + filter?: (query: string, row: DataTableRow) => boolean; + headers?: string[]; + rows: Array>; + scroll?: number; + transform?: (value: T | number) => MaybePromise; +}; + +export type DataTableObjectRow = { + cells: Record; + value?: T; +}; + +export type DataTableRow = Array | Record | DataTableObjectRow; diff --git a/packages/tui/src/contracts/prompts.ts b/packages/tui/src/contracts/prompts.ts new file mode 100644 index 00000000..3f74f228 --- /dev/null +++ b/packages/tui/src/contracts/prompts.ts @@ -0,0 +1,66 @@ +import type { MaybePromise } from '#tui/contracts/base'; +import type { ChoiceOptions, PromptInfo } from '#tui/contracts/choices'; +import type { BasePromptOptions } from '#tui/contracts/options'; + +export type TextPromptOptions = BasePromptOptions & { + label?: string; + options?: string[] | ((query: string) => MaybePromise); + placeholder?: string; + transform?: (value: string) => MaybePromise; +}; + +export type TextareaPromptOptions = TextPromptOptions & { + rows?: number; +}; + +export type NumberPromptOptions = BasePromptOptions & { + label?: string; + placeholder?: string; + min?: number; + max?: number; + integer?: boolean; + step?: number; + transform?: (value: number | string) => MaybePromise; +}; + +export type ConfirmPromptOptions = BasePromptOptions & { + label?: string; + yes?: string; + no?: string; + transform?: (value: boolean) => MaybePromise; +}; + +export type SelectPromptOptions = BasePromptOptions & { + label?: string; + options: ChoiceOptions; + scroll?: number; + info?: PromptInfo; + transform?: (value: T) => MaybePromise; +}; + +export type MultiSelectPromptOptions = BasePromptOptions & { + label?: string; + options: ChoiceOptions; + scroll?: number; + info?: PromptInfo; + transform?: (value: T[]) => MaybePromise; +}; + +export type SearchPromptOptions = Omit, 'required'> & { + label?: string; + options: ChoiceOptions | ((query: string) => MaybePromise>); + placeholder?: string; + required?: true | string; + scroll?: number; + info?: PromptInfo; + transform?: (value: T) => MaybePromise; +}; + +export type MultiSearchPromptOptions = BasePromptOptions & { + label?: string; + options: ChoiceOptions | ((query: string) => MaybePromise>); + placeholder?: string; + scroll?: number; + info?: PromptInfo; + transform?: (value: T[]) => MaybePromise; +}; diff --git a/packages/tui/src/contracts/status.ts b/packages/tui/src/contracts/status.ts new file mode 100644 index 00000000..24afa8a7 --- /dev/null +++ b/packages/tui/src/contracts/status.ts @@ -0,0 +1,3 @@ +export type StatusOptions = { + message?: string; +}; diff --git a/packages/tui/src/environment.ts b/packages/tui/src/environment.ts new file mode 100644 index 00000000..c625150d --- /dev/null +++ b/packages/tui/src/environment.ts @@ -0,0 +1,5 @@ +export { configurePrompts, promptEnvironment, withPromptEnvironment } from '#tui/environment/scope'; +export { createMemoryOutput } from '#tui/environment/memory-output'; +export { createScriptedInput } from '#tui/environment/scripted-input'; +export type { MemoryOutput } from '#tui/environment/memory-output'; +export type { RawKeyInput } from '#tui/environment/raw-key'; diff --git a/packages/tui/src/environment/default-input.ts b/packages/tui/src/environment/default-input.ts new file mode 100644 index 00000000..8786924b --- /dev/null +++ b/packages/tui/src/environment/default-input.ts @@ -0,0 +1,22 @@ +import { stdin as defaultStdin, stdout as defaultStdout } from 'node:process'; +import { createInterface } from 'node:readline/promises'; +import { readRawKey } from '#tui/environment/raw-key'; +import type { PromptInput } from '#tui/types'; + +export const defaultInput: PromptInput = { + async readKey(): Promise { + return readRawKey(defaultStdin); + }, + async readLine(message: string): Promise { + const readline = createInterface({ + input: defaultStdin, + output: defaultStdout, + }); + + try { + return await readline.question(message); + } finally { + readline.close(); + } + }, +}; diff --git a/packages/tui/src/environment/memory-output.ts b/packages/tui/src/environment/memory-output.ts new file mode 100644 index 00000000..3fc2b6ce --- /dev/null +++ b/packages/tui/src/environment/memory-output.ts @@ -0,0 +1,23 @@ +import { parsePromptOutputContent } from '#tui/environment/validators/output'; +import type { PromptOutput } from '#tui/types'; + +export type MemoryOutput = PromptOutput & { + clear(): void; + text(): string; +}; + +export const createMemoryOutput = (): MemoryOutput => { + let buffer = ''; + + return { + clear(): void { + buffer = ''; + }, + write(content: string): void { + buffer += parsePromptOutputContent(content); + }, + text(): string { + return buffer; + }, + }; +}; diff --git a/packages/tui/src/environment/raw-key.ts b/packages/tui/src/environment/raw-key.ts new file mode 100644 index 00000000..6dc0d4ea --- /dev/null +++ b/packages/tui/src/environment/raw-key.ts @@ -0,0 +1,27 @@ +import { createRawKeyInputMode } from '#tui/environment/raw-key/mode'; +import { createRawKeySession } from '#tui/environment/raw-key/session'; +import type { RawKeyInput } from '#tui/environment/raw-key/types'; + +export type { RawKeyInput }; + +export const readRawKey = async (input: RawKeyInput): Promise => + new Promise((resolve, reject) => { + const mode = createRawKeyInputMode(input); + const session = createRawKeySession(input, mode, resolve, reject); + + input.on('data', session.onData); + input.once('end', session.onEnd); + input.once('error', session.onError); + + if (input.isTTY) { + try { + mode.activate(); + } catch (error) { + input.off('data', session.onData); + input.off('end', session.onEnd); + input.off('error', session.onError); + mode.restore(); + reject(error); + } + } + }); diff --git a/packages/tui/src/environment/raw-key/mode.ts b/packages/tui/src/environment/raw-key/mode.ts new file mode 100644 index 00000000..fcdc0a8b --- /dev/null +++ b/packages/tui/src/environment/raw-key/mode.ts @@ -0,0 +1,37 @@ +import type { RawKeyInput } from '#tui/environment/raw-key/types'; + +export type RawKeyInputMode = { + activate(): void; + restore(): void; +}; + +export const createRawKeyInputMode = (input: RawKeyInput): RawKeyInputMode => { + const wasRaw = Boolean(input.isRaw); + const wasPaused = input.isPaused?.() ?? true; + + return { + activate() { + if (!input.isTTY) { + return; + } + + input.setRawMode?.(true); + input.resume(); + }, + restore() { + if (!input.isTTY) { + return; + } + + try { + input.setRawMode?.(wasRaw); + } finally { + if (wasPaused) { + input.pause(); + } else { + input.resume(); + } + } + }, + }; +}; diff --git a/packages/tui/src/environment/raw-key/normalize.ts b/packages/tui/src/environment/raw-key/normalize.ts new file mode 100644 index 00000000..cfc16a44 --- /dev/null +++ b/packages/tui/src/environment/raw-key/normalize.ts @@ -0,0 +1,21 @@ +import { Key } from '#tui/key'; +import { parseKnownKeyValues } from '#tui/key/validators/value'; + +const knownRawKeys = parseKnownKeyValues(); + +const rawKeyAliases = new Map([ + ['\r', Key.enter], + ['\u0008', Key.backspace], +]); + +export const normalizeRawKey = (value: string): string => { + return rawKeyAliases.get(value) ?? value; +}; + +export const isCompleteRawKey = (value: string): boolean => { + return rawKeyAliases.has(value) || !isPartialRawKey(value); +}; + +export const isPartialRawKey = (value: string): boolean => { + return knownRawKeys.some((key) => key !== value && key.startsWith(value)); +}; diff --git a/packages/tui/src/environment/raw-key/session.ts b/packages/tui/src/environment/raw-key/session.ts new file mode 100644 index 00000000..f7884df7 --- /dev/null +++ b/packages/tui/src/environment/raw-key/session.ts @@ -0,0 +1,94 @@ +import { isCompleteRawKey, normalizeRawKey } from '#tui/environment/raw-key/normalize'; +import { parseRawKeyChunkText } from '#tui/environment/raw-key/validators/chunk'; +import type { RawKeyInputMode } from '#tui/environment/raw-key/mode'; +import type { RawKeyInput } from '#tui/environment/raw-key/types'; + +type RawKeySessionHandlers = { + onData(chunk: unknown): void; + onEnd(): void; + onError(error: Error): void; +}; + +export const createRawKeySession = (input: RawKeyInput, mode: RawKeyInputMode, resolve: (value: null | string) => void, reject: (reason?: unknown) => void): RawKeySessionHandlers => { + let buffer = ''; + let escapeTimer: NodeJS.Timeout | undefined; + let settled = false; + + const cleanup = (): void => { + clearTimeout(escapeTimer); + input.off('data', handlers.onData); + input.off('end', handlers.onEnd); + input.off('error', handlers.onError); + mode.restore(); + }; + + const resolveWithCleanup = (value: null | string): void => { + if (settled) { + return; + } + + settled = true; + + try { + cleanup(); + } catch (error) { + reject(error); + + return; + } + + resolve(value); + }; + + const rejectWithCleanup = (error: unknown): void => { + if (settled) { + return; + } + + settled = true; + + try { + cleanup(); + } catch (cleanupError) { + reject(cleanupError); + + return; + } + + reject(error); + }; + + const resolveBufferedKey = (): void => { + resolveWithCleanup(normalizeRawKey(buffer)); + }; + + const handlers: RawKeySessionHandlers = { + onData(chunk) { + try { + buffer += parseRawKeyChunkText(chunk); + } catch (error) { + rejectWithCleanup(error); + + return; + } + + if (isCompleteRawKey(buffer)) { + resolveBufferedKey(); + + return; + } + + clearTimeout(escapeTimer); + escapeTimer = setTimeout(resolveBufferedKey, 25); + escapeTimer.unref?.(); + }, + onEnd() { + resolveWithCleanup(buffer.length > 0 ? normalizeRawKey(buffer) : null); + }, + onError(error) { + rejectWithCleanup(error); + }, + }; + + return handlers; +}; diff --git a/packages/tui/src/environment/raw-key/types.ts b/packages/tui/src/environment/raw-key/types.ts new file mode 100644 index 00000000..35d649ae --- /dev/null +++ b/packages/tui/src/environment/raw-key/types.ts @@ -0,0 +1,13 @@ +type RawKeyListener = (...args: any[]) => void; + +export type RawKeyInput = { + isPaused?(): boolean; + isRaw?: boolean; + isTTY?: boolean; + off(eventName: string | symbol, listener: RawKeyListener): unknown; + on(eventName: string | symbol, listener: RawKeyListener): unknown; + once(eventName: string | symbol, listener: RawKeyListener): unknown; + pause(): unknown; + resume(): unknown; + setRawMode?(mode: boolean): unknown; +}; diff --git a/packages/tui/src/environment/raw-key/validators/chunk.ts b/packages/tui/src/environment/raw-key/validators/chunk.ts new file mode 100644 index 00000000..21599561 --- /dev/null +++ b/packages/tui/src/environment/raw-key/validators/chunk.ts @@ -0,0 +1,15 @@ +import { z } from 'zod'; + +const bufferChunkSchema = z.custom(Buffer.isBuffer); + +const rawKeyChunkTextSchema = z.union([z.string(), bufferChunkSchema.transform((chunk) => chunk.toString('utf8'))]); + +export const parseRawKeyChunkText = (chunk: unknown): string => { + const parsed = rawKeyChunkTextSchema.safeParse(chunk); + + if (!parsed.success) { + throw new Error('Raw key chunks must be strings or buffers.'); + } + + return parsed.data; +}; diff --git a/packages/tui/src/environment/runtime.ts b/packages/tui/src/environment/runtime.ts new file mode 100644 index 00000000..1feafba7 --- /dev/null +++ b/packages/tui/src/environment/runtime.ts @@ -0,0 +1,11 @@ +import { stderr as defaultStderr, stdin as defaultStdin, stdout as defaultStdout } from 'node:process'; +import { defaultInput } from '#tui/environment/default-input'; +import { outputFromStream } from '#tui/environment/stream-output'; +import type { PromptEnvironment } from '#tui/types'; + +export const defaultEnvironment: PromptEnvironment = { + input: defaultInput, + output: outputFromStream(defaultStdout), + error: outputFromStream(defaultStderr), + interactive: Boolean(defaultStdin.isTTY), +}; diff --git a/packages/tui/src/environment/scope.ts b/packages/tui/src/environment/scope.ts new file mode 100644 index 00000000..6e042ac0 --- /dev/null +++ b/packages/tui/src/environment/scope.ts @@ -0,0 +1,32 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; +import { defaultEnvironment } from '#tui/environment/runtime'; +import { mergePromptEnvironment } from '#tui/environment/scope/merge'; +import { withPromptCancelScope } from '#tui/prompt/cancel'; +import { withPromptFallbackScope } from '#tui/prompt/fallback'; +import { withPromptGlobalValidationScope } from '#tui/prompt/validate-using'; +import type { MaybePromise, PromptEnvironment } from '#tui/types'; + +let currentEnvironment: PromptEnvironment = defaultEnvironment; + +const scopedEnvironment = new AsyncLocalStorage(); + +export const promptEnvironment = (): PromptEnvironment => scopedEnvironment.getStore() ?? currentEnvironment; + +export const configurePrompts = (environment: Partial): void => { + const scoped = scopedEnvironment.getStore(); + const next = mergePromptEnvironment(scoped ?? currentEnvironment, environment); + + if (scoped === undefined) { + currentEnvironment = next; + + return; + } + + scopedEnvironment.enterWith(next); +}; + +export const withPromptEnvironment = async (environment: Partial, callback: () => MaybePromise): Promise => { + const next = mergePromptEnvironment(promptEnvironment(), environment); + + return scopedEnvironment.run(next, () => withPromptCancelScope(() => withPromptFallbackScope(() => withPromptGlobalValidationScope(callback)))); +}; diff --git a/packages/tui/src/environment/scope/merge.ts b/packages/tui/src/environment/scope/merge.ts new file mode 100644 index 00000000..892382f3 --- /dev/null +++ b/packages/tui/src/environment/scope/merge.ts @@ -0,0 +1,12 @@ +import { parsePromptEnvironmentPatch } from '#tui/environment/scope/validators/patch'; +import type { PromptEnvironment } from '#tui/types'; + +export const mergePromptEnvironment = (current: PromptEnvironment, patch: Partial): PromptEnvironment => { + const environment = parsePromptEnvironmentPatch(patch); + + return { + ...current, + ...environment, + input: environment.input === undefined ? current.input : environment.input, + }; +}; diff --git a/packages/tui/src/environment/scope/validators/patch.ts b/packages/tui/src/environment/scope/validators/patch.ts new file mode 100644 index 00000000..26eb1052 --- /dev/null +++ b/packages/tui/src/environment/scope/validators/patch.ts @@ -0,0 +1,35 @@ +import { z } from 'zod'; +import { functionSchema } from '#tui/validators/function'; +import type { PromptEnvironment, PromptInput, PromptOutput } from '#tui/types'; + +const promptInputPatchSchema = z + .object({ + readKey: functionSchema>().optional(), + readLine: functionSchema>().optional(), + }) + .passthrough() as unknown as z.ZodType; + +const promptOutputPatchSchema = z + .object({ + write: functionSchema(), + }) + .passthrough() as unknown as z.ZodType; + +const promptEnvironmentPatchSchema = z + .object({ + error: promptOutputPatchSchema.optional(), + input: promptInputPatchSchema.optional(), + interactive: z.boolean().optional(), + output: promptOutputPatchSchema.optional(), + }) + .passthrough() as unknown as z.ZodType>; + +export const parsePromptEnvironmentPatch = (environment: Partial): Partial => { + const parsed = promptEnvironmentPatchSchema.safeParse(environment); + + if (!parsed.success) { + throw new Error('Prompt environment patches must include valid input, output, error, or interactive values.'); + } + + return environment; +}; diff --git a/packages/tui/src/environment/scripted-input.ts b/packages/tui/src/environment/scripted-input.ts new file mode 100644 index 00000000..b1db05b4 --- /dev/null +++ b/packages/tui/src/environment/scripted-input.ts @@ -0,0 +1,15 @@ +import { parseScriptedInputLines } from '#tui/environment/validators/scripted-input'; +import type { PromptInput } from '#tui/types'; + +export const createScriptedInput = (lines: string[]): PromptInput => { + const queued = parseScriptedInputLines(lines); + + return { + async readKey(): Promise { + return queued.shift() ?? null; + }, + async readLine(): Promise { + return queued.shift() ?? ''; + }, + }; +}; diff --git a/packages/tui/src/environment/stream-output.ts b/packages/tui/src/environment/stream-output.ts new file mode 100644 index 00000000..9aa6c6d9 --- /dev/null +++ b/packages/tui/src/environment/stream-output.ts @@ -0,0 +1,14 @@ +import { parsePromptOutputContent } from '#tui/environment/validators/output'; +import { parseWritableOutputStream } from '#tui/environment/validators/stream-output'; +import type { PromptOutput } from '#tui/types'; + +export const outputFromStream = (stream: NodeJS.WritableStream): PromptOutput => { + const writable = parseWritableOutputStream(stream); + const write = writable.write.bind(writable); + + return { + write(content: string): void { + write(parsePromptOutputContent(content)); + }, + }; +}; diff --git a/packages/tui/src/environment/validators/output.ts b/packages/tui/src/environment/validators/output.ts new file mode 100644 index 00000000..ca01c055 --- /dev/null +++ b/packages/tui/src/environment/validators/output.ts @@ -0,0 +1,13 @@ +import { z } from 'zod'; + +const promptOutputContentSchema = z.string(); + +export const parsePromptOutputContent = (content: unknown): string => { + const parsed = promptOutputContentSchema.safeParse(content); + + if (!parsed.success) { + throw new Error('Prompt output content must be a string.'); + } + + return parsed.data; +}; diff --git a/packages/tui/src/environment/validators/scripted-input.ts b/packages/tui/src/environment/validators/scripted-input.ts new file mode 100644 index 00000000..46fc6227 --- /dev/null +++ b/packages/tui/src/environment/validators/scripted-input.ts @@ -0,0 +1,13 @@ +import { z } from 'zod'; + +const scriptedInputLinesSchema = z.array(z.string()); + +export const parseScriptedInputLines = (lines: unknown): string[] => { + const parsed = scriptedInputLinesSchema.safeParse(lines); + + if (!parsed.success) { + throw new Error('Scripted input must be an array of strings.'); + } + + return parsed.data; +}; diff --git a/packages/tui/src/environment/validators/stream-output.ts b/packages/tui/src/environment/validators/stream-output.ts new file mode 100644 index 00000000..34032db9 --- /dev/null +++ b/packages/tui/src/environment/validators/stream-output.ts @@ -0,0 +1,18 @@ +import { z } from 'zod'; +import { functionSchema } from '#tui/validators/function'; + +const writableStreamSchema = z + .object({ + write: functionSchema(), + }) + .passthrough() as unknown as z.ZodType; + +export const parseWritableOutputStream = (stream: unknown): NodeJS.WritableStream => { + const parsed = writableStreamSchema.safeParse(stream); + + if (!parsed.success) { + throw new Error('Prompt output streams must include a write function.'); + } + + return stream as NodeJS.WritableStream; +}; diff --git a/packages/tui/src/form.ts b/packages/tui/src/form.ts new file mode 100644 index 00000000..98646ace --- /dev/null +++ b/packages/tui/src/form.ts @@ -0,0 +1,15 @@ +export { FormBuilder } from '#tui/form/builder/index'; +export type { FormResponses, FormStep, FormStepCondition } from '#tui/form/types'; + +import { FormBuilder } from '#tui/form/builder/index'; +import type { MaybePromise } from '#tui/types'; + +export function form(): FormBuilder; + +export function form(builder: (form: FormBuilder) => MaybePromise): Promise; + +export function form(builder?: (form: FormBuilder) => MaybePromise): FormBuilder | Promise { + const instance = new FormBuilder(); + + return builder ? Promise.resolve(builder(instance)) : instance; +} diff --git a/packages/tui/src/form/builder/conditions.ts b/packages/tui/src/form/builder/conditions.ts new file mode 100644 index 00000000..4d2b3caa --- /dev/null +++ b/packages/tui/src/form/builder/conditions.ts @@ -0,0 +1,14 @@ +import { resolveFormStepCondition } from '#tui/form/builder/validators/conditions'; +import type { FormResponses, FormStep } from '#tui/form/types'; + +export const shouldRunStep = async (step: FormStep, responses: FormResponses): Promise => { + return resolveFormStepCondition(step.condition, responses); +}; + +export const shouldIgnoreStepWhenReverting = async (step: FormStep, responses: FormResponses): Promise => { + if (!(await shouldRunStep(step, responses))) { + return true; + } + + return step.ignoreWhenReverting; +}; diff --git a/packages/tui/src/form/builder/index.ts b/packages/tui/src/form/builder/index.ts new file mode 100644 index 00000000..5b4f73d5 --- /dev/null +++ b/packages/tui/src/form/builder/index.ts @@ -0,0 +1,50 @@ +import { createFormResponses } from '#tui/form/builder/responses'; +import { sideEffectStep } from '#tui/form/builder/step'; +import { submitFormSteps } from '#tui/form/builder/submit'; +import { applyFormBuilderMethods, FormBuilderMethodSurface } from '#tui/form/builder/methods'; +import type { FormResponses, FormStep, FormStepCondition } from '#tui/form/types'; +import type { MaybePromise } from '#tui/types'; + +export class FormBuilder extends FormBuilderMethodSurface { + readonly #steps: FormStep[] = []; + readonly #responses: FormResponses = createFormResponses(); + + constructor() { + super(); + applyFormBuilderMethods(this); + } + + add(step: (responses: FormResponses, previous: unknown, name?: string) => MaybePromise, name?: string, ignoreWhenReverting = false): this { + this.#steps.push({ + condition: true, + ignoreWhenReverting, + name, + run: step, + }); + + return this; + } + + addIf(condition: FormStepCondition, step: (responses: FormResponses, previous: unknown, name?: string) => MaybePromise, name?: string, ignoreWhenReverting = false): this { + this.#steps.push({ + condition, + ignoreWhenReverting, + name, + run: step, + }); + + return this; + } + + addSideEffect(step: () => MaybePromise, name?: string): this { + return this.add(sideEffectStep(step), name, true); + } + + addSideEffectIf(condition: FormStepCondition, step: () => MaybePromise, name?: string): this { + return this.addIf(condition, sideEffectStep(step), name, true); + } + + async submit(): Promise { + return submitFormSteps(this.#steps, this.#responses); + } +} diff --git a/packages/tui/src/form/builder/methods.ts b/packages/tui/src/form/builder/methods.ts new file mode 100644 index 00000000..e5575e7a --- /dev/null +++ b/packages/tui/src/form/builder/methods.ts @@ -0,0 +1,52 @@ +import { outputBuilderMethods } from '#tui/form/builder/output'; +import { promptBuilderMethods } from '#tui/form/builder/prompts'; +import { statusBuilderMethods } from '#tui/form/builder/status'; +import type { FormBuilder } from '#tui/form/builder/index'; +import type { OutputBuilderMethods } from '#tui/form/builder/output'; +import type { PromptBuilderMethods } from '#tui/form/builder/prompts'; +import type { StatusBuilderMethods } from '#tui/form/builder/status'; + +export type FormBuilderMethods = OutputBuilderMethods & PromptBuilderMethods & StatusBuilderMethods; + +export abstract class FormBuilderMethodSurface { + declare readonly alert: OutputBuilderMethods['alert']; + declare readonly autocomplete: PromptBuilderMethods['autocomplete']; + declare readonly confirm: PromptBuilderMethods['confirm']; + declare readonly clear: OutputBuilderMethods['clear']; + declare readonly dataTable: OutputBuilderMethods['dataTable']; + declare readonly datatable: OutputBuilderMethods['datatable']; + declare readonly error: OutputBuilderMethods['error']; + declare readonly grid: OutputBuilderMethods['grid']; + declare readonly info: OutputBuilderMethods['info']; + declare readonly intro: OutputBuilderMethods['intro']; + declare readonly multisearch: PromptBuilderMethods['multisearch']; + declare readonly multiselect: PromptBuilderMethods['multiselect']; + declare readonly note: OutputBuilderMethods['note']; + declare readonly notify: OutputBuilderMethods['notify']; + declare readonly number: PromptBuilderMethods['number']; + declare readonly outro: OutputBuilderMethods['outro']; + declare readonly password: PromptBuilderMethods['password']; + declare readonly pause: PromptBuilderMethods['pause']; + declare readonly progress: StatusBuilderMethods['progress']; + declare readonly search: PromptBuilderMethods['search']; + declare readonly select: PromptBuilderMethods['select']; + declare readonly spin: StatusBuilderMethods['spin']; + declare readonly stream: StatusBuilderMethods['stream']; + declare readonly suggest: PromptBuilderMethods['suggest']; + declare readonly table: OutputBuilderMethods['table']; + declare readonly task: StatusBuilderMethods['task']; + declare readonly text: PromptBuilderMethods['text']; + declare readonly textarea: PromptBuilderMethods['textarea']; + declare readonly title: OutputBuilderMethods['title']; + declare readonly warning: OutputBuilderMethods['warning']; +} + +const formBuilderMethods: FormBuilderMethods & ThisType = { + ...outputBuilderMethods, + ...promptBuilderMethods, + ...statusBuilderMethods, +}; + +export const applyFormBuilderMethods = (builder: FormBuilderMethodSurface): void => { + Object.assign(builder, formBuilderMethods); +}; diff --git a/packages/tui/src/form/builder/output.ts b/packages/tui/src/form/builder/output.ts new file mode 100644 index 00000000..2506901b --- /dev/null +++ b/packages/tui/src/form/builder/output.ts @@ -0,0 +1,38 @@ +import type { FormBuilder } from '#tui/form/builder/index'; +import type { OutputBuilderMethods } from '#tui/form/builder/output/contracts'; + +import { + alertFormStep, + clearFormStep, + dataTableFormStep, + datatableFormStep, + errorFormStep, + gridFormStep, + infoFormStep, + introFormStep, + noteFormStep, + notifyFormStep, + outroFormStep, + tableFormStep, + titleFormStep, + warningFormStep, +} from '#tui/form/builder/output/index'; + +export type { OutputBuilderMethods } from '#tui/form/builder/output/contracts'; + +export const outputBuilderMethods: OutputBuilderMethods & ThisType = { + alert: alertFormStep, + clear: clearFormStep, + dataTable: dataTableFormStep, + datatable: datatableFormStep, + error: errorFormStep, + grid: gridFormStep, + info: infoFormStep, + intro: introFormStep, + note: noteFormStep, + notify: notifyFormStep, + outro: outroFormStep, + table: tableFormStep, + title: titleFormStep, + warning: warningFormStep, +}; diff --git a/packages/tui/src/form/builder/output/contracts.ts b/packages/tui/src/form/builder/output/contracts.ts new file mode 100644 index 00000000..7a0161c6 --- /dev/null +++ b/packages/tui/src/form/builder/output/contracts.ts @@ -0,0 +1,36 @@ +import type { FormBuilder } from '#tui/form/builder/index'; +import type { DataTablePromptOptions, DataTableRow, TableOptions } from '#tui/types'; + +export type OutputBuilderMethods = { + alert(this: FormBuilder, message: string, name?: string): FormBuilder; + clear(this: FormBuilder, name?: string): FormBuilder; + dataTable(this: FormBuilder, options: TableOptions, name?: string): FormBuilder; + dataTable(this: FormBuilder, headers: string[], name: string): FormBuilder; + dataTable(this: FormBuilder, headersOrOptions?: TableOptions | string[], rows?: TableOptions['rows'] | null, name?: string): FormBuilder; + datatable(this: FormBuilder, options: DataTablePromptOptions, name?: string): FormBuilder; + datatable( + this: FormBuilder, + headers?: string[], + rows?: Array> | null, + scroll?: number, + label?: string, + hint?: string, + required?: DataTablePromptOptions['required'], + validate?: DataTablePromptOptions['validate'], + transform?: DataTablePromptOptions['transform'], + filter?: DataTablePromptOptions['filter'], + name?: string, + ): FormBuilder; + error(this: FormBuilder, message: string, name?: string): FormBuilder; + grid(this: FormBuilder, items?: Array, maxWidth?: number, name?: string): FormBuilder; + info(this: FormBuilder, message: string, name?: string): FormBuilder; + intro(this: FormBuilder, message: string, name?: string): FormBuilder; + note(this: FormBuilder, message: string, type?: string | null, name?: string): FormBuilder; + notify(this: FormBuilder, title: string, body?: string, subtitle?: string, sound?: string, icon?: string, name?: string): FormBuilder; + outro(this: FormBuilder, message: string, name?: string): FormBuilder; + table(this: FormBuilder, options: TableOptions, name?: string): FormBuilder; + table(this: FormBuilder, headers: string[], name: string): FormBuilder; + table(this: FormBuilder, headersOrOptions?: TableOptions | string[], rows?: TableOptions['rows'] | null, name?: string): FormBuilder; + title(this: FormBuilder, value: string, name?: string): FormBuilder; + warning(this: FormBuilder, message: string, name?: string): FormBuilder; +}; diff --git a/packages/tui/src/form/builder/output/data-table.ts b/packages/tui/src/form/builder/output/data-table.ts new file mode 100644 index 00000000..4f2b6f50 --- /dev/null +++ b/packages/tui/src/form/builder/output/data-table.ts @@ -0,0 +1,46 @@ +import { datatable } from '#tui/output'; +import { previousValue } from '#tui/form/builder/previous'; +import { resolveDataTableFormArguments } from '#tui/form/builder/validators/output'; +import type { FormBuilder } from '#tui/form/builder/index'; +import type { DataTablePromptOptions, DataTableRow } from '#tui/types'; + +export function datatableFormStep(this: FormBuilder, options: DataTablePromptOptions, name?: string): FormBuilder; + +export function datatableFormStep( + this: FormBuilder, + headers?: string[], + rows?: Array> | null, + scroll?: number, + label?: string, + hint?: string, + required?: DataTablePromptOptions['required'], + validate?: DataTablePromptOptions['validate'], + transform?: DataTablePromptOptions['transform'], + filter?: DataTablePromptOptions['filter'], + name?: string, +): FormBuilder; + +export function datatableFormStep( + this: FormBuilder, + optionsOrHeaders: DataTablePromptOptions | string[] = [], + rowsOrName: Array> | null | string = null, + scrollOrName: number | string = 10, + label = '', + hint = '', + required: DataTablePromptOptions['required'] = false, + validate: DataTablePromptOptions['validate'] = undefined, + transform: DataTablePromptOptions['transform'] = undefined, + filter: DataTablePromptOptions['filter'] = undefined, + name?: string, +): FormBuilder { + const resolved = resolveDataTableFormArguments(optionsOrHeaders, rowsOrName, scrollOrName, label, hint, required, validate, transform, filter, name); + + return this.add( + (_, previous) => + datatable({ + ...resolved.options, + default: previousValue(previous, resolved.options.default), + }), + resolved.name, + ); +} diff --git a/packages/tui/src/form/builder/output/display.ts b/packages/tui/src/form/builder/output/display.ts new file mode 100644 index 00000000..382fa90c --- /dev/null +++ b/packages/tui/src/form/builder/output/display.ts @@ -0,0 +1,34 @@ +import { alert, error, grid, info, intro, note, outro, warning } from '#tui/output'; +import type { FormBuilder } from '#tui/form/builder/index'; + +export const alertFormStep = function alertFormStep(this: FormBuilder, message: string, name?: string): FormBuilder { + return this.addSideEffect(() => alert(message), name); +}; + +export const errorFormStep = function errorFormStep(this: FormBuilder, message: string, name?: string): FormBuilder { + return this.addSideEffect(() => error(message), name); +}; + +export const gridFormStep = function gridFormStep(this: FormBuilder, items: Array = [], maxWidth?: number, name?: string): FormBuilder { + return this.addSideEffect(() => grid(items, maxWidth), name); +}; + +export const infoFormStep = function infoFormStep(this: FormBuilder, message: string, name?: string): FormBuilder { + return this.addSideEffect(() => info(message), name); +}; + +export const introFormStep = function introFormStep(this: FormBuilder, message: string, name?: string): FormBuilder { + return this.addSideEffect(() => intro(message), name); +}; + +export const noteFormStep = function noteFormStep(this: FormBuilder, message: string, type: string | null = null, name?: string): FormBuilder { + return this.addSideEffect(() => note(message, type), name); +}; + +export const outroFormStep = function outroFormStep(this: FormBuilder, message: string, name?: string): FormBuilder { + return this.addSideEffect(() => outro(message), name); +}; + +export const warningFormStep = function warningFormStep(this: FormBuilder, message: string, name?: string): FormBuilder { + return this.addSideEffect(() => warning(message), name); +}; diff --git a/packages/tui/src/form/builder/output/index.ts b/packages/tui/src/form/builder/output/index.ts new file mode 100644 index 00000000..18f28855 --- /dev/null +++ b/packages/tui/src/form/builder/output/index.ts @@ -0,0 +1,5 @@ +export { datatableFormStep } from '#tui/form/builder/output/data-table'; +export { alertFormStep, errorFormStep, gridFormStep, infoFormStep, introFormStep, noteFormStep, outroFormStep, warningFormStep } from '#tui/form/builder/output/display'; +export { notifyFormStep } from '#tui/form/builder/output/notification'; +export { clearFormStep, titleFormStep } from '#tui/form/builder/output/terminal'; +export { dataTableFormStep, tableFormStep } from '#tui/form/builder/output/table'; diff --git a/packages/tui/src/form/builder/output/notification.ts b/packages/tui/src/form/builder/output/notification.ts new file mode 100644 index 00000000..2b610874 --- /dev/null +++ b/packages/tui/src/form/builder/output/notification.ts @@ -0,0 +1,6 @@ +import { notify } from '#tui/output'; +import type { FormBuilder } from '#tui/form/builder/index'; + +export const notifyFormStep = function notifyFormStep(this: FormBuilder, title: string, body = '', subtitle = '', sound = '', icon = '', name?: string): FormBuilder { + return this.addSideEffect(() => notify(title, body, subtitle, sound, icon), name); +}; diff --git a/packages/tui/src/form/builder/output/table.ts b/packages/tui/src/form/builder/output/table.ts new file mode 100644 index 00000000..0d9b10df --- /dev/null +++ b/packages/tui/src/form/builder/output/table.ts @@ -0,0 +1,36 @@ +import { dataTable, table } from '#tui/output'; +import { resolveTableFormArguments } from '#tui/form/builder/validators/output'; +import type { FormBuilder } from '#tui/form/builder/index'; +import type { TableOptions } from '#tui/types'; + +export function dataTableFormStep(this: FormBuilder, options: TableOptions, name?: string): FormBuilder; + +export function dataTableFormStep(this: FormBuilder, headers: string[], name: string): FormBuilder; + +export function dataTableFormStep(this: FormBuilder, headersOrOptions?: TableOptions | string[], rows?: TableOptions['rows'] | null, name?: string): FormBuilder; + +export function dataTableFormStep(this: FormBuilder, headersOrOptions: TableOptions | string[] = [], rowsOrName: TableOptions['rows'] | string | null = null, name?: string): FormBuilder { + const resolved = resolveTableFormArguments(headersOrOptions, rowsOrName, name); + + if (resolved.kind === 'options') { + return this.addSideEffect(() => dataTable(resolved.options), resolved.name); + } + + return this.addSideEffect(() => dataTable(resolved.headers, resolved.rows), resolved.name); +} + +export function tableFormStep(this: FormBuilder, options: TableOptions, name?: string): FormBuilder; + +export function tableFormStep(this: FormBuilder, headers: string[], name: string): FormBuilder; + +export function tableFormStep(this: FormBuilder, headersOrOptions?: TableOptions | string[], rows?: TableOptions['rows'] | null, name?: string): FormBuilder; + +export function tableFormStep(this: FormBuilder, headersOrOptions: TableOptions | string[] = [], rowsOrName: TableOptions['rows'] | string | null = null, name?: string): FormBuilder { + const resolved = resolveTableFormArguments(headersOrOptions, rowsOrName, name); + + if (resolved.kind === 'options') { + return this.addSideEffect(() => table(resolved.options), resolved.name); + } + + return this.addSideEffect(() => table(resolved.headers, resolved.rows), resolved.name); +} diff --git a/packages/tui/src/form/builder/output/terminal.ts b/packages/tui/src/form/builder/output/terminal.ts new file mode 100644 index 00000000..7ae6d250 --- /dev/null +++ b/packages/tui/src/form/builder/output/terminal.ts @@ -0,0 +1,10 @@ +import { clear, title } from '#tui/output'; +import type { FormBuilder } from '#tui/form/builder/index'; + +export const clearFormStep = function clearFormStep(this: FormBuilder, name?: string): FormBuilder { + return this.addSideEffect(() => clear(), name); +}; + +export const titleFormStep = function titleFormStep(this: FormBuilder, value: string, name?: string): FormBuilder { + return this.addSideEffect(() => title(value), name); +}; diff --git a/packages/tui/src/form/builder/previous.ts b/packages/tui/src/form/builder/previous.ts new file mode 100644 index 00000000..9df58152 --- /dev/null +++ b/packages/tui/src/form/builder/previous.ts @@ -0,0 +1,21 @@ +import { parsePreviousArray, parsePreviousBoolean, parsePreviousNumber, parsePreviousString, parsePreviousValue } from '#tui/form/builder/validators/previous'; + +export const previousString = (previous: unknown, defaultValue: string): string => { + return parsePreviousString(previous, defaultValue); +}; + +export const previousNumber = (previous: unknown, defaultValue: number | string): number | string => { + return parsePreviousNumber(previous, defaultValue); +}; + +export const previousArray = (previous: unknown, defaultValue: T[]): T[] => { + return parsePreviousArray(previous, defaultValue); +}; + +export const previousBoolean = (previous: unknown, defaultValue: boolean): boolean => { + return parsePreviousBoolean(previous, defaultValue); +}; + +export const previousValue = (previous: unknown, defaultValue: T): T => { + return parsePreviousValue(previous, defaultValue); +}; diff --git a/packages/tui/src/form/builder/prompts.ts b/packages/tui/src/form/builder/prompts.ts new file mode 100644 index 00000000..31c017af --- /dev/null +++ b/packages/tui/src/form/builder/prompts.ts @@ -0,0 +1,11 @@ +import { basicPromptBuilderMethods } from '#tui/form/builder/prompts/basic'; +import { choicePromptBuilderMethods } from '#tui/form/builder/prompts/choices'; +import type { FormBuilder } from '#tui/form/builder/index'; +import type { PromptBuilderMethods } from '#tui/form/builder/prompts/types'; + +export type { PromptBuilderMethods }; + +export const promptBuilderMethods: PromptBuilderMethods & ThisType = { + ...basicPromptBuilderMethods, + ...choicePromptBuilderMethods, +}; diff --git a/packages/tui/src/form/builder/prompts/basic.ts b/packages/tui/src/form/builder/prompts/basic.ts new file mode 100644 index 00000000..1713b987 --- /dev/null +++ b/packages/tui/src/form/builder/prompts/basic.ts @@ -0,0 +1,10 @@ +import { numberFormStep, passwordFormStep, textFormStep, textareaFormStep } from '#tui/form/builder/prompts/basic/index'; +import type { FormBuilder } from '#tui/form/builder/index'; +import type { BasicPromptBuilderMethods } from '#tui/form/builder/prompts/types'; + +export const basicPromptBuilderMethods: BasicPromptBuilderMethods & ThisType = { + number: numberFormStep, + password: passwordFormStep, + text: textFormStep, + textarea: textareaFormStep, +}; diff --git a/packages/tui/src/form/builder/prompts/basic/index.ts b/packages/tui/src/form/builder/prompts/basic/index.ts new file mode 100644 index 00000000..3d2ef7bc --- /dev/null +++ b/packages/tui/src/form/builder/prompts/basic/index.ts @@ -0,0 +1,4 @@ +export { numberFormStep } from '#tui/form/builder/prompts/basic/number'; +export { passwordFormStep } from '#tui/form/builder/prompts/basic/password'; +export { textFormStep } from '#tui/form/builder/prompts/basic/text'; +export { textareaFormStep } from '#tui/form/builder/prompts/basic/textarea'; diff --git a/packages/tui/src/form/builder/prompts/basic/number.ts b/packages/tui/src/form/builder/prompts/basic/number.ts new file mode 100644 index 00000000..9b4c6f6b --- /dev/null +++ b/packages/tui/src/form/builder/prompts/basic/number.ts @@ -0,0 +1,62 @@ +import { labelNumberOptions } from '#tui/form/builder/prompts/basic/number/options'; +import { runLabelNumberFormStep, runObjectNumberFormStep } from '#tui/form/builder/prompts/basic/number/step'; +import { resolveNumberFormArguments } from '#tui/form/builder/prompts/validators/basic'; +import type { FormBuilder } from '#tui/form/builder/index'; +import type { NumberPromptOptions } from '#tui/types'; + +export function numberFormStep(this: FormBuilder, options: NumberPromptOptions, name?: string): FormBuilder; + +export function numberFormStep( + this: FormBuilder, + label: string, + placeholder?: string, + defaultValue?: number | string, + required?: boolean | string, + validate?: NumberPromptOptions['validate'], + hint?: string, + min?: number, + max?: number, + step?: number, + name?: string, + transform?: NumberPromptOptions['transform'], +): FormBuilder; + +export function numberFormStep( + this: FormBuilder, + optionsOrLabel: NumberPromptOptions | string, + placeholder = '', + defaultValue: number | string = '', + required: NumberPromptOptions['required'] = false, + validate: NumberPromptOptions['validate'] = undefined, + hint = '', + min?: number, + max?: number, + step?: number, + name?: string, + transform: NumberPromptOptions['transform'] = undefined, +): FormBuilder { + const resolved = resolveNumberFormArguments(optionsOrLabel, placeholder, defaultValue, required, validate, hint, min, max, step, name, transform, arguments.length); + + if (resolved.kind === 'options') { + return this.add((_, previous) => runObjectNumberFormStep(resolved.options, previous), resolved.name); + } + + return this.add((_, previous) => { + const promptOptions = labelNumberOptions({ + defaultValue: resolved.defaultValue, + hasLabelDefault: resolved.hasLabelDefault, + hint: resolved.hint, + label: resolved.label, + max: resolved.max, + min: resolved.min, + placeholder: resolved.placeholder, + previous, + required: resolved.required, + step: resolved.step, + transform: resolved.transform, + validate: resolved.validate, + }); + + return runLabelNumberFormStep(promptOptions); + }, resolved.name); +} diff --git a/packages/tui/src/form/builder/prompts/basic/number/defaults.ts b/packages/tui/src/form/builder/prompts/basic/number/defaults.ts new file mode 100644 index 00000000..c9c9f96b --- /dev/null +++ b/packages/tui/src/form/builder/prompts/basic/number/defaults.ts @@ -0,0 +1,20 @@ +import { previousNumber } from '#tui/form/builder/previous'; +import { hasPromptDefault } from '#tui/validators/default'; +import { hasPreviousResponse } from '#tui/form/builder/validators/previous'; +import type { NumberPromptOptions } from '#tui/types'; + +export const numberOptionsWithPreviousDefault = (options: NumberPromptOptions, previous: unknown): NumberPromptOptions => { + if (hasPreviousResponse(previous)) { + return { ...options, default: previousNumber(previous, options.default ?? '') }; + } + + if (hasPromptDefault(options)) { + return { ...options, default: previousNumber(previous, options.default ?? '') }; + } + + return options; +}; + +export const previousNumberDefault = (previous: unknown, fallback: number | string): number | string => { + return previousNumber(previous, fallback); +}; diff --git a/packages/tui/src/form/builder/prompts/basic/number/options.ts b/packages/tui/src/form/builder/prompts/basic/number/options.ts new file mode 100644 index 00000000..98b798d9 --- /dev/null +++ b/packages/tui/src/form/builder/prompts/basic/number/options.ts @@ -0,0 +1,54 @@ +import { previousNumberDefault } from '#tui/form/builder/prompts/basic/number/defaults'; +import { hasPreviousResponse } from '#tui/form/builder/validators/previous'; +import type { NumberPromptOptions } from '#tui/types'; + +type LabelNumberOptionsInput = { + defaultValue: number | string; + hasLabelDefault: boolean; + hint: string; + label: string; + max?: number; + min?: number; + placeholder: string; + previous: unknown; + required: NumberPromptOptions['required']; + step?: number; + transform: NumberPromptOptions['transform']; + validate: NumberPromptOptions['validate']; +}; + +export const labelNumberOptions = ({ + defaultValue, + hasLabelDefault, + hint, + label, + max, + min, + placeholder, + previous, + required, + step, + transform, + validate, +}: LabelNumberOptionsInput): NumberPromptOptions => { + const options: NumberPromptOptions = { + message: label, + label, + placeholder, + required, + validate, + hint, + min, + max, + step, + transform, + }; + + if (hasPreviousResponse(previous)) { + options.default = previousNumberDefault(previous, hasLabelDefault ? defaultValue : ''); + } else if (hasLabelDefault) { + options.default = previousNumberDefault(previous, defaultValue); + } + + return options; +}; diff --git a/packages/tui/src/form/builder/prompts/basic/number/step.ts b/packages/tui/src/form/builder/prompts/basic/number/step.ts new file mode 100644 index 00000000..fa3b8b8b --- /dev/null +++ b/packages/tui/src/form/builder/prompts/basic/number/step.ts @@ -0,0 +1,11 @@ +import { number } from '#tui/prompts/basic'; +import { numberOptionsWithPreviousDefault } from '#tui/form/builder/prompts/basic/number/defaults'; +import type { NumberPromptOptions } from '#tui/types'; + +export const runObjectNumberFormStep = (options: NumberPromptOptions, previous: unknown): Promise => { + return number(numberOptionsWithPreviousDefault(options, previous)); +}; + +export const runLabelNumberFormStep = (options: NumberPromptOptions): Promise => { + return number(options); +}; diff --git a/packages/tui/src/form/builder/prompts/basic/password.ts b/packages/tui/src/form/builder/prompts/basic/password.ts new file mode 100644 index 00000000..8b4d0ba6 --- /dev/null +++ b/packages/tui/src/form/builder/prompts/basic/password.ts @@ -0,0 +1,33 @@ +import { password } from '#tui/prompts/basic'; +import { previousString } from '#tui/form/builder/previous'; +import { resolvePasswordFormArguments } from '#tui/form/builder/prompts/validators/basic'; +import type { FormBuilder } from '#tui/form/builder/index'; +import type { TextPromptOptions } from '#tui/types'; + +export function passwordFormStep(this: FormBuilder, options: TextPromptOptions, name?: string): FormBuilder; + +export function passwordFormStep( + this: FormBuilder, + label: string, + placeholder?: string, + required?: boolean | string, + validate?: TextPromptOptions['validate'], + hint?: string, + name?: string, + transform?: TextPromptOptions['transform'], +): FormBuilder; + +export function passwordFormStep( + this: FormBuilder, + optionsOrLabel: TextPromptOptions | string, + placeholder = '', + required: TextPromptOptions['required'] = false, + validate: TextPromptOptions['validate'] = undefined, + hint = '', + name?: string, + transform: TextPromptOptions['transform'] = undefined, +): FormBuilder { + const resolved = resolvePasswordFormArguments(optionsOrLabel, placeholder, required, validate, hint, name, transform); + + return this.add((_, previous) => password({ ...resolved.options, default: previousString(previous, resolved.options.default ?? '') }), resolved.name); +} diff --git a/packages/tui/src/form/builder/prompts/basic/text.ts b/packages/tui/src/form/builder/prompts/basic/text.ts new file mode 100644 index 00000000..d5eb4730 --- /dev/null +++ b/packages/tui/src/form/builder/prompts/basic/text.ts @@ -0,0 +1,35 @@ +import { text } from '#tui/prompts/basic'; +import { previousString } from '#tui/form/builder/previous'; +import { resolveTextFormArguments } from '#tui/form/builder/prompts/validators/basic'; +import type { FormBuilder } from '#tui/form/builder/index'; +import type { TextPromptOptions } from '#tui/types'; + +export function textFormStep(this: FormBuilder, options: TextPromptOptions, name?: string): FormBuilder; + +export function textFormStep( + this: FormBuilder, + label: string, + placeholder?: string, + defaultValue?: string, + required?: boolean | string, + validate?: TextPromptOptions['validate'], + hint?: string, + name?: string, + transform?: TextPromptOptions['transform'], +): FormBuilder; + +export function textFormStep( + this: FormBuilder, + optionsOrLabel: TextPromptOptions | string, + placeholder = '', + defaultValue = '', + required: TextPromptOptions['required'] = false, + validate: TextPromptOptions['validate'] = undefined, + hint = '', + name?: string, + transform: TextPromptOptions['transform'] = undefined, +): FormBuilder { + const resolved = resolveTextFormArguments(optionsOrLabel, placeholder, defaultValue, required, validate, hint, name, transform); + + return this.add((_, previous) => text({ ...resolved.options, default: previousString(previous, resolved.options.default ?? '') }), resolved.name); +} diff --git a/packages/tui/src/form/builder/prompts/basic/textarea.ts b/packages/tui/src/form/builder/prompts/basic/textarea.ts new file mode 100644 index 00000000..dc19f8d3 --- /dev/null +++ b/packages/tui/src/form/builder/prompts/basic/textarea.ts @@ -0,0 +1,37 @@ +import { textarea } from '#tui/prompts/basic'; +import { previousString } from '#tui/form/builder/previous'; +import { resolveTextareaFormArguments } from '#tui/form/builder/prompts/validators/basic'; +import type { FormBuilder } from '#tui/form/builder/index'; +import type { TextareaPromptOptions } from '#tui/types'; + +export function textareaFormStep(this: FormBuilder, options: TextareaPromptOptions, name?: string): FormBuilder; + +export function textareaFormStep( + this: FormBuilder, + label: string, + placeholder?: string, + defaultValue?: string, + required?: boolean | string, + validate?: TextareaPromptOptions['validate'], + hint?: string, + rows?: number, + name?: string, + transform?: TextareaPromptOptions['transform'], +): FormBuilder; + +export function textareaFormStep( + this: FormBuilder, + optionsOrLabel: TextareaPromptOptions | string, + placeholder = '', + defaultValue = '', + required: TextareaPromptOptions['required'] = false, + validate: TextareaPromptOptions['validate'] = undefined, + hint = '', + rows = 5, + name?: string, + transform: TextareaPromptOptions['transform'] = undefined, +): FormBuilder { + const resolved = resolveTextareaFormArguments(optionsOrLabel, placeholder, defaultValue, required, validate, hint, rows, name, transform); + + return this.add((_, previous) => textarea({ ...resolved.options, default: previousString(previous, resolved.options.default ?? '') }), resolved.name); +} diff --git a/packages/tui/src/form/builder/prompts/choice/autocomplete.ts b/packages/tui/src/form/builder/prompts/choice/autocomplete.ts new file mode 100644 index 00000000..3c93b90b --- /dev/null +++ b/packages/tui/src/form/builder/prompts/choice/autocomplete.ts @@ -0,0 +1,40 @@ +import { autocomplete } from '#tui/prompts/choices'; +import { previousString } from '#tui/form/builder/previous'; +import { resolveAutocompleteFormArguments } from '#tui/form/builder/prompts/validators/suggest'; +import type { FormBuilder } from '#tui/form/builder/index'; +import type { SuggestOptions } from '#tui/prompts/choices'; +import type { TextPromptOptions } from '#tui/types'; + +export function autocompleteFormStep(this: FormBuilder, options: SuggestOptions, name?: string): FormBuilder; + +export function autocompleteFormStep( + this: FormBuilder, + label: string, + options: SuggestOptions['options'], + placeholder?: string, + defaultValue?: string, + required?: boolean | string, + validate?: TextPromptOptions['validate'], + hint?: string, + name?: string, + transform?: TextPromptOptions['transform'], + info?: SuggestOptions['info'], +): FormBuilder; + +export function autocompleteFormStep( + this: FormBuilder, + optionsOrLabel: SuggestOptions | string, + options?: SuggestOptions['options'] | string, + placeholder = '', + defaultValue = '', + required: boolean | string = false, + validate: TextPromptOptions['validate'] = undefined, + hint = '', + name?: string, + transform: TextPromptOptions['transform'] = undefined, + info: SuggestOptions['info'] = '', +): FormBuilder { + const resolved = resolveAutocompleteFormArguments(optionsOrLabel, options, placeholder, defaultValue, required, validate, hint, name, transform, info); + + return this.add((_, previous) => autocomplete({ ...resolved.options, default: previousString(previous, resolved.options.default ?? '') }), resolved.name); +} diff --git a/packages/tui/src/form/builder/prompts/choice/confirm.ts b/packages/tui/src/form/builder/prompts/choice/confirm.ts new file mode 100644 index 00000000..20ef2295 --- /dev/null +++ b/packages/tui/src/form/builder/prompts/choice/confirm.ts @@ -0,0 +1,37 @@ +import { confirm } from '#tui/prompts/choices'; +import { previousBoolean } from '#tui/form/builder/previous'; +import { resolveConfirmFormArguments } from '#tui/form/builder/prompts/validators/select'; +import type { FormBuilder } from '#tui/form/builder/index'; +import type { ConfirmPromptOptions } from '#tui/types'; + +export function confirmFormStep(this: FormBuilder, options: ConfirmPromptOptions, name?: string): FormBuilder; + +export function confirmFormStep( + this: FormBuilder, + label: string, + defaultValue?: boolean, + yes?: string, + no?: string, + required?: boolean | string, + validate?: ConfirmPromptOptions['validate'], + hint?: string, + name?: string, + transform?: ConfirmPromptOptions['transform'], +): FormBuilder; + +export function confirmFormStep( + this: FormBuilder, + optionsOrLabel: ConfirmPromptOptions | string, + defaultValueOrName: boolean | string = true, + yes = 'Yes', + no = 'No', + required: boolean | string = false, + validate: ConfirmPromptOptions['validate'] = undefined, + hint = '', + name?: string, + transform: ConfirmPromptOptions['transform'] = undefined, +): FormBuilder { + const resolved = resolveConfirmFormArguments(optionsOrLabel, defaultValueOrName, yes, no, required, validate, hint, name, transform); + + return this.add((_, previous) => confirm({ ...resolved.options, default: previousBoolean(previous, resolved.options.default ?? true) }), resolved.name); +} diff --git a/packages/tui/src/form/builder/prompts/choice/index.ts b/packages/tui/src/form/builder/prompts/choice/index.ts new file mode 100644 index 00000000..c3394d13 --- /dev/null +++ b/packages/tui/src/form/builder/prompts/choice/index.ts @@ -0,0 +1,8 @@ +export { autocompleteFormStep } from '#tui/form/builder/prompts/choice/autocomplete'; +export { confirmFormStep } from '#tui/form/builder/prompts/choice/confirm'; +export { multisearchFormStep } from '#tui/form/builder/prompts/choice/multisearch'; +export { multiselectFormStep } from '#tui/form/builder/prompts/choice/multiselect'; +export { pauseFormStep } from '#tui/form/builder/prompts/choice/pause'; +export { searchFormStep } from '#tui/form/builder/prompts/choice/search'; +export { selectFormStep } from '#tui/form/builder/prompts/choice/select'; +export { suggestFormStep } from '#tui/form/builder/prompts/choice/suggest'; diff --git a/packages/tui/src/form/builder/prompts/choice/multisearch.ts b/packages/tui/src/form/builder/prompts/choice/multisearch.ts new file mode 100644 index 00000000..a9ade6e3 --- /dev/null +++ b/packages/tui/src/form/builder/prompts/choice/multisearch.ts @@ -0,0 +1,39 @@ +import { multisearch } from '#tui/prompts/choices'; +import { previousArray } from '#tui/form/builder/previous'; +import { resolveMultiSearchFormArguments } from '#tui/form/builder/prompts/validators/search'; +import type { FormBuilder } from '#tui/form/builder/index'; +import type { MultiSearchPromptOptions } from '#tui/types'; + +export function multisearchFormStep(this: FormBuilder, options: MultiSearchPromptOptions, name?: string): FormBuilder; + +export function multisearchFormStep( + this: FormBuilder, + label: string, + options: MultiSearchPromptOptions['options'], + placeholder?: string, + scroll?: number, + required?: MultiSearchPromptOptions['required'], + validate?: MultiSearchPromptOptions['validate'], + hint?: string, + name?: string, + transform?: MultiSearchPromptOptions['transform'], + info?: MultiSearchPromptOptions['info'], +): FormBuilder; + +export function multisearchFormStep( + this: FormBuilder, + optionsOrLabel: MultiSearchPromptOptions | string, + options?: MultiSearchPromptOptions['options'] | string, + placeholder = '', + scroll = 5, + required: MultiSearchPromptOptions['required'] = false, + validate: MultiSearchPromptOptions['validate'] = undefined, + hint = 'Use the space bar to select options.', + name?: string, + transform: MultiSearchPromptOptions['transform'] = undefined, + info: MultiSearchPromptOptions['info'] = '', +): FormBuilder { + const resolved = resolveMultiSearchFormArguments(optionsOrLabel, options, placeholder, scroll, required, validate, hint, name, transform, info); + + return this.add((_, previous) => multisearch({ ...resolved.options, default: previousArray(previous, resolved.options.default ?? []) }), resolved.name); +} diff --git a/packages/tui/src/form/builder/prompts/choice/multiselect.ts b/packages/tui/src/form/builder/prompts/choice/multiselect.ts new file mode 100644 index 00000000..372e43ae --- /dev/null +++ b/packages/tui/src/form/builder/prompts/choice/multiselect.ts @@ -0,0 +1,39 @@ +import { multiselect } from '#tui/prompts/choices'; +import { previousArray } from '#tui/form/builder/previous'; +import { resolveMultiSelectFormArguments } from '#tui/form/builder/prompts/validators/select'; +import type { FormBuilder } from '#tui/form/builder/index'; +import type { ChoiceOptions, MultiSelectPromptOptions } from '#tui/types'; + +export function multiselectFormStep(this: FormBuilder, options: MultiSelectPromptOptions, name?: string): FormBuilder; + +export function multiselectFormStep( + this: FormBuilder, + label: string, + options: ChoiceOptions, + defaultValue?: T[], + scroll?: number, + required?: boolean | string, + validate?: MultiSelectPromptOptions['validate'], + hint?: string, + name?: string, + transform?: MultiSelectPromptOptions['transform'], + info?: MultiSelectPromptOptions['info'], +): FormBuilder; + +export function multiselectFormStep( + this: FormBuilder, + optionsOrLabel: MultiSelectPromptOptions | string, + optionsOrName?: ChoiceOptions | string, + defaultValue: T[] = [], + scroll = 5, + required: boolean | string = false, + validate: MultiSelectPromptOptions['validate'] = undefined, + hint = 'Use the space bar to select options.', + name?: string, + transform: MultiSelectPromptOptions['transform'] = undefined, + info: MultiSelectPromptOptions['info'] = '', +): FormBuilder { + const resolved = resolveMultiSelectFormArguments(optionsOrLabel, optionsOrName, defaultValue, scroll, required, validate, hint, name, transform, info); + + return this.add((_, previous) => multiselect({ ...resolved.options, default: previousArray(previous, resolved.options.default ?? []) }), resolved.name); +} diff --git a/packages/tui/src/form/builder/prompts/choice/pause.ts b/packages/tui/src/form/builder/prompts/choice/pause.ts new file mode 100644 index 00000000..d56165b6 --- /dev/null +++ b/packages/tui/src/form/builder/prompts/choice/pause.ts @@ -0,0 +1,6 @@ +import { pause } from '#tui/prompts/choices'; +import type { FormBuilder } from '#tui/form/builder/index'; + +export const pauseFormStep = function pauseFormStep(this: FormBuilder, message = 'Press enter to continue...', name?: string): FormBuilder { + return this.add(() => pause(message), name); +}; diff --git a/packages/tui/src/form/builder/prompts/choice/search.ts b/packages/tui/src/form/builder/prompts/choice/search.ts new file mode 100644 index 00000000..c4087278 --- /dev/null +++ b/packages/tui/src/form/builder/prompts/choice/search.ts @@ -0,0 +1,39 @@ +import { search } from '#tui/prompts/choices'; +import { previousValue } from '#tui/form/builder/previous'; +import { resolveSearchFormArguments } from '#tui/form/builder/prompts/validators/search'; +import type { FormBuilder } from '#tui/form/builder/index'; +import type { SearchPromptOptions } from '#tui/types'; + +export function searchFormStep(this: FormBuilder, options: SearchPromptOptions, name?: string): FormBuilder; + +export function searchFormStep( + this: FormBuilder, + label: string, + options: SearchPromptOptions['options'], + placeholder?: string, + scroll?: number, + validate?: SearchPromptOptions['validate'], + hint?: string, + required?: SearchPromptOptions['required'], + name?: string, + transform?: SearchPromptOptions['transform'], + info?: SearchPromptOptions['info'], +): FormBuilder; + +export function searchFormStep( + this: FormBuilder, + optionsOrLabel: SearchPromptOptions | string, + options?: SearchPromptOptions['options'] | string, + placeholder = '', + scroll = 5, + validate: SearchPromptOptions['validate'] = undefined, + hint = '', + required: SearchPromptOptions['required'] = true, + name?: string, + transform: SearchPromptOptions['transform'] = undefined, + info: SearchPromptOptions['info'] = '', +): FormBuilder { + const resolved = resolveSearchFormArguments(optionsOrLabel, options, placeholder, scroll, validate, hint, required, name, transform, info); + + return this.add((_, previous) => search({ ...resolved.options, default: previousValue(previous, resolved.options.default) }), resolved.name); +} diff --git a/packages/tui/src/form/builder/prompts/choice/select.ts b/packages/tui/src/form/builder/prompts/choice/select.ts new file mode 100644 index 00000000..3e06ad10 --- /dev/null +++ b/packages/tui/src/form/builder/prompts/choice/select.ts @@ -0,0 +1,39 @@ +import { select } from '#tui/prompts/choices'; +import { previousValue } from '#tui/form/builder/previous'; +import { resolveSelectFormArguments } from '#tui/form/builder/prompts/validators/select'; +import type { FormBuilder } from '#tui/form/builder/index'; +import type { ChoiceOptions, SelectPromptOptions } from '#tui/types'; + +export function selectFormStep(this: FormBuilder, options: SelectPromptOptions, name?: string): FormBuilder; + +export function selectFormStep( + this: FormBuilder, + label: string, + options: ChoiceOptions, + defaultValue?: T, + scroll?: number, + validate?: SelectPromptOptions['validate'], + hint?: string, + required?: boolean | string, + name?: string, + transform?: SelectPromptOptions['transform'], + info?: SelectPromptOptions['info'], +): FormBuilder; + +export function selectFormStep( + this: FormBuilder, + optionsOrLabel: SelectPromptOptions | string, + optionsOrName?: ChoiceOptions | string, + defaultValue?: T, + scroll = 5, + validate: SelectPromptOptions['validate'] = undefined, + hint = '', + required: boolean | string = true, + name?: string, + transform: SelectPromptOptions['transform'] = undefined, + info: SelectPromptOptions['info'] = '', +): FormBuilder { + const resolved = resolveSelectFormArguments(optionsOrLabel, optionsOrName, defaultValue, scroll, validate, hint, required, name, transform, info); + + return this.add((_, previous) => select({ ...resolved.options, default: previousValue(previous, resolved.options.default) }), resolved.name); +} diff --git a/packages/tui/src/form/builder/prompts/choice/suggest.ts b/packages/tui/src/form/builder/prompts/choice/suggest.ts new file mode 100644 index 00000000..5ad47066 --- /dev/null +++ b/packages/tui/src/form/builder/prompts/choice/suggest.ts @@ -0,0 +1,42 @@ +import { suggest } from '#tui/prompts/choices'; +import { previousString } from '#tui/form/builder/previous'; +import { resolveSuggestFormArguments } from '#tui/form/builder/prompts/validators/suggest'; +import type { FormBuilder } from '#tui/form/builder/index'; +import type { SuggestOptions } from '#tui/prompts/choices'; +import type { TextPromptOptions } from '#tui/types'; + +export function suggestFormStep(this: FormBuilder, options: SuggestOptions, name?: string): FormBuilder; + +export function suggestFormStep( + this: FormBuilder, + label: string, + options: SuggestOptions['options'], + placeholder?: string, + defaultValue?: string, + scroll?: number, + required?: boolean | string, + validate?: TextPromptOptions['validate'], + hint?: string, + name?: string, + transform?: TextPromptOptions['transform'], + info?: SuggestOptions['info'], +): FormBuilder; + +export function suggestFormStep( + this: FormBuilder, + optionsOrLabel: SuggestOptions | string, + options?: SuggestOptions['options'] | string, + placeholder = '', + defaultValue = '', + scroll = 5, + required: boolean | string = false, + validate: TextPromptOptions['validate'] = undefined, + hint = '', + name?: string, + transform: TextPromptOptions['transform'] = undefined, + info: SuggestOptions['info'] = '', +): FormBuilder { + const resolved = resolveSuggestFormArguments(optionsOrLabel, options, placeholder, defaultValue, scroll, required, validate, hint, name, transform, info); + + return this.add((_, previous) => suggest({ ...resolved.options, default: previousString(previous, resolved.options.default ?? '') }), resolved.name); +} diff --git a/packages/tui/src/form/builder/prompts/choices.ts b/packages/tui/src/form/builder/prompts/choices.ts new file mode 100644 index 00000000..7cb9be43 --- /dev/null +++ b/packages/tui/src/form/builder/prompts/choices.ts @@ -0,0 +1,24 @@ +import type { FormBuilder } from '#tui/form/builder/index'; +import type { ChoicePromptBuilderMethods } from '#tui/form/builder/prompts/types'; + +import { + autocompleteFormStep, + confirmFormStep, + multisearchFormStep, + multiselectFormStep, + pauseFormStep, + searchFormStep, + selectFormStep, + suggestFormStep, +} from '#tui/form/builder/prompts/choice/index'; + +export const choicePromptBuilderMethods: ChoicePromptBuilderMethods & ThisType = { + autocomplete: autocompleteFormStep, + confirm: confirmFormStep, + multisearch: multisearchFormStep, + multiselect: multiselectFormStep, + pause: pauseFormStep, + search: searchFormStep, + select: selectFormStep, + suggest: suggestFormStep, +}; diff --git a/packages/tui/src/form/builder/prompts/contracts/basic.ts b/packages/tui/src/form/builder/prompts/contracts/basic.ts new file mode 100644 index 00000000..46f27a1f --- /dev/null +++ b/packages/tui/src/form/builder/prompts/contracts/basic.ts @@ -0,0 +1,57 @@ +import type { FormBuilder } from '#tui/form/builder/index'; +import type { NumberPromptOptions, TextareaPromptOptions, TextPromptOptions } from '#tui/types'; +import type { MaybePromise } from '#tui/types'; + +export type BasicPromptBuilderMethods = { + number(this: FormBuilder, options: NumberPromptOptions, name?: string): FormBuilder; + number( + this: FormBuilder, + label: string, + placeholder?: string, + defaultValue?: number | string, + required?: boolean | string, + validate?: (value: number | string) => MaybePromise, + hint?: string, + min?: number, + max?: number, + step?: number, + name?: string, + transform?: NumberPromptOptions['transform'], + ): FormBuilder; + password(this: FormBuilder, options: TextPromptOptions, name?: string): FormBuilder; + password( + this: FormBuilder, + label: string, + placeholder?: string, + required?: boolean | string, + validate?: TextPromptOptions['validate'], + hint?: string, + name?: string, + transform?: TextPromptOptions['transform'], + ): FormBuilder; + text(this: FormBuilder, options: TextPromptOptions, name?: string): FormBuilder; + text( + this: FormBuilder, + label: string, + placeholder?: string, + defaultValue?: string, + required?: boolean | string, + validate?: TextPromptOptions['validate'], + hint?: string, + name?: string, + transform?: TextPromptOptions['transform'], + ): FormBuilder; + textarea(this: FormBuilder, options: TextareaPromptOptions, name?: string): FormBuilder; + textarea( + this: FormBuilder, + label: string, + placeholder?: string, + defaultValue?: string, + required?: boolean | string, + validate?: TextPromptOptions['validate'], + hint?: string, + rows?: number, + name?: string, + transform?: TextPromptOptions['transform'], + ): FormBuilder; +}; diff --git a/packages/tui/src/form/builder/prompts/contracts/choices.ts b/packages/tui/src/form/builder/prompts/contracts/choices.ts new file mode 100644 index 00000000..3901ccc1 --- /dev/null +++ b/packages/tui/src/form/builder/prompts/contracts/choices.ts @@ -0,0 +1,17 @@ +import type { AutocompletePromptBuilderMethods } from '#tui/form/builder/prompts/contracts/choices/autocomplete'; +import type { ConfirmPromptBuilderMethods } from '#tui/form/builder/prompts/contracts/choices/confirm'; +import type { MultiSearchPromptBuilderMethods } from '#tui/form/builder/prompts/contracts/choices/multisearch'; +import type { MultiSelectPromptBuilderMethods } from '#tui/form/builder/prompts/contracts/choices/multiselect'; +import type { PausePromptBuilderMethods } from '#tui/form/builder/prompts/contracts/choices/pause'; +import type { SearchPromptBuilderMethods } from '#tui/form/builder/prompts/contracts/choices/search'; +import type { SelectPromptBuilderMethods } from '#tui/form/builder/prompts/contracts/choices/select'; +import type { SuggestPromptBuilderMethods } from '#tui/form/builder/prompts/contracts/choices/suggest'; + +export type ChoicePromptBuilderMethods = AutocompletePromptBuilderMethods & + ConfirmPromptBuilderMethods & + MultiSearchPromptBuilderMethods & + MultiSelectPromptBuilderMethods & + PausePromptBuilderMethods & + SearchPromptBuilderMethods & + SelectPromptBuilderMethods & + SuggestPromptBuilderMethods; diff --git a/packages/tui/src/form/builder/prompts/contracts/choices/autocomplete.ts b/packages/tui/src/form/builder/prompts/contracts/choices/autocomplete.ts new file mode 100644 index 00000000..ccb7c424 --- /dev/null +++ b/packages/tui/src/form/builder/prompts/contracts/choices/autocomplete.ts @@ -0,0 +1,20 @@ +import type { FormBuilder } from '#tui/form/builder/index'; +import type { SuggestOptions } from '#tui/prompts/choices'; +import type { TextPromptOptions } from '#tui/types'; + +export type AutocompletePromptBuilderMethods = { + autocomplete(this: FormBuilder, options: SuggestOptions, name?: string): FormBuilder; + autocomplete( + this: FormBuilder, + label: string, + options: SuggestOptions['options'], + placeholder?: string, + defaultValue?: string, + required?: boolean | string, + validate?: TextPromptOptions['validate'], + hint?: string, + name?: string, + transform?: TextPromptOptions['transform'], + info?: SuggestOptions['info'], + ): FormBuilder; +}; diff --git a/packages/tui/src/form/builder/prompts/contracts/choices/confirm.ts b/packages/tui/src/form/builder/prompts/contracts/choices/confirm.ts new file mode 100644 index 00000000..52c2df09 --- /dev/null +++ b/packages/tui/src/form/builder/prompts/contracts/choices/confirm.ts @@ -0,0 +1,18 @@ +import type { FormBuilder } from '#tui/form/builder/index'; +import type { ConfirmPromptOptions, MaybePromise } from '#tui/types'; + +export type ConfirmPromptBuilderMethods = { + confirm(this: FormBuilder, options: ConfirmPromptOptions, name?: string): FormBuilder; + confirm( + this: FormBuilder, + label: string, + defaultValue?: boolean, + yes?: string, + no?: string, + required?: boolean | string, + validate?: (value: boolean) => MaybePromise, + hint?: string, + name?: string, + transform?: (value: boolean) => MaybePromise, + ): FormBuilder; +}; diff --git a/packages/tui/src/form/builder/prompts/contracts/choices/multisearch.ts b/packages/tui/src/form/builder/prompts/contracts/choices/multisearch.ts new file mode 100644 index 00000000..04f78ea5 --- /dev/null +++ b/packages/tui/src/form/builder/prompts/contracts/choices/multisearch.ts @@ -0,0 +1,19 @@ +import type { FormBuilder } from '#tui/form/builder/index'; +import type { MultiSearchPromptOptions } from '#tui/types'; + +export type MultiSearchPromptBuilderMethods = { + multisearch(this: FormBuilder, options: MultiSearchPromptOptions, name?: string): FormBuilder; + multisearch( + this: FormBuilder, + label: string, + options: MultiSearchPromptOptions['options'], + placeholder?: string, + scroll?: number, + required?: MultiSearchPromptOptions['required'], + validate?: MultiSearchPromptOptions['validate'], + hint?: string, + name?: string, + transform?: MultiSearchPromptOptions['transform'], + info?: MultiSearchPromptOptions['info'], + ): FormBuilder; +}; diff --git a/packages/tui/src/form/builder/prompts/contracts/choices/multiselect.ts b/packages/tui/src/form/builder/prompts/contracts/choices/multiselect.ts new file mode 100644 index 00000000..cdccf478 --- /dev/null +++ b/packages/tui/src/form/builder/prompts/contracts/choices/multiselect.ts @@ -0,0 +1,19 @@ +import type { FormBuilder } from '#tui/form/builder/index'; +import type { ChoiceOptions, MaybePromise, MultiSelectPromptOptions } from '#tui/types'; + +export type MultiSelectPromptBuilderMethods = { + multiselect(this: FormBuilder, options: MultiSelectPromptOptions, name?: string): FormBuilder; + multiselect( + this: FormBuilder, + label: string, + options: ChoiceOptions, + defaultValue?: T[], + scroll?: number, + required?: boolean | string, + validate?: (value: T[]) => MaybePromise, + hint?: string, + name?: string, + transform?: (value: T[]) => MaybePromise, + info?: MultiSelectPromptOptions['info'], + ): FormBuilder; +}; diff --git a/packages/tui/src/form/builder/prompts/contracts/choices/pause.ts b/packages/tui/src/form/builder/prompts/contracts/choices/pause.ts new file mode 100644 index 00000000..679a9d4f --- /dev/null +++ b/packages/tui/src/form/builder/prompts/contracts/choices/pause.ts @@ -0,0 +1,5 @@ +import type { FormBuilder } from '#tui/form/builder/index'; + +export type PausePromptBuilderMethods = { + pause(this: FormBuilder, message?: string, name?: string): FormBuilder; +}; diff --git a/packages/tui/src/form/builder/prompts/contracts/choices/search.ts b/packages/tui/src/form/builder/prompts/contracts/choices/search.ts new file mode 100644 index 00000000..ff855f9f --- /dev/null +++ b/packages/tui/src/form/builder/prompts/contracts/choices/search.ts @@ -0,0 +1,19 @@ +import type { FormBuilder } from '#tui/form/builder/index'; +import type { SearchPromptOptions } from '#tui/types'; + +export type SearchPromptBuilderMethods = { + search(this: FormBuilder, options: SearchPromptOptions, name?: string): FormBuilder; + search( + this: FormBuilder, + label: string, + options: SearchPromptOptions['options'], + placeholder?: string, + scroll?: number, + validate?: SearchPromptOptions['validate'], + hint?: string, + required?: SearchPromptOptions['required'], + name?: string, + transform?: SearchPromptOptions['transform'], + info?: SearchPromptOptions['info'], + ): FormBuilder; +}; diff --git a/packages/tui/src/form/builder/prompts/contracts/choices/select.ts b/packages/tui/src/form/builder/prompts/contracts/choices/select.ts new file mode 100644 index 00000000..f76ebc02 --- /dev/null +++ b/packages/tui/src/form/builder/prompts/contracts/choices/select.ts @@ -0,0 +1,19 @@ +import type { FormBuilder } from '#tui/form/builder/index'; +import type { ChoiceOptions, MaybePromise, SelectPromptOptions } from '#tui/types'; + +export type SelectPromptBuilderMethods = { + select(this: FormBuilder, options: SelectPromptOptions, name?: string): FormBuilder; + select( + this: FormBuilder, + label: string, + options: ChoiceOptions, + defaultValue?: T, + scroll?: number, + validate?: (value: T) => MaybePromise, + hint?: string, + required?: boolean | string, + name?: string, + transform?: (value: T) => MaybePromise, + info?: SelectPromptOptions['info'], + ): FormBuilder; +}; diff --git a/packages/tui/src/form/builder/prompts/contracts/choices/suggest.ts b/packages/tui/src/form/builder/prompts/contracts/choices/suggest.ts new file mode 100644 index 00000000..3760d9b6 --- /dev/null +++ b/packages/tui/src/form/builder/prompts/contracts/choices/suggest.ts @@ -0,0 +1,21 @@ +import type { FormBuilder } from '#tui/form/builder/index'; +import type { SuggestOptions } from '#tui/prompts/choices'; +import type { TextPromptOptions } from '#tui/types'; + +export type SuggestPromptBuilderMethods = { + suggest(this: FormBuilder, options: SuggestOptions, name?: string): FormBuilder; + suggest( + this: FormBuilder, + label: string, + options: SuggestOptions['options'], + placeholder?: string, + defaultValue?: string, + scroll?: number, + required?: boolean | string, + validate?: TextPromptOptions['validate'], + hint?: string, + name?: string, + transform?: TextPromptOptions['transform'], + info?: SuggestOptions['info'], + ): FormBuilder; +}; diff --git a/packages/tui/src/form/builder/prompts/contracts/index.ts b/packages/tui/src/form/builder/prompts/contracts/index.ts new file mode 100644 index 00000000..3edc9899 --- /dev/null +++ b/packages/tui/src/form/builder/prompts/contracts/index.ts @@ -0,0 +1,4 @@ +import type { BasicPromptBuilderMethods } from '#tui/form/builder/prompts/contracts/basic'; +import type { ChoicePromptBuilderMethods } from '#tui/form/builder/prompts/contracts/choices'; + +export type PromptBuilderMethods = BasicPromptBuilderMethods & ChoicePromptBuilderMethods; diff --git a/packages/tui/src/form/builder/prompts/types.ts b/packages/tui/src/form/builder/prompts/types.ts new file mode 100644 index 00000000..cc3b5efa --- /dev/null +++ b/packages/tui/src/form/builder/prompts/types.ts @@ -0,0 +1,3 @@ +export type { BasicPromptBuilderMethods } from '#tui/form/builder/prompts/contracts/basic'; +export type { ChoicePromptBuilderMethods } from '#tui/form/builder/prompts/contracts/choices'; +export type { PromptBuilderMethods } from '#tui/form/builder/prompts/contracts/index'; diff --git a/packages/tui/src/form/builder/prompts/validators/basic-label.ts b/packages/tui/src/form/builder/prompts/validators/basic-label.ts new file mode 100644 index 00000000..7202d708 --- /dev/null +++ b/packages/tui/src/form/builder/prompts/validators/basic-label.ts @@ -0,0 +1,17 @@ +import { z } from 'zod'; + +const basicPromptLabelSchema = z.string(); + +export const isBasicPromptLabel = (value: unknown): value is string => { + return basicPromptLabelSchema.safeParse(value).success; +}; + +export const parseBasicStepName = (value: unknown): string | undefined => { + const parsed = basicPromptLabelSchema.safeParse(value); + + return parsed.success ? parsed.data : undefined; +}; + +export const isBasicPromptOptions = (value: TOptions | string): value is TOptions => { + return !isBasicPromptLabel(value); +}; diff --git a/packages/tui/src/form/builder/prompts/validators/basic-number.ts b/packages/tui/src/form/builder/prompts/validators/basic-number.ts new file mode 100644 index 00000000..955535c3 --- /dev/null +++ b/packages/tui/src/form/builder/prompts/validators/basic-number.ts @@ -0,0 +1,63 @@ +import { isBasicPromptLabel, isBasicPromptOptions, parseBasicStepName } from '#tui/form/builder/prompts/validators/basic-label'; +import type { NumberPromptOptions } from '#tui/types'; + +export type ResolvedNumberFormArguments = + | { + kind: 'options'; + name?: string; + options: NumberPromptOptions; + } + | { + defaultValue: number | string; + hasLabelDefault: boolean; + hint: string; + kind: 'label'; + label: string; + max?: number; + min?: number; + name?: string; + placeholder: string; + required: NumberPromptOptions['required']; + step?: number; + transform: NumberPromptOptions['transform']; + validate: NumberPromptOptions['validate']; + }; + +export const resolveNumberFormArguments = ( + optionsOrLabel: NumberPromptOptions | string, + placeholder = '', + defaultValue: number | string = '', + required: NumberPromptOptions['required'] = false, + validate: NumberPromptOptions['validate'] = undefined, + hint = '', + min?: number, + max?: number, + step?: number, + name?: string, + transform: NumberPromptOptions['transform'] = undefined, + argumentCount = 0, +): ResolvedNumberFormArguments => { + if (isBasicPromptOptions(optionsOrLabel)) { + return { + kind: 'options', + name: parseBasicStepName(placeholder), + options: optionsOrLabel, + }; + } + + return { + defaultValue, + hasLabelDefault: isBasicPromptLabel(optionsOrLabel) && argumentCount >= 3 && defaultValue !== undefined, + hint, + kind: 'label', + label: optionsOrLabel, + max, + min, + name, + placeholder, + required, + step, + transform, + validate, + }; +}; diff --git a/packages/tui/src/form/builder/prompts/validators/basic-text.ts b/packages/tui/src/form/builder/prompts/validators/basic-text.ts new file mode 100644 index 00000000..5d8d855f --- /dev/null +++ b/packages/tui/src/form/builder/prompts/validators/basic-text.ts @@ -0,0 +1,70 @@ +import { isBasicPromptOptions, parseBasicStepName } from '#tui/form/builder/prompts/validators/basic-label'; +import type { TextPromptOptions } from '#tui/types'; + +export type ResolvedTextFormArguments = { + name?: string; + options: TextPromptOptions; +}; + +export const resolveTextFormArguments = ( + optionsOrLabel: TextPromptOptions | string, + placeholder = '', + defaultValue = '', + required: TextPromptOptions['required'] = false, + validate: TextPromptOptions['validate'] = undefined, + hint = '', + name?: string, + transform: TextPromptOptions['transform'] = undefined, +): ResolvedTextFormArguments => { + if (isBasicPromptOptions(optionsOrLabel)) { + return { + name: parseBasicStepName(placeholder), + options: optionsOrLabel, + }; + } + + return { + name, + options: { + message: optionsOrLabel, + label: optionsOrLabel, + placeholder, + default: defaultValue, + required, + validate, + hint, + transform, + }, + }; +}; + +export const resolvePasswordFormArguments = ( + optionsOrLabel: TextPromptOptions | string, + placeholder = '', + required: TextPromptOptions['required'] = false, + validate: TextPromptOptions['validate'] = undefined, + hint = '', + name?: string, + transform: TextPromptOptions['transform'] = undefined, +): ResolvedTextFormArguments => { + if (isBasicPromptOptions(optionsOrLabel)) { + return { + name: parseBasicStepName(placeholder), + options: optionsOrLabel, + }; + } + + return { + name, + options: { + message: optionsOrLabel, + label: optionsOrLabel, + placeholder, + default: '', + required, + validate, + hint, + transform, + }, + }; +}; diff --git a/packages/tui/src/form/builder/prompts/validators/basic-textarea.ts b/packages/tui/src/form/builder/prompts/validators/basic-textarea.ts new file mode 100644 index 00000000..dea0b4ab --- /dev/null +++ b/packages/tui/src/form/builder/prompts/validators/basic-textarea.ts @@ -0,0 +1,41 @@ +import { isBasicPromptOptions, parseBasicStepName } from '#tui/form/builder/prompts/validators/basic-label'; +import type { TextareaPromptOptions } from '#tui/types'; + +export type ResolvedTextareaFormArguments = { + name?: string; + options: TextareaPromptOptions; +}; + +export const resolveTextareaFormArguments = ( + optionsOrLabel: TextareaPromptOptions | string, + placeholder = '', + defaultValue = '', + required: TextareaPromptOptions['required'] = false, + validate: TextareaPromptOptions['validate'] = undefined, + hint = '', + rows = 5, + name?: string, + transform: TextareaPromptOptions['transform'] = undefined, +): ResolvedTextareaFormArguments => { + if (isBasicPromptOptions(optionsOrLabel)) { + return { + name: parseBasicStepName(placeholder), + options: optionsOrLabel, + }; + } + + return { + name, + options: { + message: optionsOrLabel, + label: optionsOrLabel, + placeholder, + default: defaultValue, + required, + validate, + hint, + rows, + transform, + }, + }; +}; diff --git a/packages/tui/src/form/builder/prompts/validators/basic.ts b/packages/tui/src/form/builder/prompts/validators/basic.ts new file mode 100644 index 00000000..c5b8597c --- /dev/null +++ b/packages/tui/src/form/builder/prompts/validators/basic.ts @@ -0,0 +1,7 @@ +export { isBasicPromptLabel, isBasicPromptOptions, parseBasicStepName } from '#tui/form/builder/prompts/validators/basic-label'; +export { resolveNumberFormArguments } from '#tui/form/builder/prompts/validators/basic-number'; +export type { ResolvedNumberFormArguments } from '#tui/form/builder/prompts/validators/basic-number'; +export { resolvePasswordFormArguments, resolveTextFormArguments } from '#tui/form/builder/prompts/validators/basic-text'; +export type { ResolvedTextFormArguments } from '#tui/form/builder/prompts/validators/basic-text'; +export { resolveTextareaFormArguments } from '#tui/form/builder/prompts/validators/basic-textarea'; +export type { ResolvedTextareaFormArguments } from '#tui/form/builder/prompts/validators/basic-textarea'; diff --git a/packages/tui/src/form/builder/prompts/validators/search.ts b/packages/tui/src/form/builder/prompts/validators/search.ts new file mode 100644 index 00000000..f6d3e632 --- /dev/null +++ b/packages/tui/src/form/builder/prompts/validators/search.ts @@ -0,0 +1,6 @@ +export { isSearchPromptLabel, isSearchPromptOptions, parseSearchChoiceSource, parseSearchStepName } from '#tui/form/builder/prompts/validators/search/common'; +export type { SearchChoiceSourceCallback } from '#tui/form/builder/prompts/validators/search/common'; +export { resolveMultiSearchFormArguments } from '#tui/form/builder/prompts/validators/search/multisearch'; +export type { ResolvedMultiSearchFormArguments } from '#tui/form/builder/prompts/validators/search/multisearch'; +export { resolveSearchFormArguments } from '#tui/form/builder/prompts/validators/search/search'; +export type { ResolvedSearchFormArguments } from '#tui/form/builder/prompts/validators/search/search'; diff --git a/packages/tui/src/form/builder/prompts/validators/search/common.ts b/packages/tui/src/form/builder/prompts/validators/search/common.ts new file mode 100644 index 00000000..0befbd99 --- /dev/null +++ b/packages/tui/src/form/builder/prompts/validators/search/common.ts @@ -0,0 +1,36 @@ +import { z } from 'zod'; +import { choiceOptionsSchema } from '#tui/concerns/validators/choice'; +import { functionSchema } from '#tui/validators/function'; +import type { ChoiceOptions, MaybePromise } from '#tui/types'; + +export type SearchChoiceSourceCallback = (query: string) => MaybePromise>; + +const searchLabelSchema = z.string(); + +const searchChoiceSourceCallbackSchema = (): z.ZodType> => functionSchema>(); + +const searchChoiceSourceSchema = (): z.ZodType | SearchChoiceSourceCallback> => z.union([choiceOptionsSchema(), searchChoiceSourceCallbackSchema()]); + +export const isSearchPromptLabel = (value: unknown): value is string => { + return searchLabelSchema.safeParse(value).success; +}; + +export const isSearchPromptOptions = (value: TOptions | string): value is TOptions => { + return !isSearchPromptLabel(value); +}; + +export const parseSearchStepName = (value: unknown): string | undefined => { + const parsed = searchLabelSchema.safeParse(value); + + return parsed.success ? parsed.data : undefined; +}; + +export const parseSearchChoiceSource = (value: unknown): ChoiceOptions | SearchChoiceSourceCallback => { + const parsed = searchChoiceSourceSchema().safeParse(value); + + if (!parsed.success) { + throw new TypeError('Search choices must be an array, record, or callback.'); + } + + return parsed.data; +}; diff --git a/packages/tui/src/form/builder/prompts/validators/search/multisearch.ts b/packages/tui/src/form/builder/prompts/validators/search/multisearch.ts new file mode 100644 index 00000000..a1b93cf5 --- /dev/null +++ b/packages/tui/src/form/builder/prompts/validators/search/multisearch.ts @@ -0,0 +1,43 @@ +import { isSearchPromptOptions, parseSearchChoiceSource, parseSearchStepName } from '#tui/form/builder/prompts/validators/search/common'; +import type { MultiSearchPromptOptions } from '#tui/types'; + +export type ResolvedMultiSearchFormArguments = { + name?: string; + options: MultiSearchPromptOptions; +}; + +export const resolveMultiSearchFormArguments = ( + optionsOrLabel: MultiSearchPromptOptions | string, + sourceOrName?: MultiSearchPromptOptions['options'] | string, + placeholder = '', + scroll = 5, + required: MultiSearchPromptOptions['required'] = false, + validate: MultiSearchPromptOptions['validate'] = undefined, + hint = 'Use the space bar to select options.', + name?: string, + transform: MultiSearchPromptOptions['transform'] = undefined, + info: MultiSearchPromptOptions['info'] = '', +): ResolvedMultiSearchFormArguments => { + if (isSearchPromptOptions(optionsOrLabel)) { + return { + name: parseSearchStepName(sourceOrName), + options: optionsOrLabel, + }; + } + + return { + name, + options: { + message: optionsOrLabel, + label: optionsOrLabel, + options: parseSearchChoiceSource(sourceOrName), + placeholder, + scroll, + required, + validate, + hint, + transform, + info, + }, + }; +}; diff --git a/packages/tui/src/form/builder/prompts/validators/search/search.ts b/packages/tui/src/form/builder/prompts/validators/search/search.ts new file mode 100644 index 00000000..8aee2cde --- /dev/null +++ b/packages/tui/src/form/builder/prompts/validators/search/search.ts @@ -0,0 +1,43 @@ +import { isSearchPromptOptions, parseSearchChoiceSource, parseSearchStepName } from '#tui/form/builder/prompts/validators/search/common'; +import type { SearchPromptOptions } from '#tui/types'; + +export type ResolvedSearchFormArguments = { + name?: string; + options: SearchPromptOptions; +}; + +export const resolveSearchFormArguments = ( + optionsOrLabel: SearchPromptOptions | string, + sourceOrName?: SearchPromptOptions['options'] | string, + placeholder = '', + scroll = 5, + validate: SearchPromptOptions['validate'] = undefined, + hint = '', + required: SearchPromptOptions['required'] = true, + name?: string, + transform: SearchPromptOptions['transform'] = undefined, + info: SearchPromptOptions['info'] = '', +): ResolvedSearchFormArguments => { + if (isSearchPromptOptions(optionsOrLabel)) { + return { + name: parseSearchStepName(sourceOrName), + options: optionsOrLabel, + }; + } + + return { + name, + options: { + message: optionsOrLabel, + label: optionsOrLabel, + options: parseSearchChoiceSource(sourceOrName), + placeholder, + scroll, + validate, + hint, + required, + transform, + info, + }, + }; +}; diff --git a/packages/tui/src/form/builder/prompts/validators/select.ts b/packages/tui/src/form/builder/prompts/validators/select.ts new file mode 100644 index 00000000..66fafe55 --- /dev/null +++ b/packages/tui/src/form/builder/prompts/validators/select.ts @@ -0,0 +1,7 @@ +export { isSelectPromptLabel, isSelectPromptOptions, parseConfirmDefault, parseSelectChoiceOptions, parseSelectStepName } from '#tui/form/builder/prompts/validators/select/common'; +export { resolveConfirmFormArguments } from '#tui/form/builder/prompts/validators/select/confirm'; +export type { ResolvedConfirmFormArguments } from '#tui/form/builder/prompts/validators/select/confirm'; +export { resolveMultiSelectFormArguments } from '#tui/form/builder/prompts/validators/select/multiselect'; +export type { ResolvedMultiSelectFormArguments } from '#tui/form/builder/prompts/validators/select/multiselect'; +export { resolveSelectFormArguments } from '#tui/form/builder/prompts/validators/select/select'; +export type { ResolvedSelectFormArguments } from '#tui/form/builder/prompts/validators/select/select'; diff --git a/packages/tui/src/form/builder/prompts/validators/select/common.ts b/packages/tui/src/form/builder/prompts/validators/select/common.ts new file mode 100644 index 00000000..6efa49ef --- /dev/null +++ b/packages/tui/src/form/builder/prompts/validators/select/common.ts @@ -0,0 +1,36 @@ +import { z } from 'zod'; +import { choiceOptionsSchema } from '#tui/concerns/validators/choice'; +import type { ChoiceOptions } from '#tui/types'; + +const selectPromptLabelSchema = z.string(); +const selectPromptDefaultSchema = z.boolean(); + +export const isSelectPromptLabel = (value: unknown): value is string => { + return selectPromptLabelSchema.safeParse(value).success; +}; + +export const isSelectPromptOptions = (value: TOptions | string): value is TOptions => { + return !isSelectPromptLabel(value); +}; + +export const parseSelectStepName = (value: unknown): string | undefined => { + const parsed = selectPromptLabelSchema.safeParse(value); + + return parsed.success ? parsed.data : undefined; +}; + +export const parseSelectChoiceOptions = (value: unknown): ChoiceOptions => { + const parsed = choiceOptionsSchema().safeParse(value); + + if (!parsed.success) { + throw new TypeError('Choice options must be an array or record.'); + } + + return parsed.data; +}; + +export const parseConfirmDefault = (value: unknown, fallback: boolean): boolean => { + const parsed = selectPromptDefaultSchema.safeParse(value); + + return parsed.success ? parsed.data : fallback; +}; diff --git a/packages/tui/src/form/builder/prompts/validators/select/confirm.ts b/packages/tui/src/form/builder/prompts/validators/select/confirm.ts new file mode 100644 index 00000000..947cf4ec --- /dev/null +++ b/packages/tui/src/form/builder/prompts/validators/select/confirm.ts @@ -0,0 +1,41 @@ +import { isSelectPromptOptions, parseConfirmDefault, parseSelectStepName } from '#tui/form/builder/prompts/validators/select/common'; +import type { ConfirmPromptOptions } from '#tui/types'; + +export type ResolvedConfirmFormArguments = { + name?: string; + options: ConfirmPromptOptions; +}; + +export const resolveConfirmFormArguments = ( + optionsOrLabel: ConfirmPromptOptions | string, + defaultValueOrName: boolean | string = true, + yes = 'Yes', + no = 'No', + required: boolean | string = false, + validate: ConfirmPromptOptions['validate'] = undefined, + hint = '', + name?: string, + transform: ConfirmPromptOptions['transform'] = undefined, +): ResolvedConfirmFormArguments => { + if (isSelectPromptOptions(optionsOrLabel)) { + return { + name: parseSelectStepName(defaultValueOrName), + options: optionsOrLabel, + }; + } + + return { + name, + options: { + message: optionsOrLabel, + label: optionsOrLabel, + default: parseConfirmDefault(defaultValueOrName, true), + yes, + no, + required, + validate, + hint, + transform, + }, + }; +}; diff --git a/packages/tui/src/form/builder/prompts/validators/select/multiselect.ts b/packages/tui/src/form/builder/prompts/validators/select/multiselect.ts new file mode 100644 index 00000000..21f68065 --- /dev/null +++ b/packages/tui/src/form/builder/prompts/validators/select/multiselect.ts @@ -0,0 +1,42 @@ +import { isSelectPromptOptions, parseSelectChoiceOptions, parseSelectStepName } from '#tui/form/builder/prompts/validators/select/common'; +import type { ChoiceOptions, MultiSelectPromptOptions } from '#tui/types'; + +export type ResolvedMultiSelectFormArguments = { + name?: string; + options: MultiSelectPromptOptions; +}; + +export const resolveMultiSelectFormArguments = ( + optionsOrLabel: MultiSelectPromptOptions | string, + choicesOrName?: ChoiceOptions | string, + defaultValue: T[] = [], + scroll = 5, + required: boolean | string = false, + validate: MultiSelectPromptOptions['validate'] = undefined, + hint = 'Use the space bar to select options.', + name?: string, + transform: MultiSelectPromptOptions['transform'] = undefined, + info: MultiSelectPromptOptions['info'] = '', +): ResolvedMultiSelectFormArguments => { + if (isSelectPromptOptions(optionsOrLabel)) { + return { + name: parseSelectStepName(choicesOrName), + options: optionsOrLabel, + }; + } + + return { + name, + options: { + message: optionsOrLabel, + options: parseSelectChoiceOptions(choicesOrName), + default: defaultValue, + scroll, + required, + validate, + hint, + transform, + info, + }, + }; +}; diff --git a/packages/tui/src/form/builder/prompts/validators/select/select.ts b/packages/tui/src/form/builder/prompts/validators/select/select.ts new file mode 100644 index 00000000..bbbe9c89 --- /dev/null +++ b/packages/tui/src/form/builder/prompts/validators/select/select.ts @@ -0,0 +1,42 @@ +import { isSelectPromptOptions, parseSelectChoiceOptions, parseSelectStepName } from '#tui/form/builder/prompts/validators/select/common'; +import type { ChoiceOptions, SelectPromptOptions } from '#tui/types'; + +export type ResolvedSelectFormArguments = { + name?: string; + options: SelectPromptOptions; +}; + +export const resolveSelectFormArguments = ( + optionsOrLabel: SelectPromptOptions | string, + choicesOrName?: ChoiceOptions | string, + defaultValue?: T, + scroll = 5, + validate: SelectPromptOptions['validate'] = undefined, + hint = '', + required: boolean | string = true, + name?: string, + transform: SelectPromptOptions['transform'] = undefined, + info: SelectPromptOptions['info'] = '', +): ResolvedSelectFormArguments => { + if (isSelectPromptOptions(optionsOrLabel)) { + return { + name: parseSelectStepName(choicesOrName), + options: optionsOrLabel, + }; + } + + return { + name, + options: { + message: optionsOrLabel, + options: parseSelectChoiceOptions(choicesOrName), + default: defaultValue, + scroll, + validate, + hint, + required, + transform, + info, + }, + }; +}; diff --git a/packages/tui/src/form/builder/prompts/validators/suggest.ts b/packages/tui/src/form/builder/prompts/validators/suggest.ts new file mode 100644 index 00000000..c18d51ad --- /dev/null +++ b/packages/tui/src/form/builder/prompts/validators/suggest.ts @@ -0,0 +1,6 @@ +export { resolveAutocompleteFormArguments } from '#tui/form/builder/prompts/validators/suggest/autocomplete'; +export type { ResolvedAutocompleteFormArguments } from '#tui/form/builder/prompts/validators/suggest/autocomplete'; +export { isSuggestPromptLabel, isSuggestPromptOptions, parseSuggestSource, parseSuggestStepName } from '#tui/form/builder/prompts/validators/suggest/common'; +export type { SuggestSourceCallback } from '#tui/form/builder/prompts/validators/suggest/common'; +export { resolveSuggestFormArguments } from '#tui/form/builder/prompts/validators/suggest/suggest'; +export type { ResolvedSuggestFormArguments } from '#tui/form/builder/prompts/validators/suggest/suggest'; diff --git a/packages/tui/src/form/builder/prompts/validators/suggest/autocomplete.ts b/packages/tui/src/form/builder/prompts/validators/suggest/autocomplete.ts new file mode 100644 index 00000000..8f9b2327 --- /dev/null +++ b/packages/tui/src/form/builder/prompts/validators/suggest/autocomplete.ts @@ -0,0 +1,44 @@ +import { isSuggestPromptOptions, parseSuggestSource, parseSuggestStepName } from '#tui/form/builder/prompts/validators/suggest/common'; +import type { SuggestOptions } from '#tui/prompts/suggest/options'; +import type { TextPromptOptions } from '#tui/types'; + +export type ResolvedAutocompleteFormArguments = { + name?: string; + options: SuggestOptions; +}; + +export const resolveAutocompleteFormArguments = ( + optionsOrLabel: SuggestOptions | string, + sourceOrName?: SuggestOptions['options'] | string, + placeholder = '', + defaultValue = '', + required: boolean | string = false, + validate: TextPromptOptions['validate'] = undefined, + hint = '', + name?: string, + transform: TextPromptOptions['transform'] = undefined, + info: SuggestOptions['info'] = '', +): ResolvedAutocompleteFormArguments => { + if (isSuggestPromptOptions(optionsOrLabel)) { + return { + name: parseSuggestStepName(sourceOrName), + options: optionsOrLabel, + }; + } + + return { + name, + options: { + message: optionsOrLabel, + label: optionsOrLabel, + options: parseSuggestSource(sourceOrName), + placeholder, + default: defaultValue, + required, + validate, + hint, + transform, + info, + }, + }; +}; diff --git a/packages/tui/src/form/builder/prompts/validators/suggest/common.ts b/packages/tui/src/form/builder/prompts/validators/suggest/common.ts new file mode 100644 index 00000000..9e3231f3 --- /dev/null +++ b/packages/tui/src/form/builder/prompts/validators/suggest/common.ts @@ -0,0 +1,33 @@ +import { z } from 'zod'; +import { functionSchema } from '#tui/validators/function'; +import type { MaybePromise } from '#tui/types'; + +export type SuggestSourceCallback = (query: string) => MaybePromise; + +const suggestLabelSchema = z.string(); +const suggestSourceCallbackSchema: z.ZodType = functionSchema(); +const suggestSourceSchema = z.union([z.array(z.string()), suggestSourceCallbackSchema]); + +export const isSuggestPromptLabel = (value: unknown): value is string => { + return suggestLabelSchema.safeParse(value).success; +}; + +export const isSuggestPromptOptions = (value: TOptions | string): value is TOptions => { + return !isSuggestPromptLabel(value); +}; + +export const parseSuggestStepName = (value: unknown): string | undefined => { + const parsed = suggestLabelSchema.safeParse(value); + + return parsed.success ? parsed.data : undefined; +}; + +export const parseSuggestSource = (value: unknown): string[] | SuggestSourceCallback => { + const parsed = suggestSourceSchema.safeParse(value); + + if (!parsed.success) { + throw new TypeError('Suggestion source must be an array of strings or callback.'); + } + + return parsed.data; +}; diff --git a/packages/tui/src/form/builder/prompts/validators/suggest/suggest.ts b/packages/tui/src/form/builder/prompts/validators/suggest/suggest.ts new file mode 100644 index 00000000..f7af1779 --- /dev/null +++ b/packages/tui/src/form/builder/prompts/validators/suggest/suggest.ts @@ -0,0 +1,46 @@ +import { isSuggestPromptOptions, parseSuggestSource, parseSuggestStepName } from '#tui/form/builder/prompts/validators/suggest/common'; +import type { SuggestOptions } from '#tui/prompts/suggest/options'; +import type { TextPromptOptions } from '#tui/types'; + +export type ResolvedSuggestFormArguments = { + name?: string; + options: SuggestOptions; +}; + +export const resolveSuggestFormArguments = ( + optionsOrLabel: SuggestOptions | string, + sourceOrName?: SuggestOptions['options'] | string, + placeholder = '', + defaultValue = '', + scroll = 5, + required: boolean | string = false, + validate: TextPromptOptions['validate'] = undefined, + hint = '', + name?: string, + transform: TextPromptOptions['transform'] = undefined, + info: SuggestOptions['info'] = '', +): ResolvedSuggestFormArguments => { + if (isSuggestPromptOptions(optionsOrLabel)) { + return { + name: parseSuggestStepName(sourceOrName), + options: optionsOrLabel, + }; + } + + return { + name, + options: { + message: optionsOrLabel, + label: optionsOrLabel, + options: parseSuggestSource(sourceOrName), + placeholder, + default: defaultValue, + scroll, + required, + validate, + hint, + transform, + info, + }, + }; +}; diff --git a/packages/tui/src/form/builder/responses.ts b/packages/tui/src/form/builder/responses.ts new file mode 100644 index 00000000..ff83a1c5 --- /dev/null +++ b/packages/tui/src/form/builder/responses.ts @@ -0,0 +1,7 @@ +import type { FormResponses } from '#tui/form/types'; + +class FormResponseList extends Array implements FormResponses { + [name: string]: unknown; +} + +export const createFormResponses = (): FormResponses => new FormResponseList(); diff --git a/packages/tui/src/form/builder/revert.ts b/packages/tui/src/form/builder/revert.ts new file mode 100644 index 00000000..739114f7 --- /dev/null +++ b/packages/tui/src/form/builder/revert.ts @@ -0,0 +1,42 @@ +import { promptEnvironment, withPromptEnvironment } from '#tui/environment'; +import { Key } from '#tui/key'; +import { rejectPromptRevert } from '#tui/prompt/revert'; +import type { MaybePromise, PromptInput } from '#tui/types'; + +export class FormRevertedError extends Error { + constructor() { + super('Form reverted.'); + this.name = 'FormRevertedError'; + } +} + +export const runWithFormRevert = async (canRevert: boolean, callback: () => MaybePromise): Promise => { + const environment = promptEnvironment(); + + if (!environment.input.readKey) { + return callback(); + } + + const previousInput = environment.input; + + const input: PromptInput = { + ...previousInput, + async readKey(): Promise { + while (true) { + const key = await previousInput.readKey?.(); + + if (key !== Key.ctrlU) { + return key ?? null; + } + + if (canRevert) { + throw new FormRevertedError(); + } + + rejectPromptRevert(); + } + }, + }; + + return withPromptEnvironment({ input }, callback); +}; diff --git a/packages/tui/src/form/builder/status.ts b/packages/tui/src/form/builder/status.ts new file mode 100644 index 00000000..37ff4439 --- /dev/null +++ b/packages/tui/src/form/builder/status.ts @@ -0,0 +1,23 @@ +import { progressFormStep, spinFormStep, streamFormStep, taskFormStep } from '#tui/form/builder/status/index'; +import type { FormBuilder } from '#tui/form/builder/index'; +import type { MaybePromise } from '#tui/types'; +import type { Logger, Progress, TaskDefinition } from '#tui/status'; + +export type StatusBuilderMethods = { + progress(this: FormBuilder, total: number, message?: string, name?: string): FormBuilder; + progress(this: FormBuilder, label: string, steps: Iterable | number, callback: (step: T | number, bar: Progress) => MaybePromise, hint?: string, name?: string): FormBuilder; + spin(this: FormBuilder, callback: () => MaybePromise, message?: string, name?: string): FormBuilder; + spin(this: FormBuilder, message: string, callback: () => MaybePromise, name?: string): FormBuilder; + task(this: FormBuilder, definition: TaskDefinition, name?: string): FormBuilder; + stream(this: FormBuilder, name?: string): FormBuilder; + stream(this: FormBuilder): FormBuilder; + stream(this: FormBuilder, source: AsyncIterable | Iterable, name?: string): FormBuilder; + task(this: FormBuilder, label: string, callback: (logger: Logger) => MaybePromise, limit?: number, keepSummary?: boolean, subLabel?: string, name?: string): FormBuilder; +}; + +export const statusBuilderMethods: StatusBuilderMethods & ThisType = { + progress: progressFormStep, + spin: spinFormStep, + stream: streamFormStep, + task: taskFormStep, +}; diff --git a/packages/tui/src/form/builder/status/index.ts b/packages/tui/src/form/builder/status/index.ts new file mode 100644 index 00000000..e51471b9 --- /dev/null +++ b/packages/tui/src/form/builder/status/index.ts @@ -0,0 +1,4 @@ +export { progressFormStep } from '#tui/form/builder/status/progress'; +export { spinFormStep } from '#tui/form/builder/status/spin'; +export { streamFormStep } from '#tui/form/builder/status/stream'; +export { taskFormStep } from '#tui/form/builder/status/task'; diff --git a/packages/tui/src/form/builder/status/progress.ts b/packages/tui/src/form/builder/status/progress.ts new file mode 100644 index 00000000..64915bd6 --- /dev/null +++ b/packages/tui/src/form/builder/status/progress.ts @@ -0,0 +1,38 @@ +import { progress } from '#tui/status'; +import { resolveProgressFormArguments } from '#tui/form/builder/validators/status'; +import type { FormBuilder } from '#tui/form/builder/index'; +import type { MaybePromise } from '#tui/types'; +import type { Progress } from '#tui/status'; + +export function progressFormStep(this: FormBuilder, total: number, message?: string, name?: string): FormBuilder; + +export function progressFormStep( + this: FormBuilder, + label: string, + steps: Iterable | number, + callback: (step: T | number, bar: Progress) => MaybePromise, + hint?: string, + name?: string, +): FormBuilder; + +export function progressFormStep( + this: FormBuilder, + labelOrTotal: string | number, + stepsOrMessage?: Iterable | number | string, + callbackOrName?: ((step: T | number, bar: Progress) => MaybePromise) | string, + hint = '', + name?: string, +): FormBuilder { + const resolved = resolveProgressFormArguments(labelOrTotal, stepsOrMessage, callbackOrName, hint, name); + + if (resolved.kind === 'total') { + return this.addSideEffect(() => { + const bar = progress(resolved.total, resolved.message); + + bar.start(); + bar.finish(); + }, resolved.name); + } + + return this.add(() => progress(resolved.label, resolved.steps, resolved.callback, resolved.hint), resolved.name, true); +} diff --git a/packages/tui/src/form/builder/status/spin.ts b/packages/tui/src/form/builder/status/spin.ts new file mode 100644 index 00000000..20d25120 --- /dev/null +++ b/packages/tui/src/form/builder/status/spin.ts @@ -0,0 +1,18 @@ +import { spin } from '#tui/status'; +import { resolveSpinFormArguments } from '#tui/form/builder/validators/status'; +import type { FormBuilder } from '#tui/form/builder/index'; +import type { MaybePromise } from '#tui/types'; + +export function spinFormStep(this: FormBuilder, callback: () => MaybePromise, message?: string, name?: string): FormBuilder; + +export function spinFormStep(this: FormBuilder, message: string, callback: () => MaybePromise, name?: string): FormBuilder; + +export function spinFormStep(this: FormBuilder, callbackOrMessage: (() => MaybePromise) | string, messageOrCallback: string | (() => MaybePromise) = '', name?: string): FormBuilder { + const resolved = resolveSpinFormArguments(callbackOrMessage, messageOrCallback, name); + + if (resolved.kind === 'message') { + return this.add(() => spin(resolved.message, resolved.callback), resolved.name, true); + } + + return this.add(() => spin(resolved.callback, { message: resolved.message }), resolved.name, true); +} diff --git a/packages/tui/src/form/builder/status/stream.ts b/packages/tui/src/form/builder/status/stream.ts new file mode 100644 index 00000000..5892bca3 --- /dev/null +++ b/packages/tui/src/form/builder/status/stream.ts @@ -0,0 +1,19 @@ +import { stream } from '#tui/status'; +import { resolveStreamFormArguments } from '#tui/form/builder/validators/status'; +import type { FormBuilder } from '#tui/form/builder/index'; + +export function streamFormStep(this: FormBuilder, name?: string): FormBuilder; + +export function streamFormStep(this: FormBuilder): FormBuilder; + +export function streamFormStep(this: FormBuilder, source: AsyncIterable | Iterable, name?: string): FormBuilder; + +export function streamFormStep(this: FormBuilder, sourceOrName?: AsyncIterable | Iterable | string, name?: string): FormBuilder { + const resolved = resolveStreamFormArguments(sourceOrName, name); + + if (resolved.kind === 'manual') { + return this.add(() => stream(), resolved.name); + } + + return this.addSideEffect(() => stream(resolved.source), resolved.name); +} diff --git a/packages/tui/src/form/builder/status/task.ts b/packages/tui/src/form/builder/status/task.ts new file mode 100644 index 00000000..637f0127 --- /dev/null +++ b/packages/tui/src/form/builder/status/task.ts @@ -0,0 +1,27 @@ +import { task } from '#tui/status'; +import { resolveTaskFormArguments } from '#tui/form/builder/validators/status'; +import type { FormBuilder } from '#tui/form/builder/index'; +import type { MaybePromise } from '#tui/types'; +import type { Logger, TaskDefinition } from '#tui/status'; + +export function taskFormStep(this: FormBuilder, definition: TaskDefinition, name?: string): FormBuilder; + +export function taskFormStep(this: FormBuilder, label: string, callback: (logger: Logger) => MaybePromise, limit?: number, keepSummary?: boolean, subLabel?: string, name?: string): FormBuilder; + +export function taskFormStep( + this: FormBuilder, + definitionOrLabel: TaskDefinition | string, + callbackOrName?: ((logger: Logger) => MaybePromise) | string, + limit = 10, + keepSummary = false, + subLabel = '', + name?: string, +): FormBuilder { + const resolved = resolveTaskFormArguments(definitionOrLabel, callbackOrName, limit, keepSummary, subLabel, name); + + if (resolved.kind === 'label') { + return this.add(() => task(resolved.label, resolved.callback, resolved.limit, resolved.keepSummary, resolved.subLabel), resolved.name); + } + + return this.add(() => task(resolved.definition), resolved.name); +} diff --git a/packages/tui/src/form/builder/step.ts b/packages/tui/src/form/builder/step.ts new file mode 100644 index 00000000..c0f30adc --- /dev/null +++ b/packages/tui/src/form/builder/step.ts @@ -0,0 +1,9 @@ +import type { MaybePromise } from '#tui/types'; + +export const sideEffectStep = (callback: () => MaybePromise): (() => MaybePromise) => { + return async () => { + await callback(); + + return null; + }; +}; diff --git a/packages/tui/src/form/builder/submit.ts b/packages/tui/src/form/builder/submit.ts new file mode 100644 index 00000000..57c30985 --- /dev/null +++ b/packages/tui/src/form/builder/submit.ts @@ -0,0 +1,46 @@ +import { shouldIgnoreStepWhenReverting, shouldRunStep } from '#tui/form/builder/conditions'; +import { runWithFormRevert } from '#tui/form/builder/revert'; +import { isFormRevertedError } from '#tui/form/builder/validators/revert'; +import type { FormResponses, FormStep } from '#tui/form/types'; + +export const submitFormSteps = async (steps: FormStep[], responses: FormResponses): Promise => { + let index = 0; + let wasReverted = false; + + while (index < steps.length) { + const step = steps[index]; + + if (step === undefined) { + break; + } + + if (wasReverted && index > 0 && (await shouldIgnoreStepWhenReverting(step, responses))) { + index -= 1; + continue; + } + + wasReverted = false; + + const key = step.name ?? index; + + if (!(await shouldRunStep(step, responses))) { + responses[key] = null; + index += 1; + continue; + } + + try { + responses[key] = await runWithFormRevert(index > 0, () => step.run(responses, responses[key], step.name)); + } catch (error) { + if (!isFormRevertedError(error)) { + throw error; + } + + wasReverted = true; + } + + index += wasReverted ? -1 : 1; + } + + return responses; +}; diff --git a/packages/tui/src/form/builder/validators/conditions.ts b/packages/tui/src/form/builder/validators/conditions.ts new file mode 100644 index 00000000..e38689a0 --- /dev/null +++ b/packages/tui/src/form/builder/validators/conditions.ts @@ -0,0 +1,15 @@ +import { z } from 'zod'; +import type { FormResponses, FormStepCondition } from '#tui/form/types'; +import type { MaybePromise } from '#tui/types'; + +type FormStepConditionCallback = (responses: FormResponses) => MaybePromise; + +const formStepConditionCallbackSchema = z.function(); + +export const isFormStepConditionCallback = (condition: FormStepCondition): condition is FormStepConditionCallback => { + return formStepConditionCallbackSchema.safeParse(condition).success; +}; + +export const resolveFormStepCondition = (condition: FormStepCondition, responses: FormResponses): MaybePromise => { + return isFormStepConditionCallback(condition) ? condition(responses) : condition; +}; diff --git a/packages/tui/src/form/builder/validators/output.ts b/packages/tui/src/form/builder/validators/output.ts new file mode 100644 index 00000000..4a1b32c5 --- /dev/null +++ b/packages/tui/src/form/builder/validators/output.ts @@ -0,0 +1,5 @@ +export { isNullOutputRows, parseOutputScroll, parseOutputStepName } from '#tui/form/builder/validators/output/common'; +export { parseOutputDataTableRows, resolveDataTableFormArguments } from '#tui/form/builder/validators/output/data-table'; +export type { ResolvedDataTableFormArguments } from '#tui/form/builder/validators/output/data-table'; +export { parseOutputTableRows, resolveTableFormArguments } from '#tui/form/builder/validators/output/table'; +export type { ResolvedTableFormArguments } from '#tui/form/builder/validators/output/table'; diff --git a/packages/tui/src/form/builder/validators/output/common.ts b/packages/tui/src/form/builder/validators/output/common.ts new file mode 100644 index 00000000..1024244e --- /dev/null +++ b/packages/tui/src/form/builder/validators/output/common.ts @@ -0,0 +1,21 @@ +import { z } from 'zod'; + +const outputStepNameSchema = z.string(); +const outputScrollSchema = z.number(); +const nullOutputRowsSchema = z.null(); + +export const isNullOutputRows = (value: unknown): value is null => { + return nullOutputRowsSchema.safeParse(value).success; +}; + +export const parseOutputStepName = (value: unknown): string | undefined => { + const parsed = outputStepNameSchema.safeParse(value); + + return parsed.success ? parsed.data : undefined; +}; + +export const parseOutputScroll = (value: unknown, defaultValue: number): number => { + const parsed = outputScrollSchema.safeParse(value); + + return parsed.success ? parsed.data : defaultValue; +}; diff --git a/packages/tui/src/form/builder/validators/output/data-table.ts b/packages/tui/src/form/builder/validators/output/data-table.ts new file mode 100644 index 00000000..3bd37999 --- /dev/null +++ b/packages/tui/src/form/builder/validators/output/data-table.ts @@ -0,0 +1,61 @@ +import { isNullOutputRows, parseOutputScroll, parseOutputStepName } from '#tui/form/builder/validators/output/common'; +import { dataTableStepName, isDataTablePromptOptions } from '#tui/output/validators/data-table'; +import { dataTableRowsSchema } from '#tui/output/validators/data-table/schemas'; +import type { z } from 'zod'; +import type { DataTablePromptOptions, DataTableRow } from '#tui/types'; + +const outputDataTableRowsSchema = (): z.ZodType>> => dataTableRowsSchema as z.ZodType>>; + +export type ResolvedDataTableFormArguments = { + name?: string; + options: DataTablePromptOptions; +}; + +export const parseOutputDataTableRows = (value: unknown): Array> => { + if (isNullOutputRows(value)) { + return []; + } + + const parsed = outputDataTableRowsSchema().safeParse(value); + + if (!parsed.success) { + throw new TypeError('Data table rows must be an array.'); + } + + return parsed.data; +}; + +export const resolveDataTableFormArguments = ( + optionsOrHeaders: DataTablePromptOptions | string[] = [], + rowsOrName: Array> | null | string = null, + scrollOrName: number | string = 10, + label = '', + hint = '', + required: DataTablePromptOptions['required'] = false, + validate: DataTablePromptOptions['validate'] = undefined, + transform: DataTablePromptOptions['transform'] = undefined, + filter: DataTablePromptOptions['filter'] = undefined, + name?: string, +): ResolvedDataTableFormArguments => { + if (isDataTablePromptOptions(optionsOrHeaders)) { + return { + name: dataTableStepName(rowsOrName), + options: optionsOrHeaders, + }; + } + + return { + name: parseOutputStepName(scrollOrName) ?? name, + options: { + filter, + headers: optionsOrHeaders, + hint, + message: label, + required, + rows: parseOutputDataTableRows(rowsOrName), + scroll: parseOutputScroll(scrollOrName, 10), + transform, + validate, + }, + }; +}; diff --git a/packages/tui/src/form/builder/validators/output/table.ts b/packages/tui/src/form/builder/validators/output/table.ts new file mode 100644 index 00000000..4eca758c --- /dev/null +++ b/packages/tui/src/form/builder/validators/output/table.ts @@ -0,0 +1,59 @@ +import { isNullOutputRows } from '#tui/form/builder/validators/output/common'; +import { isTableOptions, tableStepName } from '#tui/output/validators/table'; +import { tableRowsSchema } from '#tui/output/validators/table/schemas'; +import type { TableOptions } from '#tui/types'; + +export type ResolvedTableFormArguments = + | { + kind: 'options'; + name?: string; + options: TableOptions; + } + | { + headers: string[]; + kind: 'rows'; + name?: string; + rows: TableOptions['rows'] | null; + }; + +export const parseOutputTableRows = (value: unknown): TableOptions['rows'] | null => { + if (isNullOutputRows(value)) { + return null; + } + + const parsed = tableRowsSchema.safeParse(value); + + if (!parsed.success) { + throw new TypeError('Table rows must be an array.'); + } + + return parsed.data; +}; + +export const resolveTableFormArguments = (headersOrOptions: TableOptions | string[] = [], rowsOrName: TableOptions['rows'] | string | null = null, name?: string): ResolvedTableFormArguments => { + if (isTableOptions(headersOrOptions)) { + return { + kind: 'options', + name: tableStepName(rowsOrName), + options: headersOrOptions, + }; + } + + const rowsStepName = tableStepName(rowsOrName); + + if (rowsStepName !== undefined) { + return { + headers: headersOrOptions, + kind: 'rows', + name: rowsStepName, + rows: null, + }; + } + + return { + headers: headersOrOptions, + kind: 'rows', + name, + rows: parseOutputTableRows(rowsOrName), + }; +}; diff --git a/packages/tui/src/form/builder/validators/previous.ts b/packages/tui/src/form/builder/validators/previous.ts new file mode 100644 index 00000000..6d0ebed8 --- /dev/null +++ b/packages/tui/src/form/builder/validators/previous.ts @@ -0,0 +1,42 @@ +import { z } from 'zod'; + +const presentPreviousValueSchema = z.unknown().refine((value) => value !== undefined && value !== null); +const previousStringSchema = presentPreviousValueSchema.transform((value) => String(value)); +const previousNumberSchema = z.union([z.number(), z.string()]); +const previousArraySchema = (): z.ZodType => z.array(z.unknown()) as z.ZodType; +const previousBooleanSchema = z.boolean(); +const previousValueSchema = (): z.ZodType => presentPreviousValueSchema as z.ZodType; + +export const parsePreviousString = (previous: unknown, defaultValue: string): string => { + const parsed = previousStringSchema.safeParse(previous); + + return parsed.success ? parsed.data : defaultValue; +}; + +export const hasPreviousResponse = (previous: unknown): boolean => { + return presentPreviousValueSchema.safeParse(previous).success; +}; + +export const parsePreviousNumber = (previous: unknown, defaultValue: number | string): number | string => { + const parsed = previousNumberSchema.safeParse(previous); + + return parsed.success ? parsed.data : defaultValue; +}; + +export const parsePreviousArray = (previous: unknown, defaultValue: T[]): T[] => { + const parsed = previousArraySchema().safeParse(previous); + + return parsed.success ? parsed.data : defaultValue; +}; + +export const parsePreviousBoolean = (previous: unknown, defaultValue: boolean): boolean => { + const parsed = previousBooleanSchema.safeParse(previous); + + return parsed.success ? parsed.data : defaultValue; +}; + +export const parsePreviousValue = (previous: unknown, defaultValue: T): T => { + const parsed = previousValueSchema().safeParse(previous); + + return parsed.success ? parsed.data : defaultValue; +}; diff --git a/packages/tui/src/form/builder/validators/revert.ts b/packages/tui/src/form/builder/validators/revert.ts new file mode 100644 index 00000000..a942e66b --- /dev/null +++ b/packages/tui/src/form/builder/validators/revert.ts @@ -0,0 +1,8 @@ +import { z } from 'zod'; +import { FormRevertedError } from '#tui/form/builder/revert'; + +const formRevertedErrorSchema = z.instanceof(FormRevertedError); + +export const isFormRevertedError = (error: unknown): error is FormRevertedError => { + return formRevertedErrorSchema.safeParse(error).success; +}; diff --git a/packages/tui/src/form/builder/validators/status.ts b/packages/tui/src/form/builder/validators/status.ts new file mode 100644 index 00000000..dc672f82 --- /dev/null +++ b/packages/tui/src/form/builder/validators/status.ts @@ -0,0 +1,9 @@ +export { isProgressTotal, isStatusLabel, parseProgressCallback, parseStatusCallback, parseStatusLabel } from '#tui/form/builder/validators/status/common'; +export { resolveProgressFormArguments } from '#tui/form/builder/validators/status/progress'; +export type { ResolvedProgressFormArguments } from '#tui/form/builder/validators/status/progress'; +export { resolveSpinFormArguments } from '#tui/form/builder/validators/status/spin'; +export type { ResolvedSpinFormArguments } from '#tui/form/builder/validators/status/spin'; +export { isStreamSource, resolveStreamFormArguments } from '#tui/form/builder/validators/status/stream'; +export type { ResolvedStreamFormArguments } from '#tui/form/builder/validators/status/stream'; +export { resolveTaskFormArguments } from '#tui/form/builder/validators/status/task'; +export type { ResolvedTaskFormArguments } from '#tui/form/builder/validators/status/task'; diff --git a/packages/tui/src/form/builder/validators/status/common.ts b/packages/tui/src/form/builder/validators/status/common.ts new file mode 100644 index 00000000..3758f215 --- /dev/null +++ b/packages/tui/src/form/builder/validators/status/common.ts @@ -0,0 +1,50 @@ +import { z } from 'zod'; +import { functionSchema } from '#tui/validators/function'; +import type { MaybePromise } from '#tui/types'; +import type { Progress } from '#tui/status'; + +const progressTotalSchema = z.number(); +const statusLabelSchema = z.string(); +const statusCallbackSchema = (): z.ZodType<() => MaybePromise> => functionSchema<() => MaybePromise>(); + +const progressCallbackSchema = (): z.ZodType<(step: T | number, bar: Progress) => MaybePromise> => functionSchema<(step: T | number, bar: Progress) => MaybePromise>(); + +export const isProgressTotal = (value: unknown): value is number => { + return progressTotalSchema.safeParse(value).success; +}; + +export const isStatusLabel = (value: unknown): value is string => { + return statusLabelSchema.safeParse(value).success; +}; + +export const parseStatusLabel = (value: unknown): string | undefined => { + const parsed = statusLabelSchema.safeParse(value); + + return parsed.success ? parsed.data : undefined; +}; + +export const parseStatusCallback = (value: unknown): (() => MaybePromise) => { + const parsed = statusCallbackSchema().safeParse(value); + + if (!parsed.success) { + throw new Error('A status callback is required.'); + } + + return parsed.data; +}; + +export const parseProgressCallback = (value: unknown): ((step: T | number, bar: Progress) => MaybePromise) | undefined => { + const parsed = progressCallbackSchema().safeParse(value); + + return parsed.success ? parsed.data : undefined; +}; + +export const parseRequiredProgressCallback = (value: unknown): ((step: T | number, bar: Progress) => MaybePromise) => { + const parsed = progressCallbackSchema().safeParse(value); + + if (!parsed.success) { + throw new Error('A progress callback is required.'); + } + + return parsed.data; +}; diff --git a/packages/tui/src/form/builder/validators/status/progress.ts b/packages/tui/src/form/builder/validators/status/progress.ts new file mode 100644 index 00000000..77ca388c --- /dev/null +++ b/packages/tui/src/form/builder/validators/status/progress.ts @@ -0,0 +1,46 @@ +import { isProgressTotal, isStatusLabel, parseRequiredProgressCallback, parseStatusLabel } from '#tui/form/builder/validators/status/common'; +import { progressStepsArgument } from '#tui/status/progress/validators/arguments'; +import type { MaybePromise } from '#tui/types'; +import type { Progress } from '#tui/status'; + +export type ResolvedProgressFormArguments = + | { + kind: 'total'; + message?: string; + name?: string; + total: number; + } + | { + callback: (step: T | number, bar: Progress) => MaybePromise; + hint: string; + kind: 'label'; + label: string; + name?: string; + steps: Iterable | number; + }; + +export const resolveProgressFormArguments = ( + labelOrTotal: string | number, + stepsOrMessage?: Iterable | number | string, + callbackOrName?: ((step: T | number, bar: Progress) => MaybePromise) | string, + hint = '', + name?: string, +): ResolvedProgressFormArguments => { + if (isProgressTotal(labelOrTotal)) { + return { + kind: 'total', + message: isStatusLabel(stepsOrMessage) ? stepsOrMessage : undefined, + name: parseStatusLabel(callbackOrName), + total: labelOrTotal, + }; + } + + return { + callback: parseRequiredProgressCallback(callbackOrName), + hint, + kind: 'label', + label: labelOrTotal, + name, + steps: progressStepsArgument(stepsOrMessage), + }; +}; diff --git a/packages/tui/src/form/builder/validators/status/spin.ts b/packages/tui/src/form/builder/validators/status/spin.ts new file mode 100644 index 00000000..dfac6e12 --- /dev/null +++ b/packages/tui/src/form/builder/validators/status/spin.ts @@ -0,0 +1,38 @@ +import { isStatusLabel, parseStatusCallback, parseStatusLabel } from '#tui/form/builder/validators/status/common'; +import type { MaybePromise } from '#tui/types'; + +export type ResolvedSpinFormArguments = + | { + callback: () => MaybePromise; + kind: 'message'; + message: string; + name?: string; + } + | { + callback: () => MaybePromise; + kind: 'callback'; + message: string; + name?: string; + }; + +export const resolveSpinFormArguments = ( + callbackOrMessage: (() => MaybePromise) | string, + messageOrCallback: string | (() => MaybePromise) = '', + name?: string, +): ResolvedSpinFormArguments => { + if (isStatusLabel(callbackOrMessage)) { + return { + callback: parseStatusCallback(messageOrCallback), + kind: 'message', + message: callbackOrMessage, + name, + }; + } + + return { + callback: parseStatusCallback(callbackOrMessage), + kind: 'callback', + message: parseStatusLabel(messageOrCallback) ?? '', + name, + }; +}; diff --git a/packages/tui/src/form/builder/validators/status/stream.ts b/packages/tui/src/form/builder/validators/status/stream.ts new file mode 100644 index 00000000..576dcb99 --- /dev/null +++ b/packages/tui/src/form/builder/validators/status/stream.ts @@ -0,0 +1,32 @@ +import { z } from 'zod'; +import { isStatusLabel } from '#tui/form/builder/validators/status/common'; +import { asyncIterableSchema, iterableSchema } from '#tui/validators/iterable'; + +const streamSourceSchema = z.union([asyncIterableSchema(), iterableSchema()]); + +export type ResolvedStreamFormArguments = + | { + kind: 'manual'; + name?: string; + } + | { + kind: 'source'; + name?: string; + source: AsyncIterable | Iterable; + }; + +export const isStreamSource = (value: unknown): value is AsyncIterable | Iterable => { + return streamSourceSchema.safeParse(value).success; +}; + +export const resolveStreamFormArguments = (sourceOrName?: AsyncIterable | Iterable | string, name?: string): ResolvedStreamFormArguments => { + if (isStatusLabel(sourceOrName)) { + return { kind: 'manual', name: sourceOrName }; + } + + if (!isStreamSource(sourceOrName)) { + return { kind: 'manual' }; + } + + return { kind: 'source', name, source: sourceOrName }; +}; diff --git a/packages/tui/src/form/builder/validators/status/task.ts b/packages/tui/src/form/builder/validators/status/task.ts new file mode 100644 index 00000000..4ca5745c --- /dev/null +++ b/packages/tui/src/form/builder/validators/status/task.ts @@ -0,0 +1,48 @@ +import { isStatusLabel, parseStatusLabel } from '#tui/form/builder/validators/status/common'; +import { parseTaskCallback } from '#tui/status/task/validators/definition'; +import type { MaybePromise } from '#tui/types'; +import type { TaskDefinition } from '#tui/status'; +import type { Logger } from '#tui/status/task/logger'; + +export type ResolvedTaskFormArguments = + | { + callback: (logger: Logger) => MaybePromise; + keepSummary: boolean; + kind: 'label'; + label: string; + limit: number; + name?: string; + subLabel: string; + } + | { + definition: TaskDefinition; + kind: 'definition'; + name?: string; + }; + +export const resolveTaskFormArguments = ( + definitionOrLabel: TaskDefinition | string, + callbackOrName?: ((logger: Logger) => MaybePromise) | string, + limit = 10, + keepSummary = false, + subLabel = '', + name?: string, +): ResolvedTaskFormArguments => { + if (isStatusLabel(definitionOrLabel)) { + return { + callback: parseTaskCallback(callbackOrName), + keepSummary, + kind: 'label', + label: definitionOrLabel, + limit, + name, + subLabel, + }; + } + + return { + definition: definitionOrLabel, + kind: 'definition', + name: parseStatusLabel(callbackOrName), + }; +}; diff --git a/packages/tui/src/form/types.ts b/packages/tui/src/form/types.ts new file mode 100644 index 00000000..a5941ca5 --- /dev/null +++ b/packages/tui/src/form/types.ts @@ -0,0 +1,14 @@ +import type { MaybePromise } from '#tui/types'; + +export interface FormResponses extends Array { + [name: string]: unknown; +} + +export type FormStepCondition = boolean | ((responses: FormResponses) => MaybePromise); + +export type FormStep = { + condition: FormStepCondition; + ignoreWhenReverting: boolean; + name?: string; + run: (responses: FormResponses, previous: unknown, name?: string) => MaybePromise; +}; diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts new file mode 100644 index 00000000..3be5aeaf --- /dev/null +++ b/packages/tui/src/index.ts @@ -0,0 +1,83 @@ +export { configurePrompts, createMemoryOutput, createScriptedInput, promptEnvironment, withPromptEnvironment } from '#tui/environment'; +export type { MemoryOutput, RawKeyInput } from '#tui/environment'; +export { FormBuilder, form } from '#tui/form'; +export type { FormResponses, FormStep, FormStepCondition } from '#tui/form'; +export { Key, keyFromEvent, oneOf } from '#tui/key'; +export type { KeyboardEventLike, KeyName, KeyValue } from '#tui/key'; +export { renderOpenTuiTextFrame } from '#tui/opentui'; +export type { OpenTuiFrame } from '#tui/opentui'; +export { confirm, select, multiselect, suggest, search, multisearch, autocomplete, pause } from '#tui/prompts/choices'; +export type { SuggestOptions } from '#tui/prompts/choices'; +export { number, password, text, textarea } from '#tui/prompts/basic'; +export { + alert, + clear, + commandExists, + dataTable, + datatable, + error, + executeNotificationCommand, + grid, + info, + intro, + note, + notificationCommand, + notificationCommands, + notify, + notifyForPlatform, + outro, + table, + title, + warning, +} from '#tui/output'; +export type { NoteType, NotificationCommand, NotificationOptions, NotificationPlatform, NotificationRuntime } from '#tui/output'; +export { cancelUsing, fallbackUsing, fallbackWhen, PromptValidationError, validateUsing } from '#tui/prompt'; +export type { PromptCancelHandler, PromptFallbackCondition, PromptFallbackHandler, PromptFallbackKind, PromptGlobalValidator } from '#tui/prompt'; +export { Logger, Progress, Stream, progress, spin, stream, task } from '#tui/status'; +export type { ProgressSignalTarget, TaskDefinition } from '#tui/status'; +export { + backgroundColor, + clearTerminal, + cursorToStart, + eraseLine, + erasePreviousLines, + foregroundColor, + hideCursor, + setTerminalTitle, + showCursor, + supportsTrueColor, + terminalSize, +} from '#tui/terminal'; +export type { TerminalSize } from '#tui/terminal'; +export { applyTypedKey, readTypedValue } from '#tui/typed-value'; +export type { TypedValueOptions, TypedValueReadResult, TypedValueState } from '#tui/typed-value'; +export { parseAnsiSegments, parseAnsiText, truncate, visibleWidth, wrap } from '#tui/strings'; +export type { AnsiSegment } from '#tui/strings'; +export type { + BasePromptOptions, + Choice, + ChoiceInput, + ChoiceOptions, + ConfirmPromptOptions, + DataTableObjectRow, + DataTablePromptOptions, + DataTableRow, + MaybePromise, + MultiSearchPromptOptions, + MultiSelectPromptOptions, + NumberPromptOptions, + PromptEnvironment, + PromptInfo, + PromptInput, + PromptOutput, + PromptValue, + SearchPromptOptions, + SelectPromptOptions, + StatusOptions, + TableCell, + TableOptions, + TextareaPromptOptions, + TextPromptOptions, + ValidationResult, + Validator, +} from '#tui/types'; diff --git a/packages/tui/src/key.ts b/packages/tui/src/key.ts new file mode 100644 index 00000000..44382ce1 --- /dev/null +++ b/packages/tui/src/key.ts @@ -0,0 +1,4 @@ +export { Key } from '#tui/key/constants'; +export { keyFromEvent } from '#tui/key/event'; +export { oneOf } from '#tui/key/match'; +export type { KeyboardEventLike, KeyName, KeyValue } from '#tui/key/types'; diff --git a/packages/tui/src/key/constants.ts b/packages/tui/src/key/constants.ts new file mode 100644 index 00000000..d24fa895 --- /dev/null +++ b/packages/tui/src/key/constants.ts @@ -0,0 +1,34 @@ +export const Key = { + up: '\u001B[A', + shiftUp: '\u001B[1;2A', + pageUp: '\u001B[5~', + down: '\u001B[B', + shiftDown: '\u001B[1;2B', + pageDown: '\u001B[6~', + right: '\u001B[C', + left: '\u001B[D', + upArrow: '\u001BOA', + downArrow: '\u001BOB', + rightArrow: '\u001BOC', + leftArrow: '\u001BOD', + escape: '\u001B', + delete: '\u001B[3~', + backspace: '\u007F', + enter: '\n', + space: ' ', + tab: '\t', + shiftTab: '\u001B[Z', + home: ['\u001B[1~', '\u001BOH', '\u001B[H', '\u001B[7~'], + end: ['\u001B[4~', '\u001BOF', '\u001B[F', '\u001B[8~'], + ctrlC: '\u0003', + ctrlP: '\u0010', + ctrlN: '\u000E', + ctrlF: '\u0006', + ctrlB: '\u0002', + ctrlH: '\u0008', + ctrlA: '\u0001', + ctrlD: '\u0004', + ctrlE: '\u0005', + ctrlU: '\u0015', + optionBackspace: '\u001B\u007F', +} as const; diff --git a/packages/tui/src/key/event.ts b/packages/tui/src/key/event.ts new file mode 100644 index 00000000..c89e727c --- /dev/null +++ b/packages/tui/src/key/event.ts @@ -0,0 +1,59 @@ +import { Key } from '#tui/key/constants'; +import { ctrlKeyMap, namedKeyMap, shiftedKeyMap } from '#tui/key/event/maps'; +import { firstKeyValue, keyValueFromName } from '#tui/key/event/value'; +import { parseKeyboardEvent } from '#tui/key/validators/event'; +import type { KeyboardEventLike, KeyName } from '#tui/key/types'; + +export const keyFromEvent = (event: KeyboardEventLike): KeyName | string => { + const parsedEvent = parseKeyboardEvent(event); + + if (parsedEvent.ctrl && parsedEvent.name) { + const key = ctrlKeyMap[parsedEvent.name.toLowerCase()]; + + if (key !== undefined) { + return key; + } + } + + if (parsedEvent.meta && parsedEvent.name === 'backspace') { + return Key.optionBackspace; + } + + if (parsedEvent.shift && parsedEvent.name?.toLowerCase() === 'tab') { + return Key.shiftTab; + } + + if (parsedEvent.shift && parsedEvent.name) { + const key = shiftedKeyMap[parsedEvent.name.toLowerCase()]; + + if (key !== undefined) { + return key; + } + } + + if (parsedEvent.name) { + const key = namedKeyMap[parsedEvent.name.toLowerCase()]; + + if (key !== undefined) { + return key; + } + } + + if (parsedEvent.name) { + const key = keyValueFromName(parsedEvent.name); + + if (key !== undefined) { + return firstKeyValue(key); + } + } + + if (parsedEvent.sequence === ' ') { + return Key.space; + } + + if (parsedEvent.sequence === '\r' || parsedEvent.sequence === '\n') { + return Key.enter; + } + + return parsedEvent.name ?? parsedEvent.sequence ?? ''; +}; diff --git a/packages/tui/src/key/event/maps.ts b/packages/tui/src/key/event/maps.ts new file mode 100644 index 00000000..da2aa2fd --- /dev/null +++ b/packages/tui/src/key/event/maps.ts @@ -0,0 +1,26 @@ +import { Key } from '#tui/key/constants'; +import type { KeyName } from '#tui/key/types'; + +export const ctrlKeyMap: Record = { + a: Key.ctrlA, + b: Key.ctrlB, + c: Key.ctrlC, + d: Key.ctrlD, + e: Key.ctrlE, + f: Key.ctrlF, + h: Key.ctrlH, + n: Key.ctrlN, + p: Key.ctrlP, + u: Key.ctrlU, +}; + +export const namedKeyMap: Record = { + pagedown: Key.pageDown, + pageup: Key.pageUp, + return: Key.enter, +}; + +export const shiftedKeyMap: Record = { + down: Key.shiftDown, + up: Key.shiftUp, +}; diff --git a/packages/tui/src/key/event/value.ts b/packages/tui/src/key/event/value.ts new file mode 100644 index 00000000..b6fb2f2c --- /dev/null +++ b/packages/tui/src/key/event/value.ts @@ -0,0 +1,15 @@ +import { Key } from '#tui/key/constants'; +import { parseFirstKeyValue, parseKeyLookupName } from '#tui/key/validators/value'; +import type { KeyValue } from '#tui/key/types'; + +export const keyValueFromName = (name: string): KeyValue | undefined => { + const keyName = parseKeyLookupName(name); + + if (keyName === undefined) { + return undefined; + } + + return Key[keyName]; +}; + +export const firstKeyValue = (key: KeyValue): string => parseFirstKeyValue(key); diff --git a/packages/tui/src/key/match.ts b/packages/tui/src/key/match.ts new file mode 100644 index 00000000..a5204feb --- /dev/null +++ b/packages/tui/src/key/match.ts @@ -0,0 +1,6 @@ +import { parseKeyMatchValues } from '#tui/key/validators/match'; +import type { KeyValue } from '#tui/key/types'; + +export const oneOf = (keys: KeyValue[], match: string): string | undefined => { + return parseKeyMatchValues(keys).includes(match) ? match : undefined; +}; diff --git a/packages/tui/src/key/types.ts b/packages/tui/src/key/types.ts new file mode 100644 index 00000000..19091507 --- /dev/null +++ b/packages/tui/src/key/types.ts @@ -0,0 +1,13 @@ +import type { Key } from '#tui/key/constants'; + +export type KeyValue = string | readonly string[]; + +export type KeyName = Extract<(typeof Key)[keyof typeof Key], string>; + +export type KeyboardEventLike = { + name?: string; + sequence?: string; + ctrl?: boolean; + meta?: boolean; + shift?: boolean; +}; diff --git a/packages/tui/src/key/validators/event.ts b/packages/tui/src/key/validators/event.ts new file mode 100644 index 00000000..beb73bfe --- /dev/null +++ b/packages/tui/src/key/validators/event.ts @@ -0,0 +1,18 @@ +import { z } from 'zod'; +import type { KeyboardEventLike } from '#tui/key/types'; + +const keyboardEventSchema = z + .object({ + ctrl: z.boolean().optional(), + meta: z.boolean().optional(), + name: z.string().optional(), + sequence: z.string().optional(), + shift: z.boolean().optional(), + }) + .catch({}); + +export const parseKeyboardEvent = (event: unknown): KeyboardEventLike => { + const parsed = keyboardEventSchema.safeParse(event); + + return parsed.success ? parsed.data : {}; +}; diff --git a/packages/tui/src/key/validators/match.ts b/packages/tui/src/key/validators/match.ts new file mode 100644 index 00000000..2d70a9ec --- /dev/null +++ b/packages/tui/src/key/validators/match.ts @@ -0,0 +1,16 @@ +import { z } from 'zod'; +import type { KeyValue } from '#tui/key/types'; + +const keyValueSchema = z.union([z.string().transform((value) => [value]), z.array(z.string()).transform((values) => [...values])]); + +const keyValuesSchema = z.array(keyValueSchema).transform((values) => values.flat()); + +export const parseKeyMatchValues = (values: KeyValue[]): string[] => { + const parsed = keyValuesSchema.safeParse(values); + + if (!parsed.success) { + throw new TypeError('Key match values must be strings or string arrays.'); + } + + return parsed.data; +}; diff --git a/packages/tui/src/key/validators/value.ts b/packages/tui/src/key/validators/value.ts new file mode 100644 index 00000000..bcb879e2 --- /dev/null +++ b/packages/tui/src/key/validators/value.ts @@ -0,0 +1,26 @@ +import { z } from 'zod'; +import { Key } from '#tui/key/constants'; +import type { KeyValue } from '#tui/key/types'; + +type KeyLookupName = keyof typeof Key; + +const keyLookupNameSchema = z.enum(Object.keys(Key) as [KeyLookupName, ...KeyLookupName[]]); +const keyValueListSchema = z.array(z.string()).readonly(); + +export const isKeyValueList = (value: KeyValue): value is readonly string[] => { + return keyValueListSchema.safeParse(value).success; +}; + +export const parseKnownKeyValues = (): string[] => { + return Object.values(Key).flatMap((value) => (isKeyValueList(value) ? [...value] : [value])); +}; + +export const parseFirstKeyValue = (value: KeyValue): string => { + return isKeyValueList(value) ? value[0] : value; +}; + +export const parseKeyLookupName = (value: unknown): KeyLookupName | undefined => { + const parsed = keyLookupNameSchema.safeParse(value); + + return parsed.success ? parsed.data : undefined; +}; diff --git a/packages/tui/src/opentui.ts b/packages/tui/src/opentui.ts new file mode 100644 index 00000000..5858d489 --- /dev/null +++ b/packages/tui/src/opentui.ts @@ -0,0 +1,22 @@ +export type OpenTuiFrame = { + text: string; +}; + +export const renderOpenTuiTextFrame = async (text: string, size: { width?: number; height?: number } = {}): Promise => { + const [{ Text }, { createTestRenderer }] = await Promise.all([import('@opentui/core'), import('@opentui/core/testing')]); + + const { renderer, renderOnce, captureCharFrame } = await createTestRenderer({ + width: size.width ?? 80, + height: size.height ?? 24, + }); + + try { + renderer.root.add(Text({ content: text })); + + await renderOnce(); + + return { text: captureCharFrame() }; + } finally { + renderer.destroy(); + } +}; diff --git a/packages/tui/src/output.ts b/packages/tui/src/output.ts new file mode 100644 index 00000000..db10a4f6 --- /dev/null +++ b/packages/tui/src/output.ts @@ -0,0 +1,7 @@ +export { alert, error, info, intro, note, outro, warning } from '#tui/output/notes'; +export type { NoteType } from '#tui/output/notes'; +export { commandExists, executeNotificationCommand, notificationCommand, notificationCommands, notify, notifyForPlatform } from '#tui/output/notify'; +export type { NotificationCommand, NotificationOptions, NotificationPlatform, NotificationRuntime } from '#tui/output/notify'; +export { datatable } from '#tui/output/data-table'; +export { dataTable, grid, table } from '#tui/output/table'; +export { clear, title } from '#tui/output/terminal'; diff --git a/packages/tui/src/output/data-table.ts b/packages/tui/src/output/data-table.ts new file mode 100644 index 00000000..db43acf9 --- /dev/null +++ b/packages/tui/src/output/data-table.ts @@ -0,0 +1,35 @@ +import { normalizeDataTablePromptOptions } from '#tui/output/data-table/options'; +import { runDataTablePrompt } from '#tui/output/data-table/run'; +import { parseDataTablePromptOptions } from '#tui/output/validators/data-table'; +import type { DataTablePromptOptions, DataTableRow } from '#tui/types'; + +export function datatable(options: DataTablePromptOptions): Promise; + +export function datatable( + headers?: string[], + rows?: Array> | null, + scroll?: number, + label?: string, + hint?: string, + required?: DataTablePromptOptions['required'], + validate?: DataTablePromptOptions['validate'], + transform?: DataTablePromptOptions['transform'], + filter?: DataTablePromptOptions['filter'], +): Promise; + +export async function datatable( + optionsOrHeaders: DataTablePromptOptions | string[] = [], + rows: Array> | null = null, + scroll = 10, + label = '', + hint = '', + required: DataTablePromptOptions['required'] = false, + validate: DataTablePromptOptions['validate'] = undefined, + transform: DataTablePromptOptions['transform'] = undefined, + filter: DataTablePromptOptions['filter'] = undefined, +): Promise { + const parsedOptions = parseDataTablePromptOptions(optionsOrHeaders, rows, scroll, label, hint, required, validate, transform, filter); + const options = normalizeDataTablePromptOptions(parsedOptions); + + return runDataTablePrompt(options); +} diff --git a/packages/tui/src/output/data-table/body/active.ts b/packages/tui/src/output/data-table/body/active.ts new file mode 100644 index 00000000..f5b3fb29 --- /dev/null +++ b/packages/tui/src/output/data-table/body/active.ts @@ -0,0 +1,38 @@ +import { renderTable } from '#tui/theme'; +import { expandMultilineDataTableRows } from '#tui/output/data-table/multiline'; +import { dataTableRowCells } from '#tui/output/data-table/rows'; +import { renderDataTableHeaders } from '#tui/output/data-table/body/headers'; +import { renderScrollableDataTable } from '#tui/output/data-table/scrollbar'; +import { dataTableRowWindow } from '#tui/output/data-table/selection'; +import { fixedVisualDataTableRows } from '#tui/output/data-table/visual-window'; +import { fitDataTableColumns } from '#tui/output/data-table/widths'; +import type { DataTableBodyOptions } from '#tui/output/data-table/body/types'; + +export const renderDataTableBody = (options: DataTableBodyOptions): string[] => { + if (options.rows.length === 0) { + return [renderTable([], [['No results found.']])]; + } + + const window = dataTableRowWindow(options.rows.length, options.selected, options.scroll); + const visibleCells = options.rows.slice(window.start, window.end).map(({ row }) => dataTableRowCells(options.headers, row)); + const allCells = options.allRows.map((row) => dataTableRowCells(options.headers, row)); + const fitted = fitDataTableColumns({ allRows: allCells, headers: options.headers, rows: visibleCells }); + const visualRows = fixedVisualDataTableRows(expandMultilineDataTableRows(renderSelectedDataTableRows(fitted.rows, window.start, options.selected)), options.scroll); + const lines = [renderScrollableDataTable(renderDataTableHeaders(fitted.headers), visualRows, window.start, window.end - window.start, options.rows.length)]; + + if (window.end - window.start < options.rows.length) { + const suffix = options.query.length > 0 ? ' results' : ''; + + lines.push(` Viewing ${window.start + 1}-${window.end} of ${options.rows.length}${suffix}`); + } + + return lines; +}; + +const renderSelectedDataTableRows = (rows: string[][], start: number, selected: number): string[][] => { + return rows.map((row, offset) => { + const index = start + offset; + + return [index === selected ? '›' : ' ', ...row]; + }); +}; diff --git a/packages/tui/src/output/data-table/body/cancelled.ts b/packages/tui/src/output/data-table/body/cancelled.ts new file mode 100644 index 00000000..db2c346d --- /dev/null +++ b/packages/tui/src/output/data-table/body/cancelled.ts @@ -0,0 +1,20 @@ +import { expandMultilineDataTableRows } from '#tui/output/data-table/multiline'; +import { dataTableRowCells } from '#tui/output/data-table/rows'; +import { dim, strikethrough } from '#tui/theme/styles'; +import type { VisibleDataTableRow } from '#tui/output/data-table/types'; + +export const renderCancelledDataTableRows = (headers: string[], rows: Array>, selected: number): string[][] => { + return rows.flatMap(({ row }, index) => { + const expandedRows = expandMultilineDataTableRows([[index === selected ? '›' : ' ', ...dataTableRowCells(headers, row)]]); + + return expandedRows.map((expandedRow) => { + return expandedRow.map((cell, columnIndex) => { + if (columnIndex === 0) { + return cell; + } + + return cell.length > 0 ? dim(strikethrough(cell)) : ''; + }); + }); + }); +}; diff --git a/packages/tui/src/output/data-table/body/headers.ts b/packages/tui/src/output/data-table/body/headers.ts new file mode 100644 index 00000000..2d65c802 --- /dev/null +++ b/packages/tui/src/output/data-table/body/headers.ts @@ -0,0 +1 @@ +export const renderDataTableHeaders = (headers: string[]): string[] => (headers.length > 0 ? ['', ...headers] : []); diff --git a/packages/tui/src/output/data-table/body/submitted.ts b/packages/tui/src/output/data-table/body/submitted.ts new file mode 100644 index 00000000..6e657049 --- /dev/null +++ b/packages/tui/src/output/data-table/body/submitted.ts @@ -0,0 +1,8 @@ +import { dataTableRowCells } from '#tui/output/data-table/rows'; +import type { VisibleDataTableRow } from '#tui/output/data-table/types'; + +export const renderSubmittedDataTableRow = (headers: string[], rows: Array>, selected: number): string | null => { + const row = rows[selected]; + + return row ? dataTableRowCells(headers, row.row).join(', ') : null; +}; diff --git a/packages/tui/src/output/data-table/body/types.ts b/packages/tui/src/output/data-table/body/types.ts new file mode 100644 index 00000000..2ce3ad5b --- /dev/null +++ b/packages/tui/src/output/data-table/body/types.ts @@ -0,0 +1,11 @@ +import type { VisibleDataTableRow } from '#tui/output/data-table/types'; +import type { DataTableRow } from '#tui/types'; + +export type DataTableBodyOptions = { + allRows: Array>; + headers: string[]; + query: string; + rows: Array>; + scroll?: number; + selected: number; +}; diff --git a/packages/tui/src/output/data-table/keys.ts b/packages/tui/src/output/data-table/keys.ts new file mode 100644 index 00000000..c9280adc --- /dev/null +++ b/packages/tui/src/output/data-table/keys.ts @@ -0,0 +1,33 @@ +import { Key, oneOf } from '#tui/key'; + +export type DataTableNavigationAction = 'first' | 'last' | 'next' | 'page-next' | 'page-previous' | 'previous'; + +export const dataTableNavigationAction = (key: string): DataTableNavigationAction | null => { + if (key === Key.down || key === Key.downArrow || key === Key.ctrlN || key === Key.tab) { + return 'next'; + } + + if (key === Key.up || key === Key.upArrow || key === Key.ctrlP || key === Key.shiftTab) { + return 'previous'; + } + + if (key === Key.pageDown) { + return 'page-next'; + } + + if (key === Key.pageUp) { + return 'page-previous'; + } + + if (oneOf([Key.home, Key.ctrlA], key)) { + return 'first'; + } + + if (oneOf([Key.end, Key.ctrlE], key)) { + return 'last'; + } + + return null; +}; + +export const startsDataTableSearch = (key: string): boolean => key === '/'; diff --git a/packages/tui/src/output/data-table/multiline.ts b/packages/tui/src/output/data-table/multiline.ts new file mode 100644 index 00000000..6da9248d --- /dev/null +++ b/packages/tui/src/output/data-table/multiline.ts @@ -0,0 +1,18 @@ +export const splitMultilineDataTableCell = (cell: string): string[] => cell.split(/\r?\n/u); + +export const expandMultilineDataTableRows = (rows: string[][]): string[][] => { + return rows.flatMap((row) => { + const columns = row.map(splitMultilineDataTableCell); + const height = Math.max(1, ...columns.map((lines) => lines.length)); + + return Array.from({ length: height }, (_, lineIndex) => { + return columns.map((lines, columnIndex) => { + if (columnIndex === 0 && lineIndex > 0) { + return ''; + } + + return lines[lineIndex] ?? ''; + }); + }); + }); +}; diff --git a/packages/tui/src/output/data-table/navigation.ts b/packages/tui/src/output/data-table/navigation.ts new file mode 100644 index 00000000..d25bf1ef --- /dev/null +++ b/packages/tui/src/output/data-table/navigation.ts @@ -0,0 +1,53 @@ +import { parseScrollSize } from '#tui/concerns/validators/scroll'; +import { parseDataTableRowTotal, parseDataTableSelectedIndex } from '#tui/output/data-table/validators/selection'; +import type { DataTableNavigationAction } from '#tui/output/data-table/keys'; + +export const nextDataTableSelection = (selected: number, total: number): number => { + const rowTotal = parseDataTableRowTotal(total); + + return rowTotal === 0 ? 0 : (parseDataTableSelectedIndex(selected, rowTotal) + 1) % rowTotal; +}; + +export const previousDataTableSelection = (selected: number, total: number): number => { + const rowTotal = parseDataTableRowTotal(total); + + return rowTotal === 0 ? 0 : (parseDataTableSelectedIndex(selected, rowTotal) - 1 + rowTotal) % rowTotal; +}; + +export const pageDataTableSelection = (selected: number, total: number, direction: 1 | -1, scroll?: number): number => { + const rowTotal = parseDataTableRowTotal(total); + + if (rowTotal === 0) { + return 0; + } + + return Math.max(0, Math.min(rowTotal - 1, parseDataTableSelectedIndex(selected, rowTotal) + parseScrollSize(scroll, 10) * direction)); +}; + +export const firstDataTableSelection = (): number => 0; + +export const lastDataTableSelection = (total: number): number => Math.max(0, parseDataTableRowTotal(total) - 1); + +export const moveDataTableSelection = (action: DataTableNavigationAction, selected: number, total: number, scroll?: number): number => { + if (action === 'next') { + return nextDataTableSelection(selected, total); + } + + if (action === 'previous') { + return previousDataTableSelection(selected, total); + } + + if (action === 'page-next') { + return pageDataTableSelection(selected, total, 1, scroll); + } + + if (action === 'page-previous') { + return pageDataTableSelection(selected, total, -1, scroll); + } + + if (action === 'first') { + return firstDataTableSelection(); + } + + return lastDataTableSelection(total); +}; diff --git a/packages/tui/src/output/data-table/options.ts b/packages/tui/src/output/data-table/options.ts new file mode 100644 index 00000000..e6911a8c --- /dev/null +++ b/packages/tui/src/output/data-table/options.ts @@ -0,0 +1,45 @@ +import { parseDataTableDefault, parseInitialDataTableRow } from '#tui/output/validators/data-table'; +import { dataTableRowValue } from '#tui/output/data-table/rows'; +import { hasPromptDefault } from '#tui/validators/default'; +import type { DataTablePromptOptions, DataTableRow } from '#tui/types'; + +export type NormalizedDataTablePromptOptions = DataTablePromptOptions & { + hasDefault: boolean; +}; + +export const normalizeDataTablePromptOptions = (options: DataTablePromptOptions): NormalizedDataTablePromptOptions => ({ + ...options, + hasDefault: hasPromptDefault(options), +}); + +export const transformDataTableValue = async (options: Pick, 'transform'>, value: T | number): Promise => { + return options.transform ? options.transform(value) : value; +}; + +export const preserveDataTableRetryDefault = (options: NormalizedDataTablePromptOptions, value: T | number): void => { + options.default = value; + options.hasDefault = true; +}; + +export const initialDataTableDefault = (rows: Array>): T | number => { + return dataTableRowValue(parseInitialDataTableRow(rows), 0); +}; + +export const dataTableValidationOptions = async (options: NormalizedDataTablePromptOptions, useInitialDefault = false): Promise> => { + const defaultValue = options.hasDefault ? parseDataTableDefault(options.default) : useInitialDefault ? initialDataTableDefault(options.rows) : undefined; + + let transformedDefault = defaultValue; + + if (defaultValue !== undefined) { + try { + transformedDefault = await transformDataTableValue(options, defaultValue); + } catch { + transformedDefault = defaultValue; + } + } + + return { + ...options, + default: transformedDefault, + }; +}; diff --git a/packages/tui/src/output/data-table/read.ts b/packages/tui/src/output/data-table/read.ts new file mode 100644 index 00000000..347fc08b --- /dev/null +++ b/packages/tui/src/output/data-table/read.ts @@ -0,0 +1,15 @@ +import { promptEnvironment } from '#tui/environment'; +import { readDataTableFallbackSelection } from '#tui/output/data-table/reader/fallback'; +import { readDataTableSelectionInteractive } from '#tui/output/data-table/reader/interactive'; +import type { DataTableReadOptions } from '#tui/output/data-table/reader/types'; +import type { DataTableSelectionReadResult } from '#tui/output/data-table/types'; + +export const readDataTableSelection = async (options: DataTableReadOptions, headers: string[]): Promise> => { + const environment = promptEnvironment(); + + if (!environment.input.readKey) { + return readDataTableFallbackSelection(options, headers); + } + + return readDataTableSelectionInteractive(environment.input.readKey, options, headers); +}; diff --git a/packages/tui/src/output/data-table/reader/cancel.ts b/packages/tui/src/output/data-table/reader/cancel.ts new file mode 100644 index 00000000..c1eaa854 --- /dev/null +++ b/packages/tui/src/output/data-table/reader/cancel.ts @@ -0,0 +1,43 @@ +import { cancelPrompt } from '#tui/prompt'; +import { eraseRenderedFrame } from '#tui/status/frame'; +import { renderCancelledDataTableFrame } from '#tui/output/data-table/render'; +import { dataTableRowValue } from '#tui/output/data-table/rows'; +import { selectedDataTableValue } from '#tui/output/data-table/reader/result'; +import type { DataTableReadOptions } from '#tui/output/data-table/reader/types'; +import type { DataTableSearchMode } from '#tui/output/data-table/search'; +import type { DataTableSelectionReadResult, VisibleDataTableRow } from '#tui/output/data-table/types'; + +const cancelledDataTableValue = (options: DataTableReadOptions, rows: Array>, selected: number): T | number => { + if (rows[selected] !== undefined) { + return selectedDataTableValue(rows, selected); + } + + if (options.hasDefault === true && options.default !== undefined) { + return options.default; + } + + return dataTableRowValue(options.rows[0], 0); +}; + +export const cancelDataTableSelection = async ( + options: DataTableReadOptions, + headers: string[], + rows: Array>, + selected: number, + frame: string, + mode: DataTableSearchMode, + query: string, +): Promise> => { + const value = cancelledDataTableValue(options, rows, selected); + + eraseRenderedFrame(frame); + renderCancelledDataTableFrame(options.message, headers, rows, selected, mode, query); + + return { + cancelled: true, + rows, + selected, + submitted: false, + value: await cancelPrompt(value), + }; +}; diff --git a/packages/tui/src/output/data-table/reader/fallback.ts b/packages/tui/src/output/data-table/reader/fallback.ts new file mode 100644 index 00000000..dfdd2fff --- /dev/null +++ b/packages/tui/src/output/data-table/reader/fallback.ts @@ -0,0 +1,21 @@ +import { ask } from '#tui/prompt/ask'; +import { promptEnvironment } from '#tui/environment'; +import { visibleDataTableRows } from '#tui/output/data-table/rows'; +import { assertSelectedDataTableRow, dataTableSelectionResult, initialDataTableSelection } from '#tui/output/data-table/reader/result'; +import type { DataTableReadOptions } from '#tui/output/data-table/reader/types'; +import type { DataTableSelectionReadResult } from '#tui/output/data-table/types'; + +export const readDataTableFallbackSelection = async (options: DataTableReadOptions, headers: string[]): Promise> => { + const environment = promptEnvironment(); + + const answer = environment.input.readLine ? await ask(options.message, options.hint) : ''; + + const query = answer.trim(); + + const rows = visibleDataTableRows(options, headers, query); + const selected = query === '' ? initialDataTableSelection(rows, options.default, options.hasDefault) : 0; + + assertSelectedDataTableRow(rows, selected, ''); + + return dataTableSelectionResult(rows, selected, false); +}; diff --git a/packages/tui/src/output/data-table/reader/interactive.ts b/packages/tui/src/output/data-table/reader/interactive.ts new file mode 100644 index 00000000..3a0e71c2 --- /dev/null +++ b/packages/tui/src/output/data-table/reader/interactive.ts @@ -0,0 +1,58 @@ +import { Key } from '#tui/key'; +import { dataTableNavigationAction, startsDataTableSearch } from '#tui/output/data-table/keys'; +import { cancelDataTableSelection } from '#tui/output/data-table/reader/cancel'; +import { assertSelectedDataTableRow, dataTableSelectionResult } from '#tui/output/data-table/reader/result'; +import { createDataTableReaderSession } from '#tui/output/data-table/reader/session'; +import type { DataTableReadOptions } from '#tui/output/data-table/reader/types'; +import type { DataTableSelectionReadResult } from '#tui/output/data-table/types'; +import type { PromptInput } from '#tui/types'; + +export const readDataTableSelectionInteractive = async ( + readKey: NonNullable, + options: DataTableReadOptions, + headers: string[], +): Promise> => { + const session = createDataTableReaderSession(options, headers); + + session.render(); + + while (true) { + const key = await readKey(); + + if (key === null) { + const currentRows = session.rows(); + + assertSelectedDataTableRow(currentRows, session.selected(), session.frame()); + + return dataTableSelectionResult(currentRows, session.selected(), false, false, session.frame()); + } + + const rows = session.rows(); + + if (key === Key.ctrlC) { + return cancelDataTableSelection(options, headers, rows, session.selected(), session.frame(), session.mode(), session.query()); + } + + if (session.applySearchKey(key)) { + continue; + } + + if (startsDataTableSearch(key)) { + session.beginSearch(); + continue; + } + + const action = dataTableNavigationAction(key); + + if (action !== null) { + session.moveSelection(action); + continue; + } + + if (key === Key.enter) { + assertSelectedDataTableRow(rows, session.selected(), session.frame()); + + return dataTableSelectionResult(rows, session.selected(), true, false, session.frame()); + } + } +}; diff --git a/packages/tui/src/output/data-table/reader/result.ts b/packages/tui/src/output/data-table/reader/result.ts new file mode 100644 index 00000000..ec3c52c6 --- /dev/null +++ b/packages/tui/src/output/data-table/reader/result.ts @@ -0,0 +1,48 @@ +import { PromptValidationError } from '#tui/prompt'; +import { choiceValueEquals } from '#tui/concerns/choices'; +import { eraseRenderedFrame } from '#tui/status/frame'; +import { dataTableRowValue } from '#tui/output/data-table/rows'; +import type { DataTableSelectionReadResult, VisibleDataTableRow } from '#tui/output/data-table/types'; + +const invalidRow = (): PromptValidationError => new PromptValidationError('Please select a valid row.'); + +export const selectedDataTableValue = (rows: Array>, selected: number): T | number => { + const selectedRow = rows[selected]; + + if (!selectedRow) { + throw invalidRow(); + } + + return dataTableRowValue(selectedRow.row, selectedRow.index); +}; + +export const initialDataTableSelection = (rows: Array>, defaultValue: T | number | undefined, hasDefault = false): number => { + if (!hasDefault) { + return 0; + } + + const selected = rows.findIndex(({ index, row }) => choiceValueEquals(dataTableRowValue(row, index), defaultValue)); + + return Math.max(0, selected); +}; + +export const dataTableSelectionResult = (rows: Array>, selected: number, submitted: boolean, cancelled = false, frame?: string): DataTableSelectionReadResult => ({ + cancelled, + frame, + rows, + selected, + submitted, + value: selectedDataTableValue(rows, selected), +}); + +export const assertSelectedDataTableRow = (rows: Array>, selected: number, frame: string): void => { + if (rows[selected]) { + return; + } + + if (frame.length > 0) { + eraseRenderedFrame(frame); + } + + throw invalidRow(); +}; diff --git a/packages/tui/src/output/data-table/reader/session.ts b/packages/tui/src/output/data-table/reader/session.ts new file mode 100644 index 00000000..c8e7e541 --- /dev/null +++ b/packages/tui/src/output/data-table/reader/session.ts @@ -0,0 +1,62 @@ +import { renderDataTableReaderSessionFrame } from '#tui/output/data-table/reader/session/render'; +import { createDataTableReaderState } from '#tui/output/data-table/reader/state'; +import type { DataTableNavigationAction } from '#tui/output/data-table/keys'; +import type { DataTableReadOptions } from '#tui/output/data-table/reader/types'; +import type { DataTableSearchMode } from '#tui/output/data-table/search'; +import type { VisibleDataTableRow } from '#tui/output/data-table/types'; + +export type DataTableReaderSession = { + applySearchKey(key: string): boolean; + beginSearch(): void; + frame(): string; + mode(): DataTableSearchMode; + moveSelection(action: DataTableNavigationAction): void; + query(): string; + render(): void; + rows(): Array>; + selected(): number; +}; + +export const createDataTableReaderSession = (options: DataTableReadOptions, headers: string[]): DataTableReaderSession => { + const state = createDataTableReaderState(options, headers); + + let frame = ''; + + function render(): void { + frame = renderDataTableReaderSessionFrame(options, headers, state, frame); + } + + return { + applySearchKey(key) { + if (!state.applySearchKey(key)) { + return false; + } + + render(); + + return true; + }, + beginSearch() { + state.beginSearch(); + render(); + }, + frame() { + return frame; + }, + mode() { + return state.mode(); + }, + moveSelection(action) { + state.moveSelection(action); + render(); + }, + query() { + return state.query(); + }, + render, + rows: state.rows, + selected() { + return state.selected(); + }, + }; +}; diff --git a/packages/tui/src/output/data-table/reader/session/render.ts b/packages/tui/src/output/data-table/reader/session/render.ts new file mode 100644 index 00000000..f4aeaeb6 --- /dev/null +++ b/packages/tui/src/output/data-table/reader/session/render.ts @@ -0,0 +1,25 @@ +import { renderDataTableFrame } from '#tui/output/data-table/render'; +import { eraseRenderedFrame } from '#tui/status/frame'; +import type { DataTableReaderState } from '#tui/output/data-table/reader/state'; +import type { DataTableReadOptions } from '#tui/output/data-table/reader/types'; + +export const renderDataTableReaderSessionFrame = (options: DataTableReadOptions, headers: string[], state: DataTableReaderState, currentFrame: string): string => { + if (currentFrame.length > 0) { + eraseRenderedFrame(currentFrame); + } + + const rendered = renderDataTableFrame({ + allRows: options.rows, + headers, + message: options.message, + mode: state.mode(), + query: state.query(), + rows: state.rows(), + scroll: options.scroll, + selected: state.selected(), + }); + + state.setSelected(rendered.selected); + + return rendered.frame; +}; diff --git a/packages/tui/src/output/data-table/reader/state.ts b/packages/tui/src/output/data-table/reader/state.ts new file mode 100644 index 00000000..133f1006 --- /dev/null +++ b/packages/tui/src/output/data-table/reader/state.ts @@ -0,0 +1,70 @@ +import { createDataTableReaderSelectionState } from '#tui/output/data-table/reader/state/selection'; +import { applyDataTableReaderSearchKey, beginDataTableReaderSearch } from '#tui/output/data-table/reader/state/search'; +import { visibleDataTableRows } from '#tui/output/data-table/rows'; +import { initialDataTableSearchState } from '#tui/output/data-table/search'; +import type { DataTableNavigationAction } from '#tui/output/data-table/keys'; +import type { DataTableSearchState } from '#tui/output/data-table/search'; +import type { VisibleDataTableRow } from '#tui/output/data-table/types'; +import type { DataTableReadOptions } from '#tui/output/data-table/reader/types'; + +export type DataTableReaderState = { + applySearchKey(key: string): boolean; + beginSearch(): void; + mode(): DataTableSearchState['mode']; + moveSelection(action: DataTableNavigationAction): void; + query(): string; + rows(): Array>; + selected(): number; + setSelected(selected: number): void; +}; + +export const createDataTableReaderState = (options: DataTableReadOptions, headers: string[]): DataTableReaderState => { + let search = initialDataTableSearchState(); + + const selected = createDataTableReaderSelectionState(visibleRows(), options); + + function visibleRows(): Array> { + return visibleDataTableRows(options, headers, search.query.value); + } + + function applySearchChange(nextSearch: DataTableSearchState, resetSelection: boolean): void { + search = nextSearch; + + if (resetSelection) { + selected.reset(); + } + } + + return { + applySearchKey(key) { + const nextSearch = applyDataTableReaderSearchKey(search, key); + + if (!nextSearch.changed) { + return false; + } + + applySearchChange(nextSearch.state, nextSearch.resetSelection); + + return true; + }, + beginSearch() { + applySearchChange(beginDataTableReaderSearch(), false); + }, + mode() { + return search.mode; + }, + moveSelection(action) { + selected.move(action, visibleRows().length); + }, + query() { + return search.query.value; + }, + rows: visibleRows, + selected() { + return selected.value(); + }, + setSelected(nextSelected) { + selected.set(nextSelected); + }, + }; +}; diff --git a/packages/tui/src/output/data-table/reader/state/search.ts b/packages/tui/src/output/data-table/reader/state/search.ts new file mode 100644 index 00000000..a83d4c49 --- /dev/null +++ b/packages/tui/src/output/data-table/reader/state/search.ts @@ -0,0 +1,12 @@ +import { applyDataTableSearchKey, startDataTableSearch } from '#tui/output/data-table/search'; +import type { DataTableSearchChange, DataTableSearchState } from '#tui/output/data-table/search'; + +export type DataTableReaderSearchChange = DataTableSearchChange; + +export const applyDataTableReaderSearchKey = (state: DataTableSearchState, key: string): DataTableReaderSearchChange => { + return applyDataTableSearchKey(state, key); +}; + +export const beginDataTableReaderSearch = (): DataTableSearchState => { + return startDataTableSearch(); +}; diff --git a/packages/tui/src/output/data-table/reader/state/selection.ts b/packages/tui/src/output/data-table/reader/state/selection.ts new file mode 100644 index 00000000..e32f6a71 --- /dev/null +++ b/packages/tui/src/output/data-table/reader/state/selection.ts @@ -0,0 +1,31 @@ +import { moveDataTableSelection } from '#tui/output/data-table/navigation'; +import { initialDataTableSelection } from '#tui/output/data-table/reader/result'; +import type { DataTableNavigationAction } from '#tui/output/data-table/keys'; +import type { DataTableReadOptions } from '#tui/output/data-table/reader/types'; +import type { VisibleDataTableRow } from '#tui/output/data-table/types'; + +export type DataTableReaderSelectionState = { + move(action: DataTableNavigationAction, total: number): void; + reset(): void; + set(selected: number): void; + value(): number; +}; + +export const createDataTableReaderSelectionState = (rows: Array>, options: DataTableReadOptions): DataTableReaderSelectionState => { + let selected = initialDataTableSelection(rows, options.default, options.hasDefault); + + return { + move(action, total) { + selected = moveDataTableSelection(action, selected, total, options.scroll); + }, + reset() { + selected = 0; + }, + set(nextSelected) { + selected = nextSelected; + }, + value() { + return selected; + }, + }; +}; diff --git a/packages/tui/src/output/data-table/reader/types.ts b/packages/tui/src/output/data-table/reader/types.ts new file mode 100644 index 00000000..42360b01 --- /dev/null +++ b/packages/tui/src/output/data-table/reader/types.ts @@ -0,0 +1,5 @@ +import type { DataTablePromptOptions } from '#tui/types'; + +export type DataTableReadOptions = DataTablePromptOptions & { + hasDefault?: boolean; +}; diff --git a/packages/tui/src/output/data-table/render-body.ts b/packages/tui/src/output/data-table/render-body.ts new file mode 100644 index 00000000..ebd11d9f --- /dev/null +++ b/packages/tui/src/output/data-table/render-body.ts @@ -0,0 +1,5 @@ +export { renderDataTableBody } from '#tui/output/data-table/body/active'; +export { renderCancelledDataTableRows } from '#tui/output/data-table/body/cancelled'; +export { renderDataTableHeaders } from '#tui/output/data-table/body/headers'; +export { renderSubmittedDataTableRow } from '#tui/output/data-table/body/submitted'; +export type { DataTableBodyOptions } from '#tui/output/data-table/body/types'; diff --git a/packages/tui/src/output/data-table/render.ts b/packages/tui/src/output/data-table/render.ts new file mode 100644 index 00000000..eb9187ab --- /dev/null +++ b/packages/tui/src/output/data-table/render.ts @@ -0,0 +1,61 @@ +import { promptEnvironment } from '#tui/environment'; +import { renderTable } from '#tui/theme'; +import { renderCancelledDataTableRows, renderDataTableBody, renderDataTableHeaders, renderSubmittedDataTableRow } from '#tui/output/data-table/render-body'; +import { clampDataTableSelection } from '#tui/output/data-table/selection'; +import { dim, red } from '#tui/theme/styles'; +import type { DataTableRow } from '#tui/types'; +import type { DataTableSearchMode } from '#tui/output/data-table/search'; +import type { VisibleDataTableRow } from '#tui/output/data-table/types'; + +type RenderDataTableFrameOptions = { + allRows: Array>; + headers: string[]; + message: string; + mode: 'browse' | 'search'; + query: string; + rows: Array>; + scroll?: number; + selected: number; +}; + +export type RenderedDataTableFrame = { + frame: string; + selected: number; +}; + +export const renderDataTableFrame = (options: RenderDataTableFrameOptions): RenderedDataTableFrame => { + const environment = promptEnvironment(); + const selected = clampDataTableSelection(options.selected, options.rows); + const querySuffix = options.mode === 'search' || options.query.length > 0 ? ` ${options.query}` : ''; + const lines = [`${options.message}${querySuffix}`]; + + lines.push(...renderDataTableBody({ ...options, selected })); + + const frame = `${lines.join('\n')}\n`; + + environment.output.write(frame); + + return { frame, selected }; +}; + +export const renderSubmittedDataTableFrame = (message: string, headers: string[], rows: Array>, selected: number): void => { + const display = renderSubmittedDataTableRow(headers, rows, selected); + + if (display === null) { + return; + } + + promptEnvironment().output.write(`${message}\n${display}\n`); +}; + +export const renderCancelledDataTableFrame = (message: string, headers: string[], rows: Array>, selected: number, mode: DataTableSearchMode, query: string): void => { + const outputRows = renderCancelledDataTableRows(headers, rows, selected); + const querySuffix = mode === 'search' || query.length > 0 ? ` ${query}` : ''; + + const environment = promptEnvironment(); + + environment.output.write(`${message}\n`); + environment.output.write(`${dim(`/ Search${querySuffix}`)}\n`); + environment.output.write(`${renderTable(renderDataTableHeaders(headers), outputRows)}\n`); + environment.error.write(`${red(' ⚠ Cancelled.')}\n`); +}; diff --git a/packages/tui/src/output/data-table/rows.ts b/packages/tui/src/output/data-table/rows.ts new file mode 100644 index 00000000..5e263843 --- /dev/null +++ b/packages/tui/src/output/data-table/rows.ts @@ -0,0 +1,4 @@ +export { stringifyDataTableCell } from '#tui/output/data-table/rows/cells'; +export { dataTableRowCells, dataTableRowFields, dataTableRowLabel, dataTableRowValue } from '#tui/output/data-table/rows/fields'; +export { deriveDataTableHeaders } from '#tui/output/data-table/rows/headers'; +export { visibleDataTableRows } from '#tui/output/data-table/rows/visible'; diff --git a/packages/tui/src/output/data-table/rows/cells.ts b/packages/tui/src/output/data-table/rows/cells.ts new file mode 100644 index 00000000..062577f2 --- /dev/null +++ b/packages/tui/src/output/data-table/rows/cells.ts @@ -0,0 +1,5 @@ +import type { TableCell } from '#tui/types'; + +export const stringifyDataTableCell = (value: TableCell): string => { + return value === null || value === undefined ? '' : String(value); +}; diff --git a/packages/tui/src/output/data-table/rows/fields.ts b/packages/tui/src/output/data-table/rows/fields.ts new file mode 100644 index 00000000..f6c9bd9f --- /dev/null +++ b/packages/tui/src/output/data-table/rows/fields.ts @@ -0,0 +1,45 @@ +import { parseDataTableArrayRowFields, parseDataTableRowShape, parseDataTableRowValue } from '#tui/output/validators/data-table'; +import { stringifyDataTableCell } from '#tui/output/data-table/rows/cells'; +import type { DataTableRow, TableCell } from '#tui/types'; + +export const dataTableRowFields = (row: DataTableRow): Record => { + const shape = parseDataTableRowShape(row); + + if (shape.kind === 'array') { + return parseDataTableArrayRowFields(shape.row); + } + + if (shape.kind === 'object') { + return shape.row.cells; + } + + return shape.row; +}; + +export const dataTableRowCells = (headers: string[], row: DataTableRow): string[] => { + const shape = parseDataTableRowShape(row); + + if (shape.kind === 'array') { + return headers.length > 0 ? headers.map((_, index) => stringifyDataTableCell(shape.row[index])) : shape.row.map(stringifyDataTableCell); + } + + const fields = dataTableRowFields(row); + + return headers.map((header) => stringifyDataTableCell(fields[header])); +}; + +export const dataTableRowValue = (row: DataTableRow, index: number): T | number => { + const shape = parseDataTableRowShape(row); + + if (shape.kind === 'object' && shape.row.value !== undefined) { + return shape.row.value; + } + + if (shape.kind === 'record' && shape.row.value !== undefined) { + return parseDataTableRowValue(shape.row.value); + } + + return index; +}; + +export const dataTableRowLabel = (headers: string[], row: DataTableRow): string => dataTableRowCells(headers, row).join(' '); diff --git a/packages/tui/src/output/data-table/rows/headers.ts b/packages/tui/src/output/data-table/rows/headers.ts new file mode 100644 index 00000000..b7ded314 --- /dev/null +++ b/packages/tui/src/output/data-table/rows/headers.ts @@ -0,0 +1,19 @@ +import { parseDataTableArrayHeaders, parseDataTableRecordHeaders, parseDataTableRowShape } from '#tui/output/validators/data-table'; +import { dataTableRowFields } from '#tui/output/data-table/rows/fields'; +import type { DataTableRow } from '#tui/types'; + +export const deriveDataTableHeaders = (rows: Array>): string[] => { + const first = rows[0]; + + if (!first) { + return []; + } + + const shape = parseDataTableRowShape(first); + + if (shape.kind === 'array') { + return parseDataTableArrayHeaders(shape.row); + } + + return parseDataTableRecordHeaders(dataTableRowFields(first)); +}; diff --git a/packages/tui/src/output/data-table/rows/visible.ts b/packages/tui/src/output/data-table/rows/visible.ts new file mode 100644 index 00000000..22555f64 --- /dev/null +++ b/packages/tui/src/output/data-table/rows/visible.ts @@ -0,0 +1,15 @@ +import { dataTableRowLabel } from '#tui/output/data-table/rows/fields'; +import type { VisibleDataTableRow } from '#tui/output/data-table/types'; +import type { DataTablePromptOptions } from '#tui/types'; + +export const visibleDataTableRows = (options: DataTablePromptOptions, headers: string[], query: string): Array> => { + const rows = options.rows.map((row, index) => ({ index, row })); + + if (query === '') { + return rows; + } + + const normalizedQuery = query.toLowerCase(); + + return rows.filter(({ row }) => options.filter?.(query, row) ?? dataTableRowLabel(headers, row).toLowerCase().includes(normalizedQuery)); +}; diff --git a/packages/tui/src/output/data-table/run.ts b/packages/tui/src/output/data-table/run.ts new file mode 100644 index 00000000..e81e4576 --- /dev/null +++ b/packages/tui/src/output/data-table/run.ts @@ -0,0 +1,48 @@ +import { promptUntilValid, promptWithFallback } from '#tui/prompt'; +import { promptEnvironment } from '#tui/environment'; +import { activePromptFrame } from '#tui/prompt/active-frame'; +import { createPromptSubmissionState } from '#tui/prompt/submission'; +import { dataTableValidationOptions, preserveDataTableRetryDefault, transformDataTableValue } from '#tui/output/data-table/options'; +import { readDataTableSelection } from '#tui/output/data-table/read'; +import { renderSubmittedDataTableFrame } from '#tui/output/data-table/render'; +import { deriveDataTableHeaders } from '#tui/output/data-table/rows'; +import type { DataTableSelectionReadResult } from '#tui/output/data-table/types'; +import type { NormalizedDataTablePromptOptions } from '#tui/output/data-table/options'; + +export const runDataTablePrompt = async (options: NormalizedDataTablePromptOptions): Promise => { + const validationOptions = await dataTableValidationOptions(options, !promptEnvironment().interactive); + + const headers = options.headers ?? deriveDataTableHeaders(options.rows); + + const activeFrame = activePromptFrame(); + const submission = createPromptSubmissionState | null>(null); + + return promptWithFallback('datatable', options, () => + promptUntilValid( + validationOptions, + async () => { + submission.reset(); + + const selected = await readDataTableSelection(options, headers); + + activeFrame.set(selected.frame); + submission.capture(selected.submitted, selected.cancelled, selected); + preserveDataTableRetryDefault(options, selected.value); + + return transformDataTableValue(options, selected.value); + }, + () => { + activeFrame.clear(); + submission.render((selection) => { + if (selection) { + renderSubmittedDataTableFrame(options.message, headers, selection.rows, selection.selected); + } + }); + }, + () => { + activeFrame.clear(); + submission.reset(); + }, + ), + ); +}; diff --git a/packages/tui/src/output/data-table/scrollbar.ts b/packages/tui/src/output/data-table/scrollbar.ts new file mode 100644 index 00000000..b0b51b4b --- /dev/null +++ b/packages/tui/src/output/data-table/scrollbar.ts @@ -0,0 +1,11 @@ +import { renderScrollbarRows } from '#tui/concerns/scrollbar'; +import { renderTable } from '#tui/theme'; + +export const renderScrollableDataTable = (headers: string[], rows: string[][], firstVisible: number, visibleRows: number, totalRows: number): string => { + const lines = renderTable(headers, rows).split('\n'); + const headerLineCount = headers.length > 0 ? 2 : 0; + const head = lines.slice(0, headerLineCount); + const body = lines.slice(headerLineCount); + + return [...head, ...renderScrollbarRows(body, firstVisible, visibleRows, totalRows)].join('\n'); +}; diff --git a/packages/tui/src/output/data-table/search.ts b/packages/tui/src/output/data-table/search.ts new file mode 100644 index 00000000..82dbf924 --- /dev/null +++ b/packages/tui/src/output/data-table/search.ts @@ -0,0 +1,65 @@ +import { Key } from '#tui/key'; +import { applyTypedKey } from '#tui/typed-value'; +import type { TypedValueState } from '#tui/typed-value/types'; + +export type DataTableSearchMode = 'browse' | 'search'; + +export type DataTableSearchState = { + mode: DataTableSearchMode; + query: TypedValueState; +}; + +export type DataTableSearchChange = { + changed: boolean; + resetSelection: boolean; + state: DataTableSearchState; +}; + +export const initialDataTableSearchState = (): DataTableSearchState => ({ + mode: 'browse', + query: { cursor: 0, value: '' }, +}); + +export const startDataTableSearch = (): DataTableSearchState => ({ + mode: 'search', + query: { cursor: 0, value: '' }, +}); + +export const confirmDataTableSearch = (state: DataTableSearchState): DataTableSearchState => ({ + ...state, + mode: 'browse', +}); + +export const clearDataTableSearch = (): DataTableSearchState => ({ + mode: 'browse', + query: { cursor: 0, value: '' }, +}); + +export const applyDataTableSearchKey = (state: DataTableSearchState, key: string): DataTableSearchChange => { + if (state.mode !== 'search') { + return { changed: false, resetSelection: false, state }; + } + + if (key === Key.enter) { + return { changed: true, resetSelection: false, state: confirmDataTableSearch(state) }; + } + + if (key === Key.escape) { + return { changed: true, resetSelection: true, state: clearDataTableSearch() }; + } + + const next = applyTypedKey(state.query, key); + + if (next.value === state.query.value && next.cursor === state.query.cursor) { + return { changed: false, resetSelection: false, state }; + } + + return { + changed: true, + resetSelection: true, + state: { + mode: 'search', + query: { cursor: next.cursor, value: next.value }, + }, + }; +}; diff --git a/packages/tui/src/output/data-table/selection.ts b/packages/tui/src/output/data-table/selection.ts new file mode 100644 index 00000000..85f448ac --- /dev/null +++ b/packages/tui/src/output/data-table/selection.ts @@ -0,0 +1,22 @@ +import { parseOptionalScrollSize } from '#tui/concerns/validators/scroll'; +import { parseDataTableRowTotal, parseDataTableSelectedIndex } from '#tui/output/data-table/validators/selection'; +import type { VisibleDataTableRow } from '#tui/output/data-table/types'; + +export const clampDataTableSelection = (selected: number, rows: Array>): number => { + return parseDataTableSelectedIndex(selected, rows.length); +}; + +export const dataTableRowWindow = (total: number, selected: number, scroll?: number): { end: number; start: number } => { + const rowTotal = parseDataTableRowTotal(total); + const selectedIndex = parseDataTableSelectedIndex(selected, rowTotal); + const size = parseOptionalScrollSize(scroll); + + if (size === undefined || size >= rowTotal) { + return { end: rowTotal, start: 0 }; + } + + const before = Math.floor((size - 1) / 2); + const start = Math.max(0, Math.min(selectedIndex - before, rowTotal - size)); + + return { end: start + size, start }; +}; diff --git a/packages/tui/src/output/data-table/types.ts b/packages/tui/src/output/data-table/types.ts new file mode 100644 index 00000000..1c5d089a --- /dev/null +++ b/packages/tui/src/output/data-table/types.ts @@ -0,0 +1,15 @@ +import type { DataTableRow } from '#tui/types'; + +export type VisibleDataTableRow = { + index: number; + row: DataTableRow; +}; + +export type DataTableSelectionReadResult = { + cancelled: boolean; + frame?: string; + rows: Array>; + selected: number; + submitted: boolean; + value: T | number; +}; diff --git a/packages/tui/src/output/data-table/validators/selection.ts b/packages/tui/src/output/data-table/validators/selection.ts new file mode 100644 index 00000000..7109846c --- /dev/null +++ b/packages/tui/src/output/data-table/validators/selection.ts @@ -0,0 +1,26 @@ +import { z } from 'zod'; + +const dataTableRowTotalSchema = z.number().finite().nonnegative(); +const dataTableSelectedIndexSchema = z.number().finite().nonnegative(); + +export const parseDataTableRowTotal = (total: unknown): number => { + const parsed = dataTableRowTotalSchema.safeParse(total); + + return parsed.success ? Math.floor(parsed.data) : 0; +}; + +export const parseDataTableSelectedIndex = (selected: unknown, total: unknown): number => { + const rowTotal = parseDataTableRowTotal(total); + + if (rowTotal === 0) { + return 0; + } + + const parsed = dataTableSelectedIndexSchema.safeParse(selected); + + if (!parsed.success) { + return 0; + } + + return Math.min(Math.floor(parsed.data), rowTotal - 1); +}; diff --git a/packages/tui/src/output/data-table/visual-window.ts b/packages/tui/src/output/data-table/visual-window.ts new file mode 100644 index 00000000..3787a519 --- /dev/null +++ b/packages/tui/src/output/data-table/visual-window.ts @@ -0,0 +1,18 @@ +import { parseOptionalScrollSize } from '#tui/concerns/validators/scroll'; + +export const fixedVisualDataTableRows = (rows: string[][], scroll?: number): string[][] => { + const height = parseOptionalScrollSize(scroll); + + if (height === undefined || rows.length >= height) { + return rows; + } + + const columnCount = Math.max(0, ...rows.map((row) => row.length)); + const paddedRows = [...rows]; + + while (paddedRows.length < height) { + paddedRows.push(Array.from({ length: columnCount }, () => '')); + } + + return paddedRows; +}; diff --git a/packages/tui/src/output/data-table/widths.ts b/packages/tui/src/output/data-table/widths.ts new file mode 100644 index 00000000..4565a66e --- /dev/null +++ b/packages/tui/src/output/data-table/widths.ts @@ -0,0 +1,17 @@ +import { terminalSize } from '#tui/terminal'; +import { truncateDataTableColumns } from '#tui/output/data-table/widths/truncate'; +import { dataTableColumnWidths } from '#tui/output/data-table/widths/columns'; +import { parseDataTableWidth } from '#tui/output/data-table/widths/validators/dimensions'; + +type FitDataTableColumnsOptions = { + allRows: string[][]; + headers: string[]; + maxWidth?: number; + rows: string[][]; +}; + +export const fitDataTableColumns = ({ allRows, headers, maxWidth = terminalSize().columns, rows }: FitDataTableColumnsOptions): { headers: string[]; rows: string[][] } => { + const widths = dataTableColumnWidths(headers, allRows, parseDataTableWidth(maxWidth, terminalSize().columns)); + + return truncateDataTableColumns({ headers, rows, widths }); +}; diff --git a/packages/tui/src/output/data-table/widths/available.ts b/packages/tui/src/output/data-table/widths/available.ts new file mode 100644 index 00000000..35443034 --- /dev/null +++ b/packages/tui/src/output/data-table/widths/available.ts @@ -0,0 +1,13 @@ +import { parseDataTableColumnCount, parseDataTableWidth } from '#tui/output/data-table/widths/validators/dimensions'; + +export const availableDataTableCellWidth = (columnCount: number, maxWidth: number): number => { + const columns = parseDataTableColumnCount(columnCount); + const width = parseDataTableWidth(maxWidth, 1); + const markerWidth = 1; + const renderedColumnCount = columns + 1; + const tableOverhead = renderedColumnCount * 3 + 1; + const scrollbarArea = 2; + const frameInset = 6; + + return width - markerWidth - tableOverhead - scrollbarArea - frameInset; +}; diff --git a/packages/tui/src/output/data-table/widths/columns.ts b/packages/tui/src/output/data-table/widths/columns.ts new file mode 100644 index 00000000..52d7a86d --- /dev/null +++ b/packages/tui/src/output/data-table/widths/columns.ts @@ -0,0 +1,22 @@ +import { visibleWidth } from '#tui/strings'; +import { availableDataTableCellWidth } from '#tui/output/data-table/widths/available'; +import { naturalDataTableColumnWidth } from '#tui/output/data-table/widths/natural'; +import { shrinkDataTableColumnWidths } from '#tui/output/data-table/widths/shrink'; + +export const dataTableColumnWidths = (headers: string[], rows: string[][], maxWidth: number): number[] => { + const columnCount = Math.max(headers.length, ...rows.map((row) => row.length)); + const headerWidths = Array.from({ length: columnCount }, (_, index) => visibleWidth(headers[index] ?? '')); + const natural = headerWidths.map((headerWidth, index) => Math.max(headerWidth, naturalDataTableColumnWidth(rows, index))); + const available = availableDataTableCellWidth(columnCount, maxWidth); + const totalNatural = natural.reduce((sum, width) => sum + width, 0); + + if (available <= 0) { + return Array.from({ length: columnCount }, () => 1); + } + + if (totalNatural <= available) { + return natural; + } + + return shrinkDataTableColumnWidths({ available, headerWidths, natural }); +}; diff --git a/packages/tui/src/output/data-table/widths/natural.ts b/packages/tui/src/output/data-table/widths/natural.ts new file mode 100644 index 00000000..667db77c --- /dev/null +++ b/packages/tui/src/output/data-table/widths/natural.ts @@ -0,0 +1,19 @@ +import { visibleWidth } from '#tui/strings'; +import { splitMultilineDataTableCell } from '#tui/output/data-table/multiline'; + +export const naturalDataTableColumnWidth = (rows: string[][], column: number): number => { + const widths = rows + .map((row) => row[column] ?? '') + .flatMap((cell) => splitMultilineDataTableCell(cell).map(visibleWidth)) + .filter((width) => width > 0) + .sort((left, right) => left - right); + + if (widths.length === 0) { + return 0; + } + + const percentile = widths[Math.max(0, Math.ceil(widths.length * 0.9) - 1)] ?? 0; + const maximum = widths.at(-1) ?? 0; + + return maximum <= percentile * 2 ? maximum : percentile; +}; diff --git a/packages/tui/src/output/data-table/widths/shrink.ts b/packages/tui/src/output/data-table/widths/shrink.ts new file mode 100644 index 00000000..2f838e06 --- /dev/null +++ b/packages/tui/src/output/data-table/widths/shrink.ts @@ -0,0 +1,34 @@ +type ShrinkDataTableColumnWidthsOptions = { + available: number; + headerWidths: number[]; + natural: number[]; +}; + +export const shrinkDataTableColumnWidths = ({ available, headerWidths, natural }: ShrinkDataTableColumnWidthsOptions): number[] => { + const totalNatural = natural.reduce((sum, width) => sum + width, 0); + + if (totalNatural <= 0) { + return natural.map((_, index) => Math.max(headerWidths[index] ?? 0, 1)); + } + + const shrunk = natural.map((width, index) => Math.max(headerWidths[index] ?? 0, Math.floor((available * width) / totalNatural))); + + let remaining = available - shrunk.reduce((sum, width) => sum + width, 0); + + for (const index of dataTableWidthPriority(natural)) { + if (remaining <= 0) { + break; + } + + shrunk[index] += 1; + remaining -= 1; + } + + return shrunk; +}; + +const dataTableWidthPriority = (natural: number[]): number[] => + natural + .map((width, index) => ({ index, width })) + .sort((left, right) => right.width - left.width) + .map(({ index }) => index); diff --git a/packages/tui/src/output/data-table/widths/truncate.ts b/packages/tui/src/output/data-table/widths/truncate.ts new file mode 100644 index 00000000..28780424 --- /dev/null +++ b/packages/tui/src/output/data-table/widths/truncate.ts @@ -0,0 +1,18 @@ +import { truncate, visibleWidth } from '#tui/strings'; +import { splitMultilineDataTableCell } from '#tui/output/data-table/multiline'; + +type TruncateDataTableColumnsOptions = { + headers: string[]; + rows: string[][]; + widths: number[]; +}; + +export const truncateDataTableColumns = ({ headers, rows, widths }: TruncateDataTableColumnsOptions): { headers: string[]; rows: string[][] } => ({ + headers: headers.map((header, index) => truncate(header, widths[index] ?? visibleWidth(header))), + rows: rows.map((row) => row.map((cell, index) => truncateDataTableCell(cell, widths[index] ?? visibleWidth(cell)))), +}); + +const truncateDataTableCell = (cell: string, width: number): string => + splitMultilineDataTableCell(cell) + .map((line) => truncate(line, width)) + .join('\n'); diff --git a/packages/tui/src/output/data-table/widths/validators/dimensions.ts b/packages/tui/src/output/data-table/widths/validators/dimensions.ts new file mode 100644 index 00000000..d12803c7 --- /dev/null +++ b/packages/tui/src/output/data-table/widths/validators/dimensions.ts @@ -0,0 +1,24 @@ +import { z } from 'zod'; + +const dataTableWidthSchema = z.number().finite().positive(); +const dataTableColumnCountSchema = z.number().finite().nonnegative(); + +export const parseDataTableColumnCount = (count: unknown): number => { + const parsed = dataTableColumnCountSchema.safeParse(count); + + if (!parsed.success) { + return 0; + } + + return Math.floor(parsed.data); +}; + +export const parseDataTableWidth = (width: unknown, defaultValue: number): number => { + const parsed = dataTableWidthSchema.safeParse(width); + + if (!parsed.success) { + return defaultValue; + } + + return Math.max(1, Math.trunc(parsed.data)); +}; diff --git a/packages/tui/src/output/grid-output.ts b/packages/tui/src/output/grid-output.ts new file mode 100644 index 00000000..dd9ccaab --- /dev/null +++ b/packages/tui/src/output/grid-output.ts @@ -0,0 +1,12 @@ +import { promptEnvironment } from '#tui/environment'; +import { renderGrid } from '#tui/output/grid'; + +export const grid = (items: Array = [], maxWidth?: number): void => { + const rendered = renderGrid(items, maxWidth); + + if (rendered === '') { + return; + } + + promptEnvironment().output.write(`${rendered}\n`); +}; diff --git a/packages/tui/src/output/grid.ts b/packages/tui/src/output/grid.ts new file mode 100644 index 00000000..f80e47f3 --- /dev/null +++ b/packages/tui/src/output/grid.ts @@ -0,0 +1,19 @@ +import { createGridLayout } from '#tui/output/grid/layout'; +import { boxedGridLines } from '#tui/output/grid/lines'; +import { parseGridWidth } from '#tui/output/grid/validators/layout'; +import { terminalSize } from '#tui/terminal'; + +export const renderGrid = (items: Array = [], maxWidth?: number): string => { + if (items.length === 0) { + return ''; + } + + const values = items.map(String); + const width = parseGridWidth(maxWidth, terminalSize().columns); + const availableWidth = width - 2; + const layout = createGridLayout(values, availableWidth); + + return boxedGridLines(layout) + .map((line) => ` ${line}`) + .join('\n'); +}; diff --git a/packages/tui/src/output/grid/layout.ts b/packages/tui/src/output/grid/layout.ts new file mode 100644 index 00000000..f11d182d --- /dev/null +++ b/packages/tui/src/output/grid/layout.ts @@ -0,0 +1,61 @@ +import { visibleWidth } from '#tui/strings'; +import { parseGridWidth } from '#tui/output/grid/validators/layout'; + +export type GridLayout = { + rows: string[][]; + widths: number[]; +}; + +const balancedColumnCount = (itemCount: number, maxColumns: number): number => { + if (itemCount <= maxColumns) { + return itemCount; + } + + for (let columns = maxColumns; columns >= 1; columns -= 1) { + const remainder = itemCount % columns; + + if (remainder === 0 || remainder >= Math.ceil(columns / 2)) { + return columns; + } + } + + return maxColumns; +}; + +const chunkGridItems = (items: string[], columnCount: number): string[][] => { + const rows: string[][] = []; + + for (let index = 0; index < items.length; index += columnCount) { + const row = items.slice(index, index + columnCount); + + while (row.length < columnCount) { + row.push(''); + } + + rows.push(row); + } + + return rows; +}; + +export const createGridLayout = (values: string[], availableWidth: number): GridLayout => { + if (values.length === 0) { + return { + rows: [], + widths: [], + }; + } + + const cellWidth = Math.max(...values.map(visibleWidth)) + 4; + const usableWidth = parseGridWidth(availableWidth, 1); + const maxColumns = Math.max(1, Math.floor((usableWidth - 1) / (cellWidth + 1))); + const columnCount = Math.max(1, balancedColumnCount(values.length, maxColumns)); + const rows = chunkGridItems(values, columnCount); + + return { + rows, + widths: Array.from({ length: columnCount }, (_, column) => { + return Math.max(...rows.map((row) => visibleWidth(row[column] ?? ''))); + }), + }; +}; diff --git a/packages/tui/src/output/grid/lines.ts b/packages/tui/src/output/grid/lines.ts new file mode 100644 index 00000000..66fb0b5d --- /dev/null +++ b/packages/tui/src/output/grid/lines.ts @@ -0,0 +1,24 @@ +import { visibleWidth } from '#tui/strings'; +import type { GridLayout } from '#tui/output/grid/layout'; + +const padGridCell = (value: string, width: number): string => `${value}${' '.repeat(Math.max(0, width - visibleWidth(value)))}`; + +const gridHorizontalLine = (left: string, middle: string, right: string, widths: number[]): string => { + return `${left}${widths.map((width) => '─'.repeat(width + 2)).join(middle)}${right}`; +}; + +export const boxedGridLines = ({ rows, widths }: GridLayout): string[] => { + const renderedRows: string[] = [gridHorizontalLine('┌', '┬', '┐', widths)]; + + rows.forEach((row, index) => { + if (index > 0) { + renderedRows.push(gridHorizontalLine('├', '┼', '┤', widths)); + } + + renderedRows.push(`│ ${widths.map((columnWidth, column) => padGridCell(row[column] ?? '', columnWidth)).join(' │ ')} │`); + }); + + renderedRows.push(gridHorizontalLine('└', '┴', '┘', widths)); + + return renderedRows; +}; diff --git a/packages/tui/src/output/grid/validators/layout.ts b/packages/tui/src/output/grid/validators/layout.ts new file mode 100644 index 00000000..f27ba13e --- /dev/null +++ b/packages/tui/src/output/grid/validators/layout.ts @@ -0,0 +1,13 @@ +import { z } from 'zod'; + +const gridWidthSchema = z.number().finite().positive(); + +export const parseGridWidth = (width: unknown, defaultValue: number): number => { + const parsed = gridWidthSchema.safeParse(width); + + if (!parsed.success) { + return defaultValue; + } + + return Math.max(1, Math.trunc(parsed.data)); +}; diff --git a/packages/tui/src/output/notes.ts b/packages/tui/src/output/notes.ts new file mode 100644 index 00000000..389542a5 --- /dev/null +++ b/packages/tui/src/output/notes.ts @@ -0,0 +1,21 @@ +import { promptEnvironment } from '#tui/environment'; +import { renderNoteLines } from '#tui/output/notes/render'; +import type { NoteType } from '#tui/output/notes/types'; + +export type { NoteType } from '#tui/output/notes/types'; + +export const note = (message: string, type?: NoteType | null): void => { + promptEnvironment().output.write(`${renderNoteLines(message, type).join('\n')}\n`); +}; + +export const error = (message: string): void => note(message, 'error'); + +export const warning = (message: string): void => note(message, 'warning'); + +export const alert = (message: string): void => note(message, 'alert'); + +export const info = (message: string): void => note(message, 'info'); + +export const intro = (message: string): void => note(message, 'intro'); + +export const outro = (message: string): void => note(message, 'outro'); diff --git a/packages/tui/src/output/notes/render.ts b/packages/tui/src/output/notes/render.ts new file mode 100644 index 00000000..9dcc0362 --- /dev/null +++ b/packages/tui/src/output/notes/render.ts @@ -0,0 +1,49 @@ +import { visibleWidth } from '#tui/strings'; +import { parseNoteMessageLines } from '#tui/output/notes/validators/message'; +import { backgroundCyan, backgroundRed, black, green, red, white, yellow } from '#tui/theme/styles'; +import type { NoteType } from '#tui/output/notes/types'; + +const paddedIntroLines = (lines: string[]): string[] => { + const padded = lines.map((line) => ` ${line} `); + const longest = Math.max(...padded.map(visibleWidth)); + + return padded.map((line) => `${line}${' '.repeat(Math.max(0, longest - visibleWidth(line)))}`); +}; + +const renderNoteLine = (line: string, type?: NoteType | null): string => { + switch (type) { + case 'intro': + + case 'outro': + return ` ${backgroundCyan(black(line))}`; + + case 'warning': + return yellow(` ${line}`); + + case 'error': + return red(` ${line}`); + + case 'alert': + return ` ${backgroundRed(white(` ${line} `))}`; + + case 'info': + return green(` ${line}`); + + default: + return ` ${line}`; + } +}; + +export const renderNoteLines = (message: string, type?: NoteType | null): string[] => { + const lines = parseNoteMessageLines(message); + + switch (type) { + case 'intro': + + case 'outro': + return paddedIntroLines(lines).map((line) => renderNoteLine(line, type)); + + default: + return lines.map((line) => renderNoteLine(line, type)); + } +}; diff --git a/packages/tui/src/output/notes/types.ts b/packages/tui/src/output/notes/types.ts new file mode 100644 index 00000000..a1e6153c --- /dev/null +++ b/packages/tui/src/output/notes/types.ts @@ -0,0 +1 @@ +export type NoteType = 'alert' | 'error' | 'info' | 'intro' | 'outro' | 'warning' | string; diff --git a/packages/tui/src/output/notes/validators/message.ts b/packages/tui/src/output/notes/validators/message.ts new file mode 100644 index 00000000..63bd69c5 --- /dev/null +++ b/packages/tui/src/output/notes/validators/message.ts @@ -0,0 +1,13 @@ +import { z } from 'zod'; + +const noteMessageLinesSchema = z.string().transform((message) => message.split(/\r?\n/u)); + +export const parseNoteMessageLines = (message: unknown): string[] => { + const parsed = noteMessageLinesSchema.safeParse(message); + + if (!parsed.success) { + throw new TypeError('Note messages must be strings.'); + } + + return parsed.data; +}; diff --git a/packages/tui/src/output/notify.ts b/packages/tui/src/output/notify.ts new file mode 100644 index 00000000..7c327b9d --- /dev/null +++ b/packages/tui/src/output/notify.ts @@ -0,0 +1,37 @@ +import { platform } from 'node:process'; +import { note } from '#tui/output/notes'; +import { notificationCommands } from '#tui/output/notify/commands'; +import { availableNotificationCommands, commandExists, executeNotificationCommand } from '#tui/output/notify/executor'; +import { parseNotificationPlatform } from '#tui/output/notify/validators/platform'; +import type { NotificationCommand, NotificationPlatform } from '#tui/output/notify/commands'; +import type { NotificationRuntime } from '#tui/output/notify/executor'; + +export type { NotificationRuntime }; +export type { NotificationCommand }; +export type { NotificationPlatform }; +export type { NotificationOptions } from '#tui/output/notify/validators/options'; +export { commandExists, executeNotificationCommand, notificationCommands }; + +export const notificationCommand = (targetPlatform: NotificationPlatform, title: string, body = '', subtitle = '', sound = '', icon = ''): NotificationCommand | null => { + return notificationCommands(targetPlatform, { body, icon, sound, subtitle, title }).at(0) ?? null; +}; + +export const notifyForPlatform = (targetPlatform: NotificationPlatform, title: string, body = '', subtitle = '', sound = '', icon = '', runtime: NotificationRuntime = {}): boolean => { + const commands = notificationCommands(targetPlatform, { body, icon, sound, subtitle, title }); + const availableCommands = availableNotificationCommands(targetPlatform, commands, runtime.commandExists ?? commandExists); + const execute = runtime.execute ?? executeNotificationCommand; + + for (const command of availableCommands) { + if (execute(command)) { + return true; + } + } + + note(body ? `${title}: ${body}` : title, 'info'); + + return false; +}; + +export const notify = (title: string, body = '', subtitle = '', sound = '', icon = ''): void => { + notifyForPlatform(parseNotificationPlatform(platform), title, body, subtitle, sound, icon); +}; diff --git a/packages/tui/src/output/notify/commands.ts b/packages/tui/src/output/notify/commands.ts new file mode 100644 index 00000000..fc75860c --- /dev/null +++ b/packages/tui/src/output/notify/commands.ts @@ -0,0 +1,56 @@ +import { parseNotificationOptions } from '#tui/output/notify/validators/options'; +import type { NotificationPlatform } from '#tui/output/notify/validators/platform'; +import type { ResolvedNotificationOptions } from '#tui/output/notify/validators/options'; + +export type NotificationCommand = { + args: string[]; + bin: string; +}; + +export type { NotificationPlatform } from '#tui/output/notify/validators/platform'; + +const macOsNotificationCommand = (options: ResolvedNotificationOptions): NotificationCommand => { + const script = [ + 'display notification', + JSON.stringify(options.body), + 'with title', + JSON.stringify(options.title), + options.subtitle ? `subtitle ${JSON.stringify(options.subtitle)}` : '', + options.sound ? `sound name ${JSON.stringify(options.sound)}` : '', + ] + .filter(Boolean) + .join(' '); + + return { args: ['-e', script], bin: 'osascript' }; +}; + +const notifySendCommand = (options: ResolvedNotificationOptions): NotificationCommand => { + const args = options.icon ? ['--icon', options.icon, options.title] : [options.title]; + + if (options.body) { + args.push(options.body); + } + + return { args, bin: 'notify-send' }; +}; + +const kdialogCommand = (options: ResolvedNotificationOptions): NotificationCommand => { + const message = options.body ? `${options.title}: ${options.body}` : options.title; + const args = ['--passivepopup', message, '5', '--title', options.title]; + + return { args, bin: 'kdialog' }; +}; + +export const notificationCommands = (targetPlatform: NotificationPlatform, options: unknown): NotificationCommand[] => { + const parsed = parseNotificationOptions(options); + + if (targetPlatform === 'darwin') { + return [macOsNotificationCommand(parsed)]; + } + + if (targetPlatform === 'linux') { + return [notifySendCommand(parsed), kdialogCommand(parsed)]; + } + + return []; +}; diff --git a/packages/tui/src/output/notify/executor.ts b/packages/tui/src/output/notify/executor.ts new file mode 100644 index 00000000..f504f20e --- /dev/null +++ b/packages/tui/src/output/notify/executor.ts @@ -0,0 +1,42 @@ +import { spawnSync } from 'node:child_process'; +import { parseNotificationCommand, parseNotificationCommandBin, parseNotificationCommands } from '#tui/output/notify/validators/command'; +import type { NotificationCommand, NotificationPlatform } from '#tui/output/notify/commands'; + +export type NotificationRuntime = { + commandExists?: (bin: string) => boolean; + execute?: (command: NotificationCommand) => boolean; +}; + +export const commandExists = (bin: string): boolean => { + const parsedBin = parseNotificationCommandBin(bin); + + if (parsedBin === undefined) { + return false; + } + + const result = spawnSync('sh', ['-c', 'command -v "$1"', 'sh', parsedBin], { stdio: 'ignore' }); + + return result.status === 0; +}; + +export const executeNotificationCommand = (command: NotificationCommand): boolean => { + const parsedCommand = parseNotificationCommand(command); + + if (parsedCommand === undefined) { + return false; + } + + const result = spawnSync(parsedCommand.bin, parsedCommand.args, { stdio: 'ignore' }); + + return result.status === 0; +}; + +export const availableNotificationCommands = (targetPlatform: NotificationPlatform, commands: NotificationCommand[], exists: (bin: string) => boolean = commandExists): NotificationCommand[] => { + const parsedCommands = parseNotificationCommands(commands); + + if (targetPlatform !== 'linux') { + return parsedCommands.slice(0, 1); + } + + return parsedCommands.filter((command) => exists(command.bin)); +}; diff --git a/packages/tui/src/output/notify/validators/command.ts b/packages/tui/src/output/notify/validators/command.ts new file mode 100644 index 00000000..36133c62 --- /dev/null +++ b/packages/tui/src/output/notify/validators/command.ts @@ -0,0 +1,32 @@ +import { z } from 'zod'; +import type { NotificationCommand } from '#tui/output/notify/commands'; + +const notificationCommandBinSchema = z + .string() + .min(1) + .regex(/^(?!-)[A-Za-z0-9_./-]+$/u); + +const notificationCommandSchema = z.object({ + args: z.array(z.string()).default([]), + bin: notificationCommandBinSchema, +}); + +const notificationCommandListSchema = z.array(notificationCommandSchema); + +export const parseNotificationCommand = (command: unknown): NotificationCommand | undefined => { + const parsed = notificationCommandSchema.safeParse(command); + + return parsed.success ? parsed.data : undefined; +}; + +export const parseNotificationCommands = (commands: unknown): NotificationCommand[] => { + const parsed = notificationCommandListSchema.safeParse(commands); + + return parsed.success ? parsed.data : []; +}; + +export const parseNotificationCommandBin = (bin: unknown): string | undefined => { + const parsed = notificationCommandBinSchema.safeParse(bin); + + return parsed.success ? parsed.data : undefined; +}; diff --git a/packages/tui/src/output/notify/validators/options.ts b/packages/tui/src/output/notify/validators/options.ts new file mode 100644 index 00000000..fe05de6a --- /dev/null +++ b/packages/tui/src/output/notify/validators/options.ts @@ -0,0 +1,23 @@ +import { z } from 'zod'; + +const notificationOptionsSchema = z.object({ + body: z.string().default(''), + icon: z.string().default(''), + sound: z.string().default(''), + subtitle: z.string().default(''), + title: z.string(), +}); + +export type NotificationOptions = z.input; + +export type ResolvedNotificationOptions = z.output; + +export const parseNotificationOptions = (options: unknown): ResolvedNotificationOptions => { + const parsed = notificationOptionsSchema.safeParse(options); + + if (!parsed.success) { + throw new Error('Notification options must include a string title.'); + } + + return parsed.data; +}; diff --git a/packages/tui/src/output/notify/validators/platform.ts b/packages/tui/src/output/notify/validators/platform.ts new file mode 100644 index 00000000..858cd88e --- /dev/null +++ b/packages/tui/src/output/notify/validators/platform.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; + +const notificationPlatforms = ['aix', 'android', 'cygwin', 'darwin', 'freebsd', 'haiku', 'linux', 'netbsd', 'openbsd', 'sunos', 'win32'] as const; +const notificationPlatformSchema = z.enum(notificationPlatforms); + +export type NotificationPlatform = z.infer; + +export const parseNotificationPlatform = (value: unknown): NotificationPlatform => { + const parsed = notificationPlatformSchema.safeParse(value); + + return parsed.success ? parsed.data : 'freebsd'; +}; diff --git a/packages/tui/src/output/table-output.ts b/packages/tui/src/output/table-output.ts new file mode 100644 index 00000000..95920245 --- /dev/null +++ b/packages/tui/src/output/table-output.ts @@ -0,0 +1,13 @@ +import { promptEnvironment } from '#tui/environment'; +import { parseTableOptions, tableRowCells } from '#tui/output/validators/table'; +import { renderTable } from '#tui/theme'; +import type { TableOptions } from '#tui/types'; + +export const table = (headersOrOptions: TableOptions | string[] = [], rows: TableOptions['rows'] | null = null): void => { + const options = parseTableOptions(headersOrOptions, rows); + const normalizedRows = options.rows.map((row) => tableRowCells(row, options.headers)); + + promptEnvironment().output.write(`${renderTable(options.headers, normalizedRows)}\n`); +}; + +export const dataTable = table; diff --git a/packages/tui/src/output/table.ts b/packages/tui/src/output/table.ts new file mode 100644 index 00000000..531dee3c --- /dev/null +++ b/packages/tui/src/output/table.ts @@ -0,0 +1,2 @@ +export { grid } from '#tui/output/grid-output'; +export { dataTable, table } from '#tui/output/table-output'; diff --git a/packages/tui/src/output/terminal.ts b/packages/tui/src/output/terminal.ts new file mode 100644 index 00000000..4c1444d5 --- /dev/null +++ b/packages/tui/src/output/terminal.ts @@ -0,0 +1,9 @@ +import { clearTerminal, setTerminalTitle } from '#tui/terminal'; + +export const title = (value: string): void => { + setTerminalTitle(value); +}; + +export const clear = (): void => { + clearTerminal(); +}; diff --git a/packages/tui/src/output/validators/cells.ts b/packages/tui/src/output/validators/cells.ts new file mode 100644 index 00000000..96ae0a07 --- /dev/null +++ b/packages/tui/src/output/validators/cells.ts @@ -0,0 +1,8 @@ +import { z } from 'zod'; +import type { TableCell } from '#tui/types'; + +export const tableCellSchema: z.ZodType = z.union([z.string(), z.number(), z.boolean(), z.null(), z.undefined()]); + +export const tableCellArraySchema = z.array(tableCellSchema); + +export const tableCellRecordSchema = z.record(z.string(), tableCellSchema); diff --git a/packages/tui/src/output/validators/data-table.ts b/packages/tui/src/output/validators/data-table.ts new file mode 100644 index 00000000..d70a3d9f --- /dev/null +++ b/packages/tui/src/output/validators/data-table.ts @@ -0,0 +1,10 @@ +export { dataTableStepName, isDataTablePromptOptions, parseDataTableDefault, parseDataTablePromptOptions, parseInitialDataTableRow } from '#tui/output/validators/data-table/options'; +export { + isDataObjectRow, + parseDataTableArrayHeaders, + parseDataTableArrayRowFields, + parseDataTableRecordHeaders, + parseDataTableRowShape, + parseDataTableRowValue, +} from '#tui/output/validators/data-table/row-shape'; +export type { DataTableRowShape } from '#tui/output/validators/data-table/row-shape'; diff --git a/packages/tui/src/output/validators/data-table/options.ts b/packages/tui/src/output/validators/data-table/options.ts new file mode 100644 index 00000000..4e900f9f --- /dev/null +++ b/packages/tui/src/output/validators/data-table/options.ts @@ -0,0 +1,83 @@ +import { z } from 'zod'; +import { dataTableHeadersSchema, dataTablePromptOptionsSchema, dataTableRowsSchema, dataTableStepNameSchema } from '#tui/output/validators/data-table/schemas'; +import type { DataTablePromptOptions, DataTableRow } from '#tui/types'; + +const dataTableDefaultSchema = (): z.ZodType => z.unknown() as z.ZodType; +const dataTablePromptOptionsTypedSchema = (): z.ZodType> => dataTablePromptOptionsSchema as z.ZodType>; +const dataTableRowsTypedSchema = (): z.ZodType>> => dataTableRowsSchema as z.ZodType>>; + +export const isDataTablePromptOptions = (value: unknown): value is DataTablePromptOptions => { + return dataTablePromptOptionsSchema.safeParse(value).success; +}; + +export const dataTableStepName = (value: unknown): string | undefined => { + const result = dataTableStepNameSchema.safeParse(value); + + return result.success ? result.data : undefined; +}; + +export const parseDataTablePromptOptions = ( + optionsOrHeaders: unknown = [], + rows: unknown = null, + scroll = 10, + label = '', + hint = '', + required: DataTablePromptOptions['required'] = false, + validate: DataTablePromptOptions['validate'] = undefined, + transform: DataTablePromptOptions['transform'] = undefined, + filter: DataTablePromptOptions['filter'] = undefined, +): DataTablePromptOptions => { + const promptOptions = dataTablePromptOptionsTypedSchema().safeParse(optionsOrHeaders); + + if (promptOptions.success) { + return promptOptions.data; + } + + return { + filter, + headers: parseDataTableHeaders(optionsOrHeaders), + hint, + message: label, + required, + rows: parseDataTableRows(rows ?? []), + scroll, + transform, + validate, + }; +}; + +export const parseDataTableDefault = (value: unknown): T | number => { + const parsed = dataTableDefaultSchema().safeParse(value); + + if (!parsed.success) { + throw new TypeError('Data table defaults must resolve to a typed value or row number.'); + } + + return parsed.data; +}; + +export const parseInitialDataTableRow = (value: unknown): DataTableRow => { + const rows = parseDataTableRows(value); + + return rows[0]; +}; + +const parseDataTableHeaders = (value: unknown): string[] => { + const parsed = dataTableHeadersSchema.safeParse(value); + + if (!parsed.success) { + throw new TypeError('Data table headers must be an array of strings.'); + } + + return parsed.data; +}; + +const parseDataTableRows = (value: unknown): Array> => { + const parsed = dataTableRowsTypedSchema().safeParse(value); + + if (!parsed.success) { + throw new TypeError('Data table rows must be a non-empty array.'); + } + + return parsed.data; +}; diff --git a/packages/tui/src/output/validators/data-table/row-shape.ts b/packages/tui/src/output/validators/data-table/row-shape.ts new file mode 100644 index 00000000..e249e91c --- /dev/null +++ b/packages/tui/src/output/validators/data-table/row-shape.ts @@ -0,0 +1,68 @@ +import { z } from 'zod'; +import { dataObjectRowSchema, dataTableArrayRowSchema, dataTableRecordRowSchema } from '#tui/output/validators/data-table/schemas'; +import type { DataTableObjectRow, DataTableRow, TableCell } from '#tui/types'; + +const typedDataObjectRowSchema = (): z.ZodType> => dataObjectRowSchema as z.ZodType>; +const dataTableRowValueSchema = (): z.ZodType => z.unknown() as z.ZodType; + +export type DataTableRowShape = + | { + kind: 'array'; + row: TableCell[]; + } + | { + kind: 'object'; + row: DataTableObjectRow; + } + | { + kind: 'record'; + row: Record; + }; + +export const isDataObjectRow = (row: DataTableRow): row is DataTableObjectRow => { + return dataObjectRowSchema.safeParse(row).success; +}; + +export const parseDataTableRowShape = (row: DataTableRow): DataTableRowShape => { + const objectRow = typedDataObjectRowSchema().safeParse(row); + + if (objectRow.success) { + return { kind: 'object', row: objectRow.data }; + } + + const arrayRow = dataTableArrayRowSchema.safeParse(row); + + if (arrayRow.success) { + return { kind: 'array', row: arrayRow.data }; + } + + const recordRow = dataTableRecordRowSchema.safeParse(row); + + if (!recordRow.success) { + throw new TypeError('Data table rows must be arrays, records, or object rows.'); + } + + return { kind: 'record', row: recordRow.data }; +}; + +export const parseDataTableRowValue = (value: unknown): T => { + const parsed = dataTableRowValueSchema().safeParse(value); + + if (!parsed.success) { + throw new TypeError('Data table row values must resolve to a typed value.'); + } + + return parsed.data; +}; + +export const parseDataTableArrayRowFields = (row: TableCell[]): Record => { + return Object.fromEntries(row.map((value, index) => [String(index), value])); +}; + +export const parseDataTableArrayHeaders = (row: TableCell[]): string[] => { + return row.map((_, index) => String(index + 1)); +}; + +export const parseDataTableRecordHeaders = (row: Record): string[] => { + return Object.keys(row).filter((key) => key !== 'value'); +}; diff --git a/packages/tui/src/output/validators/data-table/schemas.ts b/packages/tui/src/output/validators/data-table/schemas.ts new file mode 100644 index 00000000..ea81c4ad --- /dev/null +++ b/packages/tui/src/output/validators/data-table/schemas.ts @@ -0,0 +1,31 @@ +import { z } from 'zod'; +import { tableCellArraySchema, tableCellRecordSchema } from '#tui/output/validators/cells'; +import type { DataTableObjectRow } from '#tui/types'; + +export const dataTableArrayRowSchema = tableCellArraySchema; + +export const dataTableRecordRowSchema = tableCellRecordSchema; + +export const dataTableStepNameSchema = z.string(); + +export const dataObjectRowSchema: z.ZodType> = z + .object({ + cells: tableCellRecordSchema, + value: z.unknown().optional(), + }) + .passthrough(); + +export const dataTableRowSchema = z.union([dataObjectRowSchema, dataTableArrayRowSchema, dataTableRecordRowSchema]); + +export const dataTableRowsSchema = z.array(dataTableRowSchema).min(1, 'Data table must have at least one row.'); + +export const dataTablePromptOptionsSchema = z + .object({ + headers: z.array(z.string()).optional(), + message: z.string(), + rows: dataTableRowsSchema, + scroll: z.number().optional(), + }) + .passthrough(); + +export const dataTableHeadersSchema = z.array(z.string()); diff --git a/packages/tui/src/output/validators/table.ts b/packages/tui/src/output/validators/table.ts new file mode 100644 index 00000000..760d8045 --- /dev/null +++ b/packages/tui/src/output/validators/table.ts @@ -0,0 +1,58 @@ +import { tableHeadersSchema, tableOptionsSchema, tableRowsSchema, tableStepNameSchema } from '#tui/output/validators/table/schemas'; +import { inferredTableHeaders } from '#tui/output/validators/table/rows'; +import type { TableOptions } from '#tui/types'; + +export { tableRowCells } from '#tui/output/validators/table/rows'; + +export const isTableOptions = (value: unknown): value is TableOptions => { + return tableOptionsSchema.safeParse(value).success; +}; + +export const tableStepName = (value: unknown): string | undefined => { + const result = tableStepNameSchema.safeParse(value); + + return result.success ? result.data : undefined; +}; + +export const parseTableOptions = (headersOrOptions: unknown = [], rows: unknown = null): Required => { + const tableOptions = tableOptionsSchema.safeParse(headersOrOptions); + + if (tableOptions.success) { + const headers = tableOptions.data.headers ?? inferredTableHeaders(tableOptions.data.rows); + + return { headers, rows: tableOptions.data.rows }; + } + + if (rows === null) { + const rowOnlyOptions = tableRowsSchema.safeParse(headersOrOptions); + + if (rowOnlyOptions.success) { + return { headers: [], rows: rowOnlyOptions.data }; + } + } + + return { + headers: parseTableHeaders(headersOrOptions), + rows: rows === null ? [] : parseTableRows(rows), + }; +}; + +const parseTableHeaders = (value: unknown): string[] => { + const parsed = tableHeadersSchema.safeParse(value); + + if (!parsed.success) { + throw new TypeError('Table headers must be an array of strings.'); + } + + return parsed.data; +}; + +const parseTableRows = (value: unknown): TableOptions['rows'] => { + const parsed = tableRowsSchema.safeParse(value); + + if (!parsed.success) { + throw new TypeError('Table rows must be an array.'); + } + + return parsed.data; +}; diff --git a/packages/tui/src/output/validators/table/rows.ts b/packages/tui/src/output/validators/table/rows.ts new file mode 100644 index 00000000..4da15eab --- /dev/null +++ b/packages/tui/src/output/validators/table/rows.ts @@ -0,0 +1,34 @@ +import { tableCellArraySchema, tableCellRecordSchema } from '#tui/output/validators/cells'; +import type { TableCell, TableOptions } from '#tui/types'; + +export const inferredTableHeaders = (rows: TableOptions['rows']): string[] => { + const firstRow = rows[0]; + + const arrayRow = tableCellArraySchema.safeParse(firstRow); + + if (arrayRow.success || firstRow === undefined) { + return []; + } + + return Object.keys(firstRow); +}; + +export const stringifyTableCell = (value: TableCell): string => { + return value === null || value === undefined ? '' : String(value); +}; + +export const tableRowCells = (row: TableOptions['rows'][number], headers: string[]): string[] => { + const arrayRow = tableCellArraySchema.safeParse(row); + + if (arrayRow.success) { + return arrayRow.data.map(stringifyTableCell); + } + + const recordRow = tableCellRecordSchema.safeParse(row); + + if (!recordRow.success) { + throw new TypeError('Table rows must be arrays or records.'); + } + + return headers.map((header) => stringifyTableCell(recordRow.data[header])); +}; diff --git a/packages/tui/src/output/validators/table/schemas.ts b/packages/tui/src/output/validators/table/schemas.ts new file mode 100644 index 00000000..6772fd0a --- /dev/null +++ b/packages/tui/src/output/validators/table/schemas.ts @@ -0,0 +1,18 @@ +import { z } from 'zod'; +import { tableCellArraySchema, tableCellRecordSchema } from '#tui/output/validators/cells'; +import type { TableOptions } from '#tui/types'; + +export const tableRowSchema = z.union([tableCellArraySchema, tableCellRecordSchema]); + +export const tableRowsSchema = z.array(tableRowSchema); + +export const tableHeadersSchema = z.array(z.string()); + +export const tableStepNameSchema = z.string(); + +export const tableOptionsSchema = z + .object({ + headers: tableHeadersSchema.optional(), + rows: tableRowsSchema, + }) + .passthrough() satisfies z.ZodType; diff --git a/packages/tui/src/prompt.ts b/packages/tui/src/prompt.ts new file mode 100644 index 00000000..04338177 --- /dev/null +++ b/packages/tui/src/prompt.ts @@ -0,0 +1,10 @@ +export { ask } from '#tui/prompt/ask'; +export { cancelPrompt, cancelUsing } from '#tui/prompt/cancel'; +export type { PromptCancelHandler } from '#tui/prompt/cancel'; +export { PromptValidationError } from '#tui/prompt/error'; +export { fallbackUsing, fallbackWhen, promptWithFallback, shouldFallback } from '#tui/prompt/fallback'; +export type { PromptFallbackCondition, PromptFallbackHandler, PromptFallbackKind } from '#tui/prompt/fallback'; +export { promptUntilValid } from '#tui/prompt/lifecycle'; +export { validateUsing } from '#tui/prompt/validate-using'; +export type { PromptGlobalValidator } from '#tui/prompt/validate-using'; +export { ensureRequired, validationMessage } from '#tui/prompt/validation'; diff --git a/packages/tui/src/prompt/active-frame.ts b/packages/tui/src/prompt/active-frame.ts new file mode 100644 index 00000000..d628b33a --- /dev/null +++ b/packages/tui/src/prompt/active-frame.ts @@ -0,0 +1,27 @@ +import { eraseRenderedFrame } from '#tui/status/frame'; + +export type ActivePromptFrame = { + clear: () => void; + set: (frame?: string) => void; +}; + +export const activePromptFrame = (): ActivePromptFrame => { + let currentFrame: string | undefined; + + return { + clear: () => { + if (!currentFrame) { + return; + } + + try { + eraseRenderedFrame(currentFrame); + } finally { + currentFrame = undefined; + } + }, + set: (frame) => { + currentFrame = frame; + }, + }; +}; diff --git a/packages/tui/src/prompt/ask.ts b/packages/tui/src/prompt/ask.ts new file mode 100644 index 00000000..8cfd2690 --- /dev/null +++ b/packages/tui/src/prompt/ask.ts @@ -0,0 +1,13 @@ +import { promptEnvironment } from '#tui/environment'; +import { PromptValidationError } from '#tui/prompt/error'; +import { renderQuestion } from '#tui/theme'; + +export const ask = async (message: string, hint?: string): Promise => { + const environment = promptEnvironment(); + + if (!environment.input.readLine) { + throw new PromptValidationError('The configured prompt input cannot read lines.'); + } + + return environment.input.readLine(renderQuestion(message, hint)); +}; diff --git a/packages/tui/src/prompt/cancel.ts b/packages/tui/src/prompt/cancel.ts new file mode 100644 index 00000000..a401d2f3 --- /dev/null +++ b/packages/tui/src/prompt/cancel.ts @@ -0,0 +1,32 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; +import { parseCancelValue } from '#tui/prompt/validators/cancel'; +import type { MaybePromise } from '#tui/types'; + +export type PromptCancelHandler = () => MaybePromise; + +type PromptCancelState = { + handler: PromptCancelHandler | null; +}; + +const scopedCancelState = new AsyncLocalStorage(); +const currentCancelState: PromptCancelState = { handler: null }; + +const promptCancelState = (): PromptCancelState => scopedCancelState.getStore() ?? currentCancelState; + +export const cancelUsing = (handler?: PromptCancelHandler | null): void => { + promptCancelState().handler = handler ?? null; +}; + +export const cancelPrompt = async (fallback: T): Promise => { + const handler = promptCancelState().handler; + + if (handler === null) { + return fallback; + } + + return parseCancelValue(await handler()); +}; + +export const withPromptCancelScope = (callback: () => MaybePromise): Promise => { + return Promise.resolve(scopedCancelState.run({ ...promptCancelState() }, callback)); +}; diff --git a/packages/tui/src/prompt/error.ts b/packages/tui/src/prompt/error.ts new file mode 100644 index 00000000..b0be0952 --- /dev/null +++ b/packages/tui/src/prompt/error.ts @@ -0,0 +1,9 @@ +export class PromptValidationError extends Error { + readonly value: unknown; + + constructor(message: string, value?: unknown) { + super(message); + this.name = 'PromptValidationError'; + this.value = value; + } +} diff --git a/packages/tui/src/prompt/fallback.ts b/packages/tui/src/prompt/fallback.ts new file mode 100644 index 00000000..a6897412 --- /dev/null +++ b/packages/tui/src/prompt/fallback.ts @@ -0,0 +1,71 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; +import { parseFallbackHandler, resolveFallbackCondition } from '#tui/prompt/validators/fallback'; +import type { MaybePromise } from '#tui/types'; + +export type PromptFallbackKind = 'autocomplete' | 'confirm' | 'datatable' | 'multisearch' | 'multiselect' | 'number' | 'password' | 'pause' | 'search' | 'select' | 'suggest' | 'text' | 'textarea'; + +export type PromptFallbackHandler = (options: TOptions) => MaybePromise; + +export type PromptFallbackCondition = boolean | (() => boolean); + +type PromptFallbackState = { + condition: PromptFallbackCondition; + handlers: Map; +}; + +const scopedFallbackState = new AsyncLocalStorage(); + +const currentFallbackState: PromptFallbackState = { + condition: false, + handlers: new Map(), +}; + +const cloneFallbackState = (state: PromptFallbackState): PromptFallbackState => ({ + condition: state.condition, + handlers: new Map(state.handlers), +}); + +const promptFallbackState = (): PromptFallbackState => scopedFallbackState.getStore() ?? currentFallbackState; + +export const fallbackWhen = (condition: PromptFallbackCondition): void => { + promptFallbackState().condition = condition; +}; + +export const fallbackUsing = (kind: PromptFallbackKind, handler?: PromptFallbackHandler | null): void => { + const state = promptFallbackState(); + + if (!handler) { + state.handlers.delete(kind); + + return; + } + + state.handlers.set(kind, parseFallbackHandler(handler)); +}; + +export const shouldFallback = (kind: PromptFallbackKind): boolean => { + const state = promptFallbackState(); + const enabled = resolveFallbackCondition(state.condition); + + return enabled && state.handlers.has(kind); +}; + +export const promptWithFallback = async (kind: PromptFallbackKind, options: TOptions, run: () => MaybePromise): Promise => { + if (!shouldFallback(kind)) { + return run(); + } + + const handlers = promptFallbackState().handlers; + + const fallback = handlers.has(kind) ? parseFallbackHandler(handlers.get(kind)) : undefined; + + if (!fallback) { + return run(); + } + + return fallback(options); +}; + +export const withPromptFallbackScope = (callback: () => MaybePromise): Promise => { + return Promise.resolve(scopedFallbackState.run(cloneFallbackState(promptFallbackState()), callback)); +}; diff --git a/packages/tui/src/prompt/lifecycle.ts b/packages/tui/src/prompt/lifecycle.ts new file mode 100644 index 00000000..bd08bab6 --- /dev/null +++ b/packages/tui/src/prompt/lifecycle.ts @@ -0,0 +1,15 @@ +import { promptEnvironment } from '#tui/environment'; +import { resolveInteractivePrompt } from '#tui/prompt/lifecycle/interactive'; +import { resolveNonInteractivePrompt } from '#tui/prompt/lifecycle/non-interactive'; +import type { PromptInvalidHandler, PromptReader, PromptValidHandler } from '#tui/prompt/lifecycle/types'; +import type { BasePromptOptions } from '#tui/types'; + +export const promptUntilValid = async (options: BasePromptOptions, read: PromptReader, onValid?: PromptValidHandler, onInvalid?: PromptInvalidHandler): Promise => { + const environment = promptEnvironment(); + + if (!environment.interactive) { + return resolveNonInteractivePrompt(options); + } + + return resolveInteractivePrompt(options, read, onValid, onInvalid); +}; diff --git a/packages/tui/src/prompt/lifecycle/interactive.ts b/packages/tui/src/prompt/lifecycle/interactive.ts new file mode 100644 index 00000000..fa977cbc --- /dev/null +++ b/packages/tui/src/prompt/lifecycle/interactive.ts @@ -0,0 +1,47 @@ +import { promptEnvironment } from '#tui/environment'; +import { validatedPromptValue } from '#tui/prompt/lifecycle/validation'; +import { isPromptValidationError } from '#tui/prompt/validators/error'; +import { parseInvalidPromptValue } from '#tui/prompt/validators/invalid'; +import { renderError } from '#tui/theme'; +import type { PromptInvalidHandler, PromptReader, PromptValidHandler } from '#tui/prompt/lifecycle/types'; +import type { BasePromptOptions } from '#tui/types'; + +export const resolveInteractivePrompt = async (options: BasePromptOptions, read: PromptReader, onValid?: PromptValidHandler, onInvalid?: PromptInvalidHandler): Promise => { + const environment = promptEnvironment(); + + let attempt = 0; + + while (true) { + let value: T; + + try { + value = await read(attempt); + } catch (error) { + if (isPromptValidationError(error)) { + await onInvalid?.(parseInvalidPromptValue(error.value)); + + environment.error.write(renderError(error.message)); + attempt += 1; + continue; + } + + await onInvalid?.(); + + throw error; + } + + const validation = await validatedPromptValue(options, value); + + if (!validation) { + await onValid?.(value); + + return value; + } + + await onInvalid?.(value); + + options.default = value; + environment.error.write(renderError(validation)); + attempt += 1; + } +}; diff --git a/packages/tui/src/prompt/lifecycle/non-interactive.ts b/packages/tui/src/prompt/lifecycle/non-interactive.ts new file mode 100644 index 00000000..8de8ff22 --- /dev/null +++ b/packages/tui/src/prompt/lifecycle/non-interactive.ts @@ -0,0 +1,16 @@ +import { PromptValidationError } from '#tui/prompt/error'; +import { validatedPromptValue } from '#tui/prompt/lifecycle/validation'; +import { parsePromptDefault } from '#tui/validators/default'; +import type { BasePromptOptions } from '#tui/types'; + +export const resolveNonInteractivePrompt = async (options: BasePromptOptions): Promise => { + const value = parsePromptDefault(options.default); + + const validation = await validatedPromptValue(options, value); + + if (validation) { + throw new PromptValidationError(validation); + } + + return value; +}; diff --git a/packages/tui/src/prompt/lifecycle/types.ts b/packages/tui/src/prompt/lifecycle/types.ts new file mode 100644 index 00000000..f79a9cb2 --- /dev/null +++ b/packages/tui/src/prompt/lifecycle/types.ts @@ -0,0 +1,5 @@ +export type PromptReader = (attempt: number) => Promise; + +export type PromptValidHandler = (value: T) => void | Promise; + +export type PromptInvalidHandler = (value?: T) => void | Promise; diff --git a/packages/tui/src/prompt/lifecycle/validation.ts b/packages/tui/src/prompt/lifecycle/validation.ts new file mode 100644 index 00000000..2131455a --- /dev/null +++ b/packages/tui/src/prompt/lifecycle/validation.ts @@ -0,0 +1,6 @@ +import { ensureRequired, validationMessage } from '#tui/prompt/validation'; +import type { BasePromptOptions } from '#tui/types'; + +export const validatedPromptValue = async (options: BasePromptOptions, value: T): Promise => { + return ensureRequired(value, options.required) ?? (await validationMessage(value, options.validate, options)); +}; diff --git a/packages/tui/src/prompt/revert.ts b/packages/tui/src/prompt/revert.ts new file mode 100644 index 00000000..d47cdce1 --- /dev/null +++ b/packages/tui/src/prompt/revert.ts @@ -0,0 +1,6 @@ +import { promptEnvironment } from '#tui/environment'; +import { renderError } from '#tui/theme'; + +export const rejectPromptRevert = (): void => { + promptEnvironment().error.write(renderError('This cannot be reverted.')); +}; diff --git a/packages/tui/src/prompt/submission.ts b/packages/tui/src/prompt/submission.ts new file mode 100644 index 00000000..6dcdd946 --- /dev/null +++ b/packages/tui/src/prompt/submission.ts @@ -0,0 +1,26 @@ +export type PromptSubmissionState = { + capture(submitted: boolean, cancelled: boolean, value: T): void; + render(renderSubmitted: (value: T) => void): void; + reset(): void; +}; + +export const createPromptSubmissionState = (emptyValue: T): PromptSubmissionState => { + let shouldRender = false; + let value = emptyValue; + + return { + capture(submitted, cancelled, nextValue) { + shouldRender = submitted && !cancelled; + value = nextValue; + }, + render(renderSubmitted) { + if (shouldRender) { + renderSubmitted(value); + } + }, + reset() { + shouldRender = false; + value = emptyValue; + }, + }; +}; diff --git a/packages/tui/src/prompt/validate-using.ts b/packages/tui/src/prompt/validate-using.ts new file mode 100644 index 00000000..1bcf7e97 --- /dev/null +++ b/packages/tui/src/prompt/validate-using.ts @@ -0,0 +1,32 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; +import { parseGlobalValidationOptions } from '#tui/prompt/validators/global-validation'; +import type { BasePromptOptions, MaybePromise, ValidationResult } from '#tui/types'; + +export type PromptGlobalValidator = (value: T, options: BasePromptOptions) => MaybePromise; + +type PromptGlobalValidationState = { + validator: PromptGlobalValidator | undefined; +}; + +const scopedGlobalValidationState = new AsyncLocalStorage(); +const currentGlobalValidationState: PromptGlobalValidationState = { validator: undefined }; + +const promptGlobalValidationState = (): PromptGlobalValidationState => scopedGlobalValidationState.getStore() ?? currentGlobalValidationState; + +export const validateUsing = (validator?: PromptGlobalValidator | null): void => { + promptGlobalValidationState().validator = validator ?? undefined; +}; + +export const globalValidationMessage = async (value: T, options: BasePromptOptions): Promise => { + const validator = promptGlobalValidationState().validator; + + if (!validator) { + return undefined; + } + + return validator(value, parseGlobalValidationOptions(options)); +}; + +export const withPromptGlobalValidationScope = (callback: () => MaybePromise): Promise => { + return Promise.resolve(scopedGlobalValidationState.run({ ...promptGlobalValidationState() }, callback)); +}; diff --git a/packages/tui/src/prompt/validation.ts b/packages/tui/src/prompt/validation.ts new file mode 100644 index 00000000..081ddae2 --- /dev/null +++ b/packages/tui/src/prompt/validation.ts @@ -0,0 +1,26 @@ +import { requiredMessage } from '#tui/validators/required'; +import { parseValidationResult } from '#tui/validators/result'; +import { globalValidationMessage } from '#tui/prompt/validate-using'; +import type { BasePromptOptions, Validator } from '#tui/types'; + +const validationErrorMessage = (result: string | null | undefined): string | undefined => { + if (result !== undefined && result !== null && result.length > 0) { + return result; + } + + return undefined; +}; + +export const validationMessage = async (value: T, validator?: Validator, options?: BasePromptOptions): Promise => { + const localMessage = validationErrorMessage(parseValidationResult(validator ? await validator(value) : undefined)); + + if (localMessage !== undefined) { + return localMessage; + } + + return validationErrorMessage(parseValidationResult(options ? await globalValidationMessage(value, options) : undefined)); +}; + +export const ensureRequired = (value: T, required?: boolean | string): string | undefined => { + return requiredMessage(value, required); +}; diff --git a/packages/tui/src/prompt/validators/cancel.ts b/packages/tui/src/prompt/validators/cancel.ts new file mode 100644 index 00000000..2bf51632 --- /dev/null +++ b/packages/tui/src/prompt/validators/cancel.ts @@ -0,0 +1,13 @@ +import { z } from 'zod'; + +const cancelValueSchema = (): z.ZodType => z.unknown() as z.ZodType; + +export const parseCancelValue = (value: unknown): T => { + const parsed = cancelValueSchema().safeParse(value); + + if (!parsed.success) { + throw new TypeError('Cancelled prompt values must resolve to a typed value.'); + } + + return parsed.data; +}; diff --git a/packages/tui/src/prompt/validators/error.ts b/packages/tui/src/prompt/validators/error.ts new file mode 100644 index 00000000..9d7be16a --- /dev/null +++ b/packages/tui/src/prompt/validators/error.ts @@ -0,0 +1,8 @@ +import { z } from 'zod'; +import { PromptValidationError } from '#tui/prompt/error'; + +const promptValidationErrorSchema = z.instanceof(PromptValidationError); + +export const isPromptValidationError = (error: unknown): error is PromptValidationError => { + return promptValidationErrorSchema.safeParse(error).success; +}; diff --git a/packages/tui/src/prompt/validators/fallback.ts b/packages/tui/src/prompt/validators/fallback.ts new file mode 100644 index 00000000..3070bb88 --- /dev/null +++ b/packages/tui/src/prompt/validators/fallback.ts @@ -0,0 +1,28 @@ +import { functionSchema } from '#tui/validators/function'; +import type { z } from 'zod'; +import type { PromptFallbackCondition } from '#tui/prompt/fallback'; +import type { PromptFallbackHandler } from '#tui/prompt/fallback'; + +type PromptFallbackConditionCallback = Exclude; + +const fallbackConditionCallbackSchema = functionSchema(); + +const fallbackHandlerSchema = (): z.ZodType> => functionSchema>(); + +export const isFallbackConditionCallback = (condition: PromptFallbackCondition): condition is PromptFallbackConditionCallback => { + return fallbackConditionCallbackSchema.safeParse(condition).success; +}; + +export const resolveFallbackCondition = (condition: PromptFallbackCondition): boolean => { + return isFallbackConditionCallback(condition) ? condition() : condition; +}; + +export const parseFallbackHandler = (handler: unknown): PromptFallbackHandler => { + const parsed = fallbackHandlerSchema().safeParse(handler); + + if (!parsed.success) { + throw new TypeError('Prompt fallback handlers must be functions.'); + } + + return parsed.data; +}; diff --git a/packages/tui/src/prompt/validators/global-validation.ts b/packages/tui/src/prompt/validators/global-validation.ts new file mode 100644 index 00000000..c4b8767c --- /dev/null +++ b/packages/tui/src/prompt/validators/global-validation.ts @@ -0,0 +1,14 @@ +import { z } from 'zod'; +import type { BasePromptOptions } from '#tui/types'; + +const globalValidationOptionsSchema = z.unknown() as z.ZodType>; + +export const parseGlobalValidationOptions = (options: unknown): BasePromptOptions => { + const parsed = globalValidationOptionsSchema.safeParse(options); + + if (!parsed.success) { + throw new TypeError('Global validation options must resolve to prompt options.'); + } + + return parsed.data; +}; diff --git a/packages/tui/src/prompt/validators/invalid.ts b/packages/tui/src/prompt/validators/invalid.ts new file mode 100644 index 00000000..86dfd877 --- /dev/null +++ b/packages/tui/src/prompt/validators/invalid.ts @@ -0,0 +1,13 @@ +import { z } from 'zod'; + +const invalidPromptValueSchema = (): z.ZodType => z.unknown() as z.ZodType; + +export const parseInvalidPromptValue = (value: unknown): T | undefined => { + const parsed = invalidPromptValueSchema().safeParse(value); + + if (!parsed.success) { + throw new TypeError('Invalid prompt values must resolve to a typed value or undefined.'); + } + + return parsed.data; +}; diff --git a/packages/tui/src/prompts.ts b/packages/tui/src/prompts.ts new file mode 100644 index 00000000..b8d4e569 --- /dev/null +++ b/packages/tui/src/prompts.ts @@ -0,0 +1,3 @@ +export { number, password, text, textarea } from '#tui/prompts/basic'; +export { autocomplete, confirm, multisearch, multiselect, pause, search, select, suggest } from '#tui/prompts/choices'; +export type { SuggestOptions } from '#tui/prompts/choices'; diff --git a/packages/tui/src/prompts/basic.ts b/packages/tui/src/prompts/basic.ts new file mode 100644 index 00000000..5bf4ecef --- /dev/null +++ b/packages/tui/src/prompts/basic.ts @@ -0,0 +1,4 @@ +export { text } from '#tui/prompts/text'; +export { textarea } from '#tui/prompts/textarea'; +export { password } from '#tui/prompts/password'; +export { number } from '#tui/prompts/number'; diff --git a/packages/tui/src/prompts/choices.ts b/packages/tui/src/prompts/choices.ts new file mode 100644 index 00000000..34ae33b4 --- /dev/null +++ b/packages/tui/src/prompts/choices.ts @@ -0,0 +1,5 @@ +export { confirm, multiselect, select } from '#tui/prompts/select/index'; +export { autocomplete, suggest } from '#tui/prompts/suggest/index'; +export { multisearch, search } from '#tui/prompts/search/index'; +export { pause } from '#tui/prompts/pause'; +export type { SuggestOptions } from '#tui/prompts/suggest/index'; diff --git a/packages/tui/src/prompts/number.ts b/packages/tui/src/prompts/number.ts new file mode 100644 index 00000000..c8b6bcf3 --- /dev/null +++ b/packages/tui/src/prompts/number.ts @@ -0,0 +1,47 @@ +import { normalizeNumberPromptOptions } from '#tui/prompts/number/options'; +import { runNumberPrompt } from '#tui/prompts/number/run'; +import type { NumberPromptOptions } from '#tui/types'; + +export function number(options: NumberPromptOptions): Promise; + +export function number( + label: string, + placeholder?: string, + defaultValue?: number | string, + required?: boolean | string, + validate?: NumberPromptOptions['validate'], + hint?: string, + min?: number, + max?: number, + step?: number, + transform?: NumberPromptOptions['transform'], +): Promise; + +export async function number( + message: string | NumberPromptOptions, + placeholder = '', + defaultValue: number | string = '', + required: boolean | string = false, + validate: NumberPromptOptions['validate'] = undefined, + hint = '', + min: number | undefined = undefined, + max: number | undefined = undefined, + step: number | undefined = undefined, + transform: NumberPromptOptions['transform'] = undefined, +): Promise { + const options = normalizeNumberPromptOptions({ + defaultValue, + hasDefaultArgument: arguments.length >= 3, + hint, + max, + message, + min, + placeholder, + required, + step, + transform, + validate, + }); + + return runNumberPrompt(options); +} diff --git a/packages/tui/src/prompts/number/defaults.ts b/packages/tui/src/prompts/number/defaults.ts new file mode 100644 index 00000000..88984312 --- /dev/null +++ b/packages/tui/src/prompts/number/defaults.ts @@ -0,0 +1,35 @@ +import { PromptValidationError } from '#tui/prompt'; +import { parseNumberInput } from '#tui/prompts/number/validators/value'; +import type { NumberPromptOptions } from '#tui/types'; + +export type NormalizedNumberPromptOptions = NumberPromptOptions & { + default: number | string; + hasDefault: boolean; +}; + +export const transformNumberValue = async (options: Pick, value: number | string): Promise => { + return options.transform ? options.transform(value) : value; +}; + +export const preserveNumberRetryDefault = (options: NormalizedNumberPromptOptions, value: number | string | undefined): void => { + if (value === undefined) { + return; + } + + options.default = value; + options.hasDefault = true; +}; + +export const numberDefault = async (options: NormalizedNumberPromptOptions): Promise => { + if (!options.hasDefault) { + return ''; + } + + const result = parseNumberInput(String(options.default), options); + + if (result.error !== undefined) { + throw new PromptValidationError(result.error); + } + + return transformNumberValue(options, result.value ?? ''); +}; diff --git a/packages/tui/src/prompts/number/fallback.ts b/packages/tui/src/prompts/number/fallback.ts new file mode 100644 index 00000000..70bd08d5 --- /dev/null +++ b/packages/tui/src/prompts/number/fallback.ts @@ -0,0 +1,19 @@ +import { promptEnvironment } from '#tui/environment'; +import { PromptValidationError } from '#tui/prompt'; +import { renderQuestion } from '#tui/theme'; +import type { NumberInputOptions, NumberReadResult } from '#tui/prompts/number/types'; + +export const readNumberFallbackValue = async (message: string, options: NumberInputOptions): Promise => { + const environment = promptEnvironment(); + + if (!environment.input.readLine) { + throw new PromptValidationError('The configured prompt input cannot read input.'); + } + + const answer = await environment.input.readLine(renderQuestion(message, options.hint)); + + return { + cancelled: false, + value: answer === '' && options.hasDefault ? String(options.default) : answer, + }; +}; diff --git a/packages/tui/src/prompts/number/input.ts b/packages/tui/src/prompts/number/input.ts new file mode 100644 index 00000000..2373760c --- /dev/null +++ b/packages/tui/src/prompts/number/input.ts @@ -0,0 +1,14 @@ +import { promptEnvironment } from '#tui/environment'; +import { readNumberFallbackValue } from '#tui/prompts/number/fallback'; +import { readInteractiveNumberValue } from '#tui/prompts/number/interactive'; +import type { NumberInputOptions, NumberReadResult } from '#tui/prompts/number/types'; + +export const readNumberValue = async (message: string, options: NumberInputOptions = {}): Promise => { + const environment = promptEnvironment(); + + if (!environment.input.readKey) { + return readNumberFallbackValue(message, options); + } + + return readInteractiveNumberValue(message, options); +}; diff --git a/packages/tui/src/prompts/number/interactive.ts b/packages/tui/src/prompts/number/interactive.ts new file mode 100644 index 00000000..ecd62798 --- /dev/null +++ b/packages/tui/src/prompts/number/interactive.ts @@ -0,0 +1,51 @@ +import { promptEnvironment } from '#tui/environment'; +import { Key } from '#tui/key'; +import { cancelPrompt } from '#tui/prompt'; +import { createNumberReaderSession } from '#tui/prompts/number/session'; +import type { NumberInputOptions, NumberReadResult } from '#tui/prompts/number/types'; + +export const readInteractiveNumberValue = async (message: string, options: NumberInputOptions): Promise => { + const environment = promptEnvironment(); + const session = createNumberReaderSession(message, options); + + while (true) { + const key = await environment.input.readKey?.(); + + if (key === null || key === undefined) { + return { + cancelled: false, + frame: session.frame(), + value: session.value(), + }; + } + + if (key === Key.up || key === Key.upArrow) { + session.step(1); + continue; + } + + if (key === Key.down || key === Key.downArrow) { + session.step(-1); + continue; + } + + const next = session.applyTypedInput(key); + + if (next.cancelled) { + session.cancel(); + + return { + cancelled: true, + value: await cancelPrompt(session.value()), + }; + } + + if (next.submitted) { + return { + cancelled: false, + frame: session.frame(), + value: session.value(), + }; + } + } +}; diff --git a/packages/tui/src/prompts/number/options.ts b/packages/tui/src/prompts/number/options.ts new file mode 100644 index 00000000..d9587f36 --- /dev/null +++ b/packages/tui/src/prompts/number/options.ts @@ -0,0 +1,49 @@ +import { hasNumberDefaultArgument, isNumberPromptLabel, parseNumberDefault } from '#tui/prompts/number/validators/overload'; +import { hasPromptDefault } from '#tui/validators/default'; +import type { NormalizedNumberPromptOptions } from '#tui/prompts/number/defaults'; +import type { NumberPromptOptions } from '#tui/types'; + +export type NumberPromptArgumentOptions = { + defaultValue: number | string; + hasDefaultArgument: boolean; + hint: string; + max?: number; + message: string | NumberPromptOptions; + min?: number; + placeholder: string; + required: boolean | string; + step?: number; + transform?: NumberPromptOptions['transform']; + validate?: NumberPromptOptions['validate']; +}; + +export const normalizeNumberPromptOptions = (options: NumberPromptArgumentOptions): NormalizedNumberPromptOptions => { + const message = options.message; + + if (!isNumberPromptLabel(message)) { + const hasDefault = hasPromptDefault(message); + + return { + ...message, + default: hasDefault ? parseNumberDefault(message.default, '') : '', + hasDefault, + }; + } + + const hasDefault = hasNumberDefaultArgument(options.hasDefaultArgument, options.defaultValue); + + return { + default: hasDefault ? options.defaultValue : '', + hasDefault, + hint: options.hint, + label: message, + max: options.max, + message, + min: options.min, + placeholder: options.placeholder, + required: options.required, + step: options.step, + transform: options.transform, + validate: options.validate, + }; +}; diff --git a/packages/tui/src/prompts/number/render.ts b/packages/tui/src/prompts/number/render.ts new file mode 100644 index 00000000..5774f253 --- /dev/null +++ b/packages/tui/src/prompts/number/render.ts @@ -0,0 +1,49 @@ +import { promptEnvironment } from '#tui/environment'; +import { parseNumericValue } from '#tui/prompts/number/validators/value'; +import { renderBox } from '#tui/theme/box'; +import { cyan, dim, red, strikethrough } from '#tui/theme/styles'; +import { placeholderWithCursor, valueWithCursor } from '#tui/typed-value/cursor'; +import type { NumberInputOptions } from '#tui/prompts/number/types'; + +const UP_ARROW = '▲'; +const DOWN_ARROW = '▼'; + +const renderNumberArrows = (value: string, options: NumberInputOptions, style: (text: string) => string = (text) => text): string => { + const number = parseNumericValue(value); + const up = number !== null && options.max !== undefined && number >= options.max ? dim(UP_ARROW) : style(UP_ARROW); + const down = number !== null && options.min !== undefined && number <= options.min ? dim(DOWN_ARROW) : style(DOWN_ARROW); + + if (value !== '' && number === null) { + return `${dim(UP_ARROW)}${dim(DOWN_ARROW)}`; + } + + return `${up}${down}`; +}; + +const renderNumberBody = (value: string, cursor: number, options: NumberInputOptions, style?: (text: string) => string): string => { + const displayValue = value.length > 0 ? valueWithCursor(value, cursor) : placeholderWithCursor(options.placeholder); + const arrows = renderNumberArrows(value, options, style); + const separator = value.length > 0 ? ' ' : ' '; + + return `${displayValue}${separator}${arrows}`; +}; + +export const renderNumberValue = (message: string, value: string, cursor: number, options: NumberInputOptions): string => { + const frame = `${renderBox({ body: renderNumberBody(value, cursor, options), borderStyle: cyan, info: options.hint, title: cyan(message) })}\n`; + + promptEnvironment().output.write(frame); + + return frame; +}; + +export const renderSubmittedNumberValue = (message: string, value: number | string): void => { + promptEnvironment().output.write(`${renderBox({ body: String(value), title: dim(message) })}\n`); +}; + +export const renderCancelledNumberValue = (message: string, value: string, options: NumberInputOptions): void => { + const environment = promptEnvironment(); + const displayValue = value.length > 0 ? value : (options.placeholder ?? ''); + + environment.output.write(`${renderBox({ body: strikethrough(dim(displayValue)), borderStyle: red, title: message })}\n`); + environment.error.write(`${red(' ⚠ Cancelled.')}\n`); +}; diff --git a/packages/tui/src/prompts/number/run.ts b/packages/tui/src/prompts/number/run.ts new file mode 100644 index 00000000..8ba251e6 --- /dev/null +++ b/packages/tui/src/prompts/number/run.ts @@ -0,0 +1,75 @@ +import { promptUntilValid, promptWithFallback, PromptValidationError } from '#tui/prompt'; +import { activePromptFrame } from '#tui/prompt/active-frame'; +import { createPromptSubmissionState } from '#tui/prompt/submission'; +import { numberDefault, preserveNumberRetryDefault, transformNumberValue } from '#tui/prompts/number/defaults'; +import { readNumberValue } from '#tui/prompts/number/input'; +import { renderSubmittedNumberValue } from '#tui/prompts/number/render'; +import { parseNumberInput } from '#tui/prompts/number/validators/value'; +import type { NormalizedNumberPromptOptions } from '#tui/prompts/number/defaults'; +import type { NumberPromptOptions } from '#tui/types'; + +export const runNumberPrompt = async (options: NormalizedNumberPromptOptions): Promise => { + const validationOptions: NumberPromptOptions = { + ...options, + default: await numberDefault(options), + }; + + const activeFrame = activePromptFrame(); + const submission = createPromptSubmissionState(undefined); + + return promptWithFallback('number', options, () => + promptUntilValid( + validationOptions, + async () => { + submission.reset(); + + const answer = await readNumberValue(options.message, { + default: options.default, + hasDefault: options.hasDefault, + hint: options.hint, + integer: options.integer, + max: options.max, + min: options.min, + placeholder: options.placeholder, + step: options.step, + }); + + const value = answer.value; + + activeFrame.set(answer.frame); + submission.capture(!answer.cancelled, answer.cancelled, undefined); + + const result = parseNumberInput(value, options); + + if (result.error !== undefined) { + throw new PromptValidationError(result.error, value); + } + + if (result.value === '' && options.hasDefault) { + return numberDefault(options); + } + + const parsedValue = result.value ?? ''; + + return transformNumberValue(options, parsedValue); + }, + (value) => { + activeFrame.clear(); + submission.render(() => { + renderSubmittedNumberValue(options.message, value); + }); + }, + (value) => { + if (value === undefined) { + preserveNumberRetryDefault(options, value); + } else { + options.default = ''; + options.hasDefault = false; + } + + activeFrame.clear(); + submission.reset(); + }, + ), + ); +}; diff --git a/packages/tui/src/prompts/number/session.ts b/packages/tui/src/prompts/number/session.ts new file mode 100644 index 00000000..1be619c9 --- /dev/null +++ b/packages/tui/src/prompts/number/session.ts @@ -0,0 +1,55 @@ +import { eraseRenderedFrame } from '#tui/status/frame'; +import { renderCancelledNumberValue, renderNumberValue } from '#tui/prompts/number/render'; +import { applyNumberReaderInput, initialNumberReaderState, steppedNumberReaderState } from '#tui/prompts/number/session/state'; +import type { NumberInputOptions } from '#tui/prompts/number/types'; + +export type NumberReaderSession = { + applyTypedInput(key: string): { cancelled: boolean; submitted: boolean }; + cancel(): void; + frame(): string; + step(delta: 1 | -1): void; + value(): string; +}; + +export const createNumberReaderSession = (message: string, options: NumberInputOptions): NumberReaderSession => { + let state = initialNumberReaderState(options); + + let frame = renderNumberValue(message, state.value, state.cursor, options); + + const redraw = (): void => { + eraseRenderedFrame(frame); + frame = renderNumberValue(message, state.value, state.cursor, options); + }; + + return { + applyTypedInput(key) { + const next = applyNumberReaderInput(state, key); + + if (next.cancelled) { + return { cancelled: true, submitted: false }; + } + + state = next.state; + + if (!next.submitted) { + redraw(); + } + + return { cancelled: false, submitted: next.submitted }; + }, + cancel() { + eraseRenderedFrame(frame); + renderCancelledNumberValue(message, state.value, options); + }, + frame() { + return frame; + }, + step(delta) { + state = steppedNumberReaderState(state, delta, options); + redraw(); + }, + value() { + return state.value; + }, + }; +}; diff --git a/packages/tui/src/prompts/number/session/state.ts b/packages/tui/src/prompts/number/session/state.ts new file mode 100644 index 00000000..81414cdb --- /dev/null +++ b/packages/tui/src/prompts/number/session/state.ts @@ -0,0 +1,43 @@ +import { applyTypedKey } from '#tui/typed-value'; +import { steppedNumberValue } from '#tui/prompts/number/step'; +import type { NumberInputOptions } from '#tui/prompts/number/types'; +import type { TypedValueState } from '#tui/typed-value/types'; + +export type NumberReaderState = TypedValueState; + +export type NumberReaderInputResult = { + cancelled: boolean; + state: NumberReaderState; + submitted: boolean; +}; + +export const initialNumberReaderState = (options: NumberInputOptions): NumberReaderState => ({ + cursor: options.hasDefault ? String(options.default).length : 0, + value: options.hasDefault ? String(options.default) : '', +}); + +export const applyNumberReaderInput = (state: NumberReaderState, key: string): NumberReaderInputResult => { + const next = applyTypedKey(state, key); + + if (next.cancelled) { + return { cancelled: true, state, submitted: false }; + } + + return { + cancelled: false, + state: { + cursor: next.cursor, + value: next.value, + }, + submitted: next.submitted, + }; +}; + +export const steppedNumberReaderState = (state: NumberReaderState, delta: 1 | -1, options: NumberInputOptions): NumberReaderState => { + const value = steppedNumberValue(state.value, delta, options); + + return { + cursor: value.length, + value, + }; +}; diff --git a/packages/tui/src/prompts/number/step.ts b/packages/tui/src/prompts/number/step.ts new file mode 100644 index 00000000..d1e3e284 --- /dev/null +++ b/packages/tui/src/prompts/number/step.ts @@ -0,0 +1,30 @@ +import { parseNumberStep } from '#tui/prompts/number/validators/step'; +import { parseNumericValue } from '#tui/prompts/number/validators/value'; +import type { NumberInputOptions } from '#tui/prompts/number/types'; + +const clamp = (value: number, min?: number, max?: number): number => { + const clampedMin = min === undefined ? value : Math.max(min, value); + + return max === undefined ? clampedMin : Math.min(max, clampedMin); +}; + +export const numberStep = (step?: number): number => { + return parseNumberStep(step); +}; + +export const steppedNumberValue = (value: string, direction: 1 | -1, options: NumberInputOptions): string => { + const step = numberStep(options.step); + const numeric = parseNumericValue(value); + + if (value === '') { + return String(direction === 1 ? (options.min ?? 1) : (options.max ?? 0)); + } + + if (numeric === null) { + return value; + } + + const steppedValue = clamp(numeric + step * direction, options.min, options.max); + + return String(options.integer === false ? steppedValue : Math.trunc(steppedValue)); +}; diff --git a/packages/tui/src/prompts/number/types.ts b/packages/tui/src/prompts/number/types.ts new file mode 100644 index 00000000..dbdac1f4 --- /dev/null +++ b/packages/tui/src/prompts/number/types.ts @@ -0,0 +1,16 @@ +export type NumberInputOptions = { + default?: number | string; + hasDefault?: boolean; + hint?: string; + integer?: boolean; + max?: number; + min?: number; + placeholder?: string; + step?: number; +}; + +export type NumberReadResult = { + cancelled: boolean; + frame?: string; + value: string; +}; diff --git a/packages/tui/src/prompts/number/validators/overload.ts b/packages/tui/src/prompts/number/validators/overload.ts new file mode 100644 index 00000000..02434431 --- /dev/null +++ b/packages/tui/src/prompts/number/validators/overload.ts @@ -0,0 +1,18 @@ +import { z } from 'zod'; + +const numberPromptLabelSchema = z.string(); +const numberDefaultSchema = z.union([z.number(), z.string()]); + +export const isNumberPromptLabel = (value: unknown): value is string => { + return numberPromptLabelSchema.safeParse(value).success; +}; + +export const hasNumberDefaultArgument = (hasDefaultArgument: boolean, defaultValue: number | string | undefined): boolean => { + return hasDefaultArgument && defaultValue !== undefined; +}; + +export const parseNumberDefault = (value: unknown, fallback: number | string): number | string => { + const parsed = numberDefaultSchema.safeParse(value); + + return parsed.success ? parsed.data : fallback; +}; diff --git a/packages/tui/src/prompts/number/validators/step.ts b/packages/tui/src/prompts/number/validators/step.ts new file mode 100644 index 00000000..de9ad017 --- /dev/null +++ b/packages/tui/src/prompts/number/validators/step.ts @@ -0,0 +1,15 @@ +import { z } from 'zod'; + +const numberStepSchema = z.number().finite().positive(); + +export const parseNumberStep = (step: unknown): number => { + const parsed = numberStepSchema.safeParse(step); + + if (!parsed.success) { + return 1; + } + + const value = Math.trunc(parsed.data); + + return value > 0 ? value : 1; +}; diff --git a/packages/tui/src/prompts/number/validators/value.ts b/packages/tui/src/prompts/number/validators/value.ts new file mode 100644 index 00000000..bcd260e7 --- /dev/null +++ b/packages/tui/src/prompts/number/validators/value.ts @@ -0,0 +1,52 @@ +import { z } from 'zod'; +import type { NumberPromptOptions } from '#tui/types'; + +export type NumberPromptValue = number | string; + +export type NumberValidationResult = { + error?: string; + value?: NumberPromptValue; +}; + +const numericInputSchema = z.string(); + +const numericValueSchema = numericInputSchema.trim().min(1).transform(Number).pipe(z.number().finite()); + +export const parseNumericValue = (value: unknown): number | null => { + const parsed = numericValueSchema.safeParse(value); + + return parsed.success ? parsed.data : null; +}; + +export const parseNumberInput = (input: unknown, options: Pick = {}): NumberValidationResult => { + const rawInput = numericInputSchema.safeParse(input); + + if (!rawInput.success) { + return { error: 'Must be a number' }; + } + + const raw = rawInput.data; + const normalized = raw.trim(); + + if (normalized === '') { + return { value: '' }; + } + + const parsed = numericValueSchema.safeParse(raw); + + if (!parsed.success) { + return { error: 'Must be a number' }; + } + + const numeric = parsed.data; + + if (options.min !== undefined && numeric < options.min) { + return { error: `Must be at least ${options.min}` }; + } + + if (options.max !== undefined && numeric > options.max) { + return { error: `Must be less than ${options.max}` }; + } + + return { value: options.integer === false ? numeric : Math.trunc(numeric) }; +}; diff --git a/packages/tui/src/prompts/password.ts b/packages/tui/src/prompts/password.ts new file mode 100644 index 00000000..8d67e82d --- /dev/null +++ b/packages/tui/src/prompts/password.ts @@ -0,0 +1,34 @@ +import { normalizePasswordPromptOptions } from '#tui/prompts/password/options'; +import { runPasswordPrompt } from '#tui/prompts/password/run'; +import type { TextPromptOptions } from '#tui/types'; + +export function password(options: TextPromptOptions): Promise; + +export function password( + label: string, + placeholder?: string, + required?: boolean | string, + validate?: TextPromptOptions['validate'], + hint?: string, + transform?: TextPromptOptions['transform'], +): Promise; + +export async function password( + message: string | TextPromptOptions, + placeholder = '', + required: boolean | string = false, + validate: TextPromptOptions['validate'] = undefined, + hint = '', + transform: TextPromptOptions['transform'] = undefined, +): Promise { + return runPasswordPrompt( + normalizePasswordPromptOptions({ + hint, + message, + placeholder, + required, + transform, + validate, + }), + ); +} diff --git a/packages/tui/src/prompts/password/fallback.ts b/packages/tui/src/prompts/password/fallback.ts new file mode 100644 index 00000000..9fb8ca71 --- /dev/null +++ b/packages/tui/src/prompts/password/fallback.ts @@ -0,0 +1,19 @@ +import { promptEnvironment } from '#tui/environment'; +import { PromptValidationError } from '#tui/prompt'; +import { renderQuestion } from '#tui/theme'; +import type { PasswordInputOptions, PasswordReadResult } from '#tui/prompts/password/types'; + +export const readPasswordFallbackValue = async (message: string, options: PasswordInputOptions): Promise => { + const environment = promptEnvironment(); + + if (!environment.input.readLine) { + throw new PromptValidationError('The configured prompt input cannot read input.'); + } + + const answer = await environment.input.readLine(renderQuestion(message, options.hint)); + + return { + cancelled: false, + value: answer === '' && options.default !== undefined ? options.default : answer, + }; +}; diff --git a/packages/tui/src/prompts/password/input.ts b/packages/tui/src/prompts/password/input.ts new file mode 100644 index 00000000..c8090815 --- /dev/null +++ b/packages/tui/src/prompts/password/input.ts @@ -0,0 +1,46 @@ +import { promptEnvironment } from '#tui/environment'; +import { cancelPrompt } from '#tui/prompt'; +import { readPasswordFallbackValue } from '#tui/prompts/password/fallback'; +import { createPasswordReaderSession } from '#tui/prompts/password/session'; +import type { PasswordInputOptions, PasswordReadResult } from '#tui/prompts/password/types'; + +export const readPasswordValue = async (message: string, options: PasswordInputOptions = {}): Promise => { + const environment = promptEnvironment(); + + if (!environment.input.readKey) { + return readPasswordFallbackValue(message, options); + } + + const session = createPasswordReaderSession(message, options); + + while (true) { + const key = await environment.input.readKey(); + + if (key === null) { + return { + cancelled: false, + frame: session.frame(), + value: session.value(), + }; + } + + const next = session.applyTypedInput(key); + + if (next.cancelled) { + session.cancel(); + + return { + cancelled: true, + value: await cancelPrompt(session.value()), + }; + } + + if (next.submitted) { + return { + cancelled: false, + frame: session.frame(), + value: session.value(), + }; + } + } +}; diff --git a/packages/tui/src/prompts/password/options.ts b/packages/tui/src/prompts/password/options.ts new file mode 100644 index 00000000..6752a3eb --- /dev/null +++ b/packages/tui/src/prompts/password/options.ts @@ -0,0 +1,28 @@ +import { textOptions } from '#tui/concerns/text-options'; +import { isTextPromptLabel } from '#tui/concerns/validators/text-options'; +import type { TextPromptOptions } from '#tui/types'; + +export type PasswordPromptArgumentOptions = { + hint: string; + message: string | TextPromptOptions; + placeholder: string; + required: boolean | string; + transform?: TextPromptOptions['transform']; + validate?: TextPromptOptions['validate']; +}; + +export const normalizePasswordPromptOptions = (options: PasswordPromptArgumentOptions): TextPromptOptions => { + if (!isTextPromptLabel(options.message)) { + return textOptions(options.message); + } + + return textOptions({ + hint: options.hint, + label: options.message, + message: options.message, + placeholder: options.placeholder, + required: options.required, + transform: options.transform, + validate: options.validate, + }); +}; diff --git a/packages/tui/src/prompts/password/render.ts b/packages/tui/src/prompts/password/render.ts new file mode 100644 index 00000000..2d9bd378 --- /dev/null +++ b/packages/tui/src/prompts/password/render.ts @@ -0,0 +1,33 @@ +import { promptEnvironment } from '#tui/environment'; +import { renderBox } from '#tui/theme/box'; +import { cyan, dim, red, strikethrough } from '#tui/theme/styles'; +import { valueWithCursor } from '#tui/typed-value/cursor'; +import type { PasswordInputOptions } from '#tui/prompts/password/types'; + +export const maskPassword = (value: string): string => '•'.repeat([...value].length); + +export const passwordLength = (value: string): number => [...value].length; + +const passwordDisplay = (value: string, cursor: number, options: PasswordInputOptions): string => { + return value.length > 0 ? valueWithCursor(maskPassword(value), cursor) : dim(options.placeholder ?? ''); +}; + +export const renderPasswordValue = (message: string, value: string, cursor: number, options: PasswordInputOptions): string => { + const frame = `${renderBox({ body: passwordDisplay(value, cursor, options), borderStyle: cyan, info: options.hint, title: cyan(message) })}\n`; + + promptEnvironment().output.write(frame); + + return frame; +}; + +export const renderSubmittedPasswordValue = (message: string, value: string): void => { + promptEnvironment().output.write(`${renderBox({ body: maskPassword(value), title: dim(message) })}\n`); +}; + +export const renderCancelledPasswordValue = (message: string, value: string, options: PasswordInputOptions): void => { + const environment = promptEnvironment(); + const displayValue = value.length > 0 ? maskPassword(value) : (options.placeholder ?? ''); + + environment.output.write(`${renderBox({ body: strikethrough(dim(displayValue)), borderStyle: red, title: message })}\n`); + environment.error.write(`${red(' ⚠ Cancelled.')}\n`); +}; diff --git a/packages/tui/src/prompts/password/run.ts b/packages/tui/src/prompts/password/run.ts new file mode 100644 index 00000000..6c2c51a5 --- /dev/null +++ b/packages/tui/src/prompts/password/run.ts @@ -0,0 +1,50 @@ +import { promptUntilValid, promptWithFallback } from '#tui/prompt'; +import { activePromptFrame } from '#tui/prompt/active-frame'; +import { createPromptSubmissionState } from '#tui/prompt/submission'; +import { readPasswordValue } from '#tui/prompts/password/input'; +import { renderSubmittedPasswordValue } from '#tui/prompts/password/render'; +import { transformedTextDefault } from '#tui/prompts/text-default'; +import type { TextPromptOptions } from '#tui/types'; + +export const runPasswordPrompt = async (options: TextPromptOptions): Promise => { + const validationOptions: TextPromptOptions = { + ...options, + default: await transformedTextDefault(options), + }; + + const activeFrame = activePromptFrame(); + const submission = createPromptSubmissionState(undefined); + + return promptWithFallback('password', options, () => + promptUntilValid( + validationOptions, + async () => { + submission.reset(); + + const answer = await readPasswordValue(options.message, { + default: options.default, + hint: options.hint, + placeholder: options.placeholder, + }); + + activeFrame.set(answer.frame); + submission.capture(!answer.cancelled, answer.cancelled, undefined); + + const value = answer.value === '' && options.default !== undefined ? options.default : answer.value; + + return options.transform ? options.transform(value) : value; + }, + (value) => { + activeFrame.clear(); + submission.render(() => { + renderSubmittedPasswordValue(options.message, value); + }); + }, + (value) => { + options.default = value; + activeFrame.clear(); + submission.reset(); + }, + ), + ); +}; diff --git a/packages/tui/src/prompts/password/session.ts b/packages/tui/src/prompts/password/session.ts new file mode 100644 index 00000000..146c5140 --- /dev/null +++ b/packages/tui/src/prompts/password/session.ts @@ -0,0 +1,56 @@ +import { eraseRenderedFrame } from '#tui/status/frame'; +import { applyTypedKey } from '#tui/typed-value'; +import { passwordLength, renderCancelledPasswordValue, renderPasswordValue } from '#tui/prompts/password/render'; +import type { PasswordInputOptions } from '#tui/prompts/password/types'; + +export type PasswordReaderSession = { + applyTypedInput(key: string): { cancelled: boolean; submitted: boolean }; + cancel(): void; + frame(): string; + value(): string; +}; + +export const createPasswordReaderSession = (message: string, options: PasswordInputOptions): PasswordReaderSession => { + let state = { + cursor: passwordLength(options.default ?? ''), + value: options.default ?? '', + }; + + let frame = renderPasswordValue(message, state.value, state.cursor, options); + + const redraw = (): void => { + eraseRenderedFrame(frame); + frame = renderPasswordValue(message, state.value, state.cursor, options); + }; + + return { + applyTypedInput(key) { + const next = applyTypedKey(state, key); + + if (next.cancelled) { + return { cancelled: true, submitted: false }; + } + + state = { + cursor: next.cursor, + value: next.value, + }; + + if (!next.submitted) { + redraw(); + } + + return { cancelled: false, submitted: next.submitted }; + }, + cancel() { + eraseRenderedFrame(frame); + renderCancelledPasswordValue(message, state.value, options); + }, + frame() { + return frame; + }, + value() { + return state.value; + }, + }; +}; diff --git a/packages/tui/src/prompts/password/types.ts b/packages/tui/src/prompts/password/types.ts new file mode 100644 index 00000000..c28fffdd --- /dev/null +++ b/packages/tui/src/prompts/password/types.ts @@ -0,0 +1,11 @@ +export type PasswordInputOptions = { + default?: string; + hint?: string; + placeholder?: string; +}; + +export type PasswordReadResult = { + cancelled: boolean; + frame?: string; + value: string; +}; diff --git a/packages/tui/src/prompts/pause.ts b/packages/tui/src/prompts/pause.ts new file mode 100644 index 00000000..84533ea7 --- /dev/null +++ b/packages/tui/src/prompts/pause.ts @@ -0,0 +1,6 @@ +import { promptWithFallback } from '#tui/prompt'; +import { readPauseConfirmation } from '#tui/prompts/pause/read'; + +export const pause = async (message = 'Press enter to continue...'): Promise => { + return promptWithFallback('pause', { message }, async () => readPauseConfirmation(message)); +}; diff --git a/packages/tui/src/prompts/pause/read.ts b/packages/tui/src/prompts/pause/read.ts new file mode 100644 index 00000000..6f09a2c9 --- /dev/null +++ b/packages/tui/src/prompts/pause/read.ts @@ -0,0 +1,36 @@ +import { promptEnvironment } from '#tui/environment'; +import { Key } from '#tui/key'; +import { ask, cancelPrompt } from '#tui/prompt'; +import { renderPause } from '#tui/prompts/pause/render'; + +export const readPauseConfirmation = async (message: string): Promise => { + const environment = promptEnvironment(); + + if (!environment.interactive) { + return false; + } + + if (!environment.input.readKey) { + await ask(message); + + return true; + } + + environment.output.write(renderPause(message)); + + while (true) { + const key = await environment.input.readKey(); + + if (key === null || key === Key.enter) { + environment.output.write('\n'); + + return true; + } + + if (key === Key.ctrlC) { + environment.output.write('\n'); + + return cancelPrompt(false); + } + } +}; diff --git a/packages/tui/src/prompts/pause/render.ts b/packages/tui/src/prompts/pause/render.ts new file mode 100644 index 00000000..e78fb23b --- /dev/null +++ b/packages/tui/src/prompts/pause/render.ts @@ -0,0 +1,6 @@ +import { renderBox } from '#tui/theme/box'; +import { cyan } from '#tui/theme/styles'; + +export const renderPause = (message: string): string => { + return renderBox({ body: '', borderStyle: cyan, title: cyan(message) }); +}; diff --git a/packages/tui/src/prompts/search/body/render.ts b/packages/tui/src/prompts/search/body/render.ts new file mode 100644 index 00000000..fc790bbd --- /dev/null +++ b/packages/tui/src/prompts/search/body/render.ts @@ -0,0 +1,24 @@ +import { renderSearchRows } from '#tui/prompts/search/body/rows'; +import { dim } from '#tui/theme/styles'; +import { placeholderWithCursor, valueWithCursor } from '#tui/typed-value/cursor'; +import type { Choice } from '#tui/types'; + +export const renderSearchBody = ( + query: string, + cursor: number, + placeholder: string, + choices: Array>, + highlighted: number | null, + marked: Set, + scroll: number | undefined, + multiple: boolean, +): string => { + const value = query.length > 0 ? valueWithCursor(query, cursor) : placeholderWithCursor(placeholder); + const rows = renderSearchRows(choices, highlighted, marked, scroll, multiple); + + if (query.length > 0 && choices.length === 0) { + return [value, dim(' No results.')].join('\n'); + } + + return rows.length > 0 ? [value, rows].join('\n') : value; +}; diff --git a/packages/tui/src/prompts/search/body/rows.ts b/packages/tui/src/prompts/search/body/rows.ts new file mode 100644 index 00000000..c668c977 --- /dev/null +++ b/packages/tui/src/prompts/search/body/rows.ts @@ -0,0 +1,44 @@ +import { choiceWindow } from '#tui/concerns/choices'; +import { choiceLabel } from '#tui/concerns/choice-label'; +import { renderScrollbarRows } from '#tui/concerns/scrollbar'; +import { cyan, dim } from '#tui/theme/styles'; +import type { Choice } from '#tui/types'; + +export const renderSearchRows = (choices: Array>, highlighted: number | null, marked: Set, scroll: number | undefined, multiple: boolean): string => { + const window = choiceWindow(choices.length, highlighted ?? 0, scroll); + + const rows = choices.slice(window.start, window.end).map((choice, offset) => { + const index = window.start + offset; + const active = highlighted === index; + const selected = marked.has(index); + const label = choiceLabel(choice); + + return multiple ? multiSearchRow(label, active, selected) : searchRow(label, active); + }); + + return renderScrollbarRows(rows, window.start, window.end - window.start, choices.length).join('\n'); +}; + +const searchRow = (label: string, active: boolean): string => { + if (active) { + return `${cyan('›')} ${label} `; + } + + return ` ${dim(label)} `; +}; + +const multiSearchRow = (label: string, active: boolean, selected: boolean): string => { + if (active && selected) { + return `${cyan('› ◼')} ${label} `; + } + + if (active) { + return `${cyan('›')} ◻ ${label} `; + } + + if (selected) { + return ` ${cyan('◼')} ${dim(label)} `; + } + + return ` ${dim('◻')} ${dim(label)} `; +}; diff --git a/packages/tui/src/prompts/search/choices.ts b/packages/tui/src/prompts/search/choices.ts new file mode 100644 index 00000000..883c18c2 --- /dev/null +++ b/packages/tui/src/prompts/search/choices.ts @@ -0,0 +1,23 @@ +import { normalizeSearchChoices } from '#tui/concerns/choices'; +import { resolveSearchChoiceSourceOptions } from '#tui/prompts/search/validators/source'; +import type { Choice, SearchPromptOptions } from '#tui/types'; + +export const resolveSearchChoices = async (source: SearchPromptOptions['options'], query: string): Promise>> => { + const options = await resolveSearchChoiceSourceOptions(source, query); + + return normalizeSearchChoices(options); +}; + +export const searchMessage = (message: string, query: string): string => { + return query.length > 0 ? `${message} ${query}` : message; +}; + +export const lastEnabledIndex = (choices: Array>): number => { + for (let index = choices.length - 1; index >= 0; index -= 1) { + if (!choices[index]?.disabled) { + return index; + } + } + + return 0; +}; diff --git a/packages/tui/src/prompts/search/defaults.ts b/packages/tui/src/prompts/search/defaults.ts new file mode 100644 index 00000000..d604a929 --- /dev/null +++ b/packages/tui/src/prompts/search/defaults.ts @@ -0,0 +1,41 @@ +import { parsePromptDefault } from '#tui/validators/default'; +import type { MultiSearchPromptOptions, SearchPromptOptions } from '#tui/types'; + +export type NormalizedSearchPromptOptions = SearchPromptOptions & { + hasDefault: boolean; +}; + +export const transformSearchValue = async (options: Pick, 'transform'>, value: T): Promise => { + return options.transform ? options.transform(value) : value; +}; + +export const preserveSearchRetryDefault = (options: NormalizedSearchPromptOptions, value: T): void => { + options.default = value; + options.hasDefault = true; +}; + +export const preserveMultiSearchRetryDefault = (options: MultiSearchPromptOptions & { default: T[] }, value: T[]): void => { + options.default = value; +}; + +export const transformedSearchDefault = async (options: NormalizedSearchPromptOptions): Promise => { + if (!options.hasDefault) { + return undefined; + } + + const rawDefault = parsePromptDefault(options.default); + + try { + return await transformSearchValue(options, rawDefault); + } catch { + return rawDefault; + } +}; + +export const transformedMultiSearchDefault = async (options: MultiSearchPromptOptions & { default: T[] }): Promise => { + try { + return options.transform ? await options.transform(options.default) : options.default; + } catch { + return options.default; + } +}; diff --git a/packages/tui/src/prompts/search/index.ts b/packages/tui/src/prompts/search/index.ts new file mode 100644 index 00000000..9012ff36 --- /dev/null +++ b/packages/tui/src/prompts/search/index.ts @@ -0,0 +1,2 @@ +export { multisearch } from '#tui/prompts/search/multisearch'; +export { search } from '#tui/prompts/search/search'; diff --git a/packages/tui/src/prompts/search/keys.ts b/packages/tui/src/prompts/search/keys.ts new file mode 100644 index 00000000..63e3999a --- /dev/null +++ b/packages/tui/src/prompts/search/keys.ts @@ -0,0 +1,4 @@ +export { clearsSearchHighlight, moveSearchHighlight } from '#tui/prompts/search/keys/highlight'; +export type { MoveSearchHighlightOptions } from '#tui/prompts/search/keys/highlight'; +export { searchNavigationAction } from '#tui/prompts/search/keys/navigation'; +export type { SearchNavigationAction, SearchNavigationOptions } from '#tui/prompts/search/keys/navigation'; diff --git a/packages/tui/src/prompts/search/keys/highlight.ts b/packages/tui/src/prompts/search/keys/highlight.ts new file mode 100644 index 00000000..de9a8672 --- /dev/null +++ b/packages/tui/src/prompts/search/keys/highlight.ts @@ -0,0 +1,36 @@ +import { Key, oneOf } from '#tui/key'; +import { firstSearchHighlight, lastSearchHighlight, nextRetriedSearchHighlight, nextSearchHighlight, pageSearchHighlight } from '#tui/prompts/search/navigation'; +import type { SearchNavigationAction } from '#tui/prompts/search/keys/navigation'; +import type { Choice } from '#tui/types'; + +export type MoveSearchHighlightOptions = { + attempt?: number; + retryFirst?: boolean; + scroll?: number; +}; + +export const clearsSearchHighlight = (key: string): boolean => oneOf([Key.left, Key.leftArrow, Key.right, Key.rightArrow, Key.ctrlB, Key.ctrlF], key) !== undefined; + +export const moveSearchHighlight = (choices: Array>, highlighted: number | null, action: SearchNavigationAction, options: MoveSearchHighlightOptions = {}): number | null => { + if (action === 'next') { + return options.retryFirst === true ? nextRetriedSearchHighlight(choices, highlighted, options.attempt ?? 0) : nextSearchHighlight(choices, highlighted, 1); + } + + if (action === 'previous') { + return nextSearchHighlight(choices, highlighted, -1); + } + + if (action === 'page-next') { + return pageSearchHighlight(choices, highlighted, 1, options.scroll); + } + + if (action === 'page-previous') { + return pageSearchHighlight(choices, highlighted, -1, options.scroll); + } + + if (action === 'first') { + return firstSearchHighlight(choices); + } + + return lastSearchHighlight(choices); +}; diff --git a/packages/tui/src/prompts/search/keys/navigation.ts b/packages/tui/src/prompts/search/keys/navigation.ts new file mode 100644 index 00000000..ad1eeff9 --- /dev/null +++ b/packages/tui/src/prompts/search/keys/navigation.ts @@ -0,0 +1,36 @@ +import { Key, oneOf } from '#tui/key'; + +export type SearchNavigationAction = 'first' | 'last' | 'next' | 'page-next' | 'page-previous' | 'previous'; + +export type SearchNavigationOptions = { + controlNavigation?: boolean; + lineControls?: boolean; +}; + +export const searchNavigationAction = (key: string, options: SearchNavigationOptions = {}): SearchNavigationAction | null => { + if (key === Key.down || key === Key.downArrow || key === Key.tab || (options.controlNavigation === true && key === Key.ctrlN)) { + return 'next'; + } + + if (key === Key.up || key === Key.upArrow || key === Key.shiftTab || (options.controlNavigation === true && key === Key.ctrlP)) { + return 'previous'; + } + + if (key === Key.pageDown) { + return 'page-next'; + } + + if (key === Key.pageUp) { + return 'page-previous'; + } + + if (oneOf(options.lineControls === true ? [Key.home, Key.ctrlA] : [Key.home], key)) { + return 'first'; + } + + if (oneOf(options.lineControls === true ? [Key.end, Key.ctrlE] : [Key.end], key)) { + return 'last'; + } + + return null; +}; diff --git a/packages/tui/src/prompts/search/line-mode.ts b/packages/tui/src/prompts/search/line-mode.ts new file mode 100644 index 00000000..a17fb5c9 --- /dev/null +++ b/packages/tui/src/prompts/search/line-mode.ts @@ -0,0 +1,56 @@ +import { choiceByValue, choiceValueEquals, findChoice } from '#tui/concerns/choices'; +import { parseChoiceAnswerList } from '#tui/concerns/validators/choice-answer'; +import { PromptValidationError } from '#tui/prompt'; +import { resolveSearchChoices } from '#tui/prompts/search/choices'; +import type { Choice, MultiSearchPromptOptions, SearchPromptOptions } from '#tui/types'; + +type SearchLineOptions = SearchPromptOptions & { + hasDefault?: boolean; +}; + +export const resolveLineSearchChoice = async (options: SearchLineOptions, query: string): Promise => { + const choices = await resolveSearchChoices(options.options, query); + + if (query === '' && options.hasDefault === true) { + const disabledChoice = choices.find((candidate) => candidate.disabled && choiceValueEquals(candidate.value, options.default)); + const choice = choiceByValue(choices, options.default); + + return disabledChoice ? undefined : (choice?.value ?? options.default); + } + + const matched = findChoice(choices, query); + + if (matched?.disabled) { + return undefined; + } + + const choice = matched ?? choices.find((candidate) => !candidate.disabled); + + return choice?.value; +}; + +export const resolveLineMultiSearchChoices = async (options: MultiSearchPromptOptions, query: string): Promise => { + const choices = await resolveSearchChoices(options.options, query); + + if (query === '' && options.default !== undefined) { + return options.default.flatMap((value) => { + const choice = choices.find((candidate) => choiceValueEquals(candidate.value, value)); + + if (choice?.disabled) { + return []; + } + + return [choice?.value ?? value]; + }); + } + + const parts = parseChoiceAnswerList(query); + + const selectedChoices = parts.map((part) => findChoice(choices, part)).filter((choice): choice is Choice => choice !== undefined && !choice.disabled); + + if (selectedChoices.length !== parts.length) { + throw new PromptValidationError('Please select valid options.'); + } + + return selectedChoices.map((choice) => choice.value); +}; diff --git a/packages/tui/src/prompts/search/multi-options.ts b/packages/tui/src/prompts/search/multi-options.ts new file mode 100644 index 00000000..ff957983 --- /dev/null +++ b/packages/tui/src/prompts/search/multi-options.ts @@ -0,0 +1,29 @@ +import { isSearchPromptLabel } from '#tui/prompts/search/validators/overload'; +import { parseMultiSearchDefault } from '#tui/prompts/search/validators/defaults'; +import { parseSearchChoiceSource } from '#tui/prompts/search/validators/source'; +import type { ChoiceOptions, MultiSearchPromptOptions } from '#tui/types'; + +export type NormalizedMultiSearchPromptOptions = MultiSearchPromptOptions & { + default: T[]; +}; + +export const normalizeMultiSearchPromptOptions = ( + optionsOrLabel: MultiSearchPromptOptions | string, + source: ChoiceOptions | ((query: string) => Promise> | ChoiceOptions) | undefined, + placeholder: string, + scroll: number, + required: MultiSearchPromptOptions['required'], + validate: MultiSearchPromptOptions['validate'], + hint: string, + transform: MultiSearchPromptOptions['transform'], + info: MultiSearchPromptOptions['info'], +): NormalizedMultiSearchPromptOptions => { + const options: MultiSearchPromptOptions = isSearchPromptLabel(optionsOrLabel) + ? { message: optionsOrLabel, label: optionsOrLabel, options: parseSearchChoiceSource(source), placeholder, scroll, required, validate, hint, transform, info } + : optionsOrLabel; + + return { + ...options, + default: parseMultiSearchDefault(options.default ?? []), + }; +}; diff --git a/packages/tui/src/prompts/search/multi-run.ts b/packages/tui/src/prompts/search/multi-run.ts new file mode 100644 index 00000000..616876f8 --- /dev/null +++ b/packages/tui/src/prompts/search/multi-run.ts @@ -0,0 +1,45 @@ +import { promptUntilValid, promptWithFallback } from '#tui/prompt'; +import { activePromptFrame } from '#tui/prompt/active-frame'; +import { createPromptSubmissionState } from '#tui/prompt/submission'; +import { preserveMultiSearchRetryDefault, transformedMultiSearchDefault } from '#tui/prompts/search/defaults'; +import { readMultiSearchChoices } from '#tui/prompts/search/read-multi'; +import { renderSubmittedSearchChoices } from '#tui/prompts/search/render'; +import type { NormalizedMultiSearchPromptOptions } from '#tui/prompts/search/multi-options'; +import type { MultiSearchPromptOptions } from '#tui/types'; + +export const runMultiSearchPrompt = async (options: NormalizedMultiSearchPromptOptions): Promise => { + const validationOptions: MultiSearchPromptOptions = { + ...options, + default: await transformedMultiSearchDefault(options), + }; + + const activeFrame = activePromptFrame(); + const submission = createPromptSubmissionState([]); + + return promptWithFallback('multisearch', options, () => + promptUntilValid( + validationOptions, + async () => { + submission.reset(); + + const selected = await readMultiSearchChoices(options); + + activeFrame.set(selected.frame); + submission.capture(selected.submitted, selected.cancelled, selected.submittedLabels); + preserveMultiSearchRetryDefault(options, selected.value); + + return options.transform ? options.transform(selected.value) : selected.value; + }, + () => { + activeFrame.clear(); + submission.render((labels) => { + renderSubmittedSearchChoices(options.message, labels); + }); + }, + () => { + activeFrame.clear(); + submission.reset(); + }, + ), + ); +}; diff --git a/packages/tui/src/prompts/search/multisearch.ts b/packages/tui/src/prompts/search/multisearch.ts new file mode 100644 index 00000000..7f65e15d --- /dev/null +++ b/packages/tui/src/prompts/search/multisearch.ts @@ -0,0 +1,31 @@ +import { normalizeMultiSearchPromptOptions } from '#tui/prompts/search/multi-options'; +import { runMultiSearchPrompt } from '#tui/prompts/search/multi-run'; +import type { ChoiceOptions, MultiSearchPromptOptions } from '#tui/types'; + +export function multisearch(options: MultiSearchPromptOptions): Promise; + +export function multisearch( + label: string, + options: MultiSearchPromptOptions['options'], + placeholder?: string, + scroll?: number, + required?: MultiSearchPromptOptions['required'], + validate?: MultiSearchPromptOptions['validate'], + hint?: string, + transform?: MultiSearchPromptOptions['transform'], + info?: MultiSearchPromptOptions['info'], +): Promise; + +export async function multisearch( + optionsOrLabel: MultiSearchPromptOptions | string, + source?: ChoiceOptions | ((query: string) => Promise> | ChoiceOptions), + placeholder = '', + scroll = 5, + required: MultiSearchPromptOptions['required'] = false, + validate: MultiSearchPromptOptions['validate'] = undefined, + hint = 'Use the space bar to select options.', + transform: MultiSearchPromptOptions['transform'] = undefined, + info: MultiSearchPromptOptions['info'] = '', +): Promise { + return runMultiSearchPrompt(normalizeMultiSearchPromptOptions(optionsOrLabel, source, placeholder, scroll, required, validate, hint, transform, info)); +} diff --git a/packages/tui/src/prompts/search/navigation.ts b/packages/tui/src/prompts/search/navigation.ts new file mode 100644 index 00000000..bee523dd --- /dev/null +++ b/packages/tui/src/prompts/search/navigation.ts @@ -0,0 +1,46 @@ +import { firstEnabledIndex, nextEnabledIndex } from '#tui/concerns/choices'; +import { lastEnabledIndex } from '#tui/prompts/search/choices'; +import { pageEnabledChoiceIndex } from '#tui/prompts/select/navigation'; +import type { Choice } from '#tui/types'; + +export const firstSearchHighlight = (choices: Array>): number | null => { + return choices.length === 0 ? null : firstEnabledIndex(choices); +}; + +export const initialRetriedSearchHighlight = (choices: Array>, attempt: number): number | null => { + return attempt > 0 && choices.length > 0 ? 0 : null; +}; + +export const lastSearchHighlight = (choices: Array>): number | null => { + return choices.length === 0 ? null : lastEnabledIndex(choices); +}; + +export const nextSearchHighlight = (choices: Array>, highlighted: number | null, direction: 1 | -1): number | null => { + if (choices.length === 0) { + return null; + } + + return highlighted === null ? (direction === 1 ? firstEnabledIndex(choices) : lastEnabledIndex(choices)) : nextEnabledIndex(choices, highlighted, direction); +}; + +export const nextRetriedSearchHighlight = (choices: Array>, highlighted: number | null, attempt: number): number | null => { + if (choices.length === 0) { + return null; + } + + if (highlighted !== null) { + return nextEnabledIndex(choices, highlighted, 1); + } + + const first = firstEnabledIndex(choices); + + return attempt > 0 ? nextEnabledIndex(choices, first, 1) : first; +}; + +export const pageSearchHighlight = (choices: Array>, highlighted: number | null, direction: 1 | -1, scroll?: number): number | null => { + if (choices.length === 0) { + return null; + } + + return highlighted === null ? (direction === 1 ? firstEnabledIndex(choices) : lastEnabledIndex(choices)) : pageEnabledChoiceIndex(choices, highlighted, direction, scroll); +}; diff --git a/packages/tui/src/prompts/search/options.ts b/packages/tui/src/prompts/search/options.ts new file mode 100644 index 00000000..4d4e966a --- /dev/null +++ b/packages/tui/src/prompts/search/options.ts @@ -0,0 +1,29 @@ +import { assertSearchOptions } from '#tui/prompts/search/validators/options'; +import { isSearchPromptLabel } from '#tui/prompts/search/validators/overload'; +import { parseSearchChoiceSource } from '#tui/prompts/search/validators/source'; +import { hasPromptDefault } from '#tui/validators/default'; +import type { NormalizedSearchPromptOptions } from '#tui/prompts/search/defaults'; +import type { ChoiceOptions, SearchPromptOptions } from '#tui/types'; + +export const normalizeSearchPromptOptions = ( + optionsOrLabel: SearchPromptOptions | string, + source: ChoiceOptions | ((query: string) => Promise> | ChoiceOptions) | undefined, + placeholder: string, + scroll: number, + validate: SearchPromptOptions['validate'], + hint: string, + required: SearchPromptOptions['required'], + transform: SearchPromptOptions['transform'], + info: SearchPromptOptions['info'], +): NormalizedSearchPromptOptions => { + const isLabel = isSearchPromptLabel(optionsOrLabel); + const hasDefault = isLabel ? false : hasPromptDefault(optionsOrLabel); + + const options: NormalizedSearchPromptOptions = isLabel + ? { message: optionsOrLabel, label: optionsOrLabel, options: parseSearchChoiceSource(source), hasDefault, placeholder, scroll, validate, hint, required, transform, info } + : { ...optionsOrLabel, hasDefault, required: optionsOrLabel.required ?? true }; + + assertSearchOptions(options); + + return options; +}; diff --git a/packages/tui/src/prompts/search/read-multi.ts b/packages/tui/src/prompts/search/read-multi.ts new file mode 100644 index 00000000..0c3d9af9 --- /dev/null +++ b/packages/tui/src/prompts/search/read-multi.ts @@ -0,0 +1,55 @@ +import { promptEnvironment } from '#tui/environment'; +import { Key } from '#tui/key'; +import { cancelMultiSearchChoices } from '#tui/prompts/search/read-multi/cancel'; +import { applyMultiSearchKey } from '#tui/prompts/search/read-multi/keys'; +import { lineMultiSearchValues, selectedSearchValues } from '#tui/prompts/search/read-multi/result'; +import type { MultiSearchChoicesReadResult } from '#tui/prompts/search/read-multi/result'; +import { createMultiSearchReaderSession } from '#tui/prompts/search/read-multi/session'; +import type { MultiSearchReaderSession } from '#tui/prompts/search/read-multi/session'; +import type { MultiSearchPromptOptions } from '#tui/types'; + +const multiSearchSelectionResult = (session: MultiSearchReaderSession, submitted: boolean): MultiSearchChoicesReadResult => ({ + cancelled: false, + frame: session.frame(), + submitted, + submittedLabels: session.selectedLabels(), + value: selectedSearchValues(session.selected()), +}); + +export const readMultiSearchChoices = async (options: MultiSearchPromptOptions): Promise> => { + const environment = promptEnvironment(); + + if (!environment.input.readKey) { + return { cancelled: false, submitted: false, submittedLabels: [], value: await lineMultiSearchValues(options) }; + } + + const session = await createMultiSearchReaderSession(options); + + session.render(); + + while (true) { + const key = await environment.input.readKey(); + + if (key === null) { + return multiSearchSelectionResult(session, true); + } + + if (key === Key.enter) { + return multiSearchSelectionResult(session, true); + } + + if (key === Key.ctrlC) { + return cancelMultiSearchChoices(options, session); + } + + if (await applyMultiSearchKey(key, session)) { + continue; + } + + const next = await session.applyTypedInput(key); + + if (next.cancelled) { + return cancelMultiSearchChoices(options, session); + } + } +}; diff --git a/packages/tui/src/prompts/search/read-multi/cancel.ts b/packages/tui/src/prompts/search/read-multi/cancel.ts new file mode 100644 index 00000000..ab4c19d9 --- /dev/null +++ b/packages/tui/src/prompts/search/read-multi/cancel.ts @@ -0,0 +1,21 @@ +import { cancelPrompt } from '#tui/prompt'; +import { eraseRenderedFrame } from '#tui/status/frame'; +import { renderCancelledSearch } from '#tui/prompts/search/render'; +import { selectedSearchValues } from '#tui/prompts/search/read-multi/result'; +import type { MultiSearchChoicesReadResult } from '#tui/prompts/search/read-multi/result'; +import type { MultiSearchReaderSession } from '#tui/prompts/search/read-multi/session'; +import type { MultiSearchPromptOptions } from '#tui/types'; + +export const cancelMultiSearchChoices = async (options: MultiSearchPromptOptions, session: MultiSearchReaderSession): Promise> => { + eraseRenderedFrame(session.frame()); + renderCancelledSearch(options.message, session.query().value, options.placeholder); + + const value = selectedSearchValues(session.selected()); + + return { + cancelled: true, + submitted: false, + submittedLabels: session.selectedLabels(), + value: await cancelPrompt(value), + }; +}; diff --git a/packages/tui/src/prompts/search/read-multi/choice-query.ts b/packages/tui/src/prompts/search/read-multi/choice-query.ts new file mode 100644 index 00000000..924b6899 --- /dev/null +++ b/packages/tui/src/prompts/search/read-multi/choice-query.ts @@ -0,0 +1,45 @@ +import { applyTypedKey } from '#tui/typed-value'; +import { resolveSearchChoices } from '#tui/prompts/search/choices'; +import { displayedSearchChoices } from '#tui/prompts/search/selection'; +import type { SearchSelection } from '#tui/prompts/search/selection'; +import type { TypedValueState } from '#tui/typed-value/types'; +import type { Choice, MultiSearchPromptOptions } from '#tui/types'; + +export type MultiSearchChoiceQuery = { + applyTypedInput(key: string): Promise<{ cancelled: boolean }>; + displayedChoices(): Array>; + resolveChoices(): Promise; + value(): TypedValueState; +}; + +export const createMultiSearchChoiceQuery = (options: MultiSearchPromptOptions, selected: SearchSelection, initialChoices: Array>): MultiSearchChoiceQuery => { + let query: TypedValueState = { cursor: 0, value: '' }; + let choices: Array> = initialChoices; + + const resolveChoices = async (): Promise => { + choices = await resolveSearchChoices(options.options, query.value); + }; + + return { + async applyTypedInput(key) { + const next = applyTypedKey(query, key); + + if (next.cancelled) { + return { cancelled: true }; + } + + query = { cursor: next.cursor, value: next.value }; + + await resolveChoices(); + + return { cancelled: false }; + }, + displayedChoices() { + return displayedSearchChoices(choices, selected, query.value); + }, + resolveChoices, + value() { + return query; + }, + }; +}; diff --git a/packages/tui/src/prompts/search/read-multi/frame.ts b/packages/tui/src/prompts/search/read-multi/frame.ts new file mode 100644 index 00000000..9fb3f17f --- /dev/null +++ b/packages/tui/src/prompts/search/read-multi/frame.ts @@ -0,0 +1,40 @@ +import { eraseRenderedFrame } from '#tui/status/frame'; +import { renderSearchChoices } from '#tui/prompts/search/render'; +import type { MultiSearchReaderState } from '#tui/prompts/search/read-multi/state'; +import type { MultiSearchPromptOptions } from '#tui/types'; + +export type MultiSearchFrameRenderer = { + current(): string; + render(): void; +}; + +export const createMultiSearchFrameRenderer = (options: MultiSearchPromptOptions, state: MultiSearchReaderState): MultiSearchFrameRenderer => { + let frame = ''; + + return { + current() { + return frame; + }, + render() { + if (frame.length > 0) { + eraseRenderedFrame(frame); + } + + const query = state.query(); + + frame = renderSearchChoices( + options.message, + query.value, + query.cursor, + state.displayedChoices(), + state.highlighted(), + state.markedChoiceIndexes(), + state.selectedLabels(), + options.scroll, + options.info, + true, + options.placeholder, + ); + }, + }; +}; diff --git a/packages/tui/src/prompts/search/read-multi/keys.ts b/packages/tui/src/prompts/search/read-multi/keys.ts new file mode 100644 index 00000000..9c6ffecc --- /dev/null +++ b/packages/tui/src/prompts/search/read-multi/keys.ts @@ -0,0 +1,31 @@ +import { Key } from '#tui/key'; +import { searchNavigationAction } from '#tui/prompts/search/keys'; +import type { MultiSearchReaderSession } from '#tui/prompts/search/read-multi/session'; + +export const applyMultiSearchKey = async (key: string, session: MultiSearchReaderSession): Promise => { + const action = searchNavigationAction(key); + + if (action !== null && (action !== 'first' || session.highlighted() !== null) && (action !== 'last' || session.highlighted() !== null)) { + await session.move(action); + + return true; + } + + if (key === Key.ctrlE && session.highlighted() !== null) { + return true; + } + + if (key === Key.ctrlA && session.highlighted() !== null) { + session.toggleAllDisplayed(); + + return true; + } + + if (key === Key.space && session.highlighted() !== null) { + session.toggleHighlighted(); + + return true; + } + + return false; +}; diff --git a/packages/tui/src/prompts/search/read-multi/result.ts b/packages/tui/src/prompts/search/read-multi/result.ts new file mode 100644 index 00000000..8ea86580 --- /dev/null +++ b/packages/tui/src/prompts/search/read-multi/result.ts @@ -0,0 +1,33 @@ +import { ask } from '#tui/prompt'; +import { resolveLineMultiSearchChoices } from '#tui/prompts/search/line-mode'; +import { toggleSearchChoice } from '#tui/prompts/search/selection'; +import type { SearchSelection } from '#tui/prompts/search/selection'; +import type { Choice, MultiSearchPromptOptions } from '#tui/types'; + +export type MultiSearchChoicesReadResult = { + cancelled: boolean; + frame?: string; + submitted: boolean; + submittedLabels: string[]; + value: T[]; +}; + +export const selectedSearchValues = (selected: SearchSelection): T[] => [...selected.keys()]; + +export const lineMultiSearchValues = async (options: MultiSearchPromptOptions): Promise => { + const query = (await ask(options.message, options.hint)).trim(); + + return resolveLineMultiSearchChoices(options, query); +}; + +export const toggleHighlightedSearchChoice = (selected: SearchSelection, choices: Array>, highlighted: number | null): void => { + if (highlighted === null) { + return; + } + + const choice = choices[highlighted]; + + if (choice) { + toggleSearchChoice(selected, choice); + } +}; diff --git a/packages/tui/src/prompts/search/read-multi/session.ts b/packages/tui/src/prompts/search/read-multi/session.ts new file mode 100644 index 00000000..09fde679 --- /dev/null +++ b/packages/tui/src/prompts/search/read-multi/session.ts @@ -0,0 +1,63 @@ +import { createMultiSearchFrameRenderer } from '#tui/prompts/search/read-multi/frame'; +import { createMultiSearchReaderState } from '#tui/prompts/search/read-multi/state'; +import type { SearchNavigationAction } from '#tui/prompts/search/keys'; +import type { SearchSelection } from '#tui/prompts/search/selection'; +import type { TypedValueState } from '#tui/typed-value/types'; +import type { MultiSearchPromptOptions } from '#tui/types'; + +import { + applyMultiSearchSessionTypedInput, + moveMultiSearchSessionHighlight, + toggleAllMultiSearchSessionDisplayed, + toggleMultiSearchSessionHighlighted, +} from '#tui/prompts/search/read-multi/session/actions'; + +export type MultiSearchReaderSession = { + applyTypedInput(key: string): Promise<{ cancelled: boolean }>; + frame(): string; + highlighted(): number | null; + move(action: SearchNavigationAction): Promise; + query(): TypedValueState; + render(): void; + selected(): SearchSelection; + selectedLabels(): string[]; + toggleAllDisplayed(): void; + toggleHighlighted(): void; +}; + +export const createMultiSearchReaderSession = async (options: MultiSearchPromptOptions): Promise> => { + const state = await createMultiSearchReaderState(options); + + const frame = createMultiSearchFrameRenderer(options, state); + + return { + async applyTypedInput(key: string) { + return applyMultiSearchSessionTypedInput(state, frame, key); + }, + frame() { + return frame.current(); + }, + highlighted() { + return state.highlighted(); + }, + async move(action) { + await moveMultiSearchSessionHighlight(state, frame, action); + }, + query() { + return state.query(); + }, + render: frame.render, + selected() { + return state.selected(); + }, + selectedLabels() { + return state.selectedLabels(); + }, + toggleAllDisplayed() { + toggleAllMultiSearchSessionDisplayed(state, frame); + }, + toggleHighlighted() { + toggleMultiSearchSessionHighlighted(state, frame); + }, + }; +}; diff --git a/packages/tui/src/prompts/search/read-multi/session/actions.ts b/packages/tui/src/prompts/search/read-multi/session/actions.ts new file mode 100644 index 00000000..7e156cfd --- /dev/null +++ b/packages/tui/src/prompts/search/read-multi/session/actions.ts @@ -0,0 +1,29 @@ +import type { SearchNavigationAction } from '#tui/prompts/search/keys'; +import type { MultiSearchFrameRenderer } from '#tui/prompts/search/read-multi/frame'; +import type { MultiSearchReaderState } from '#tui/prompts/search/read-multi/state'; + +export const applyMultiSearchSessionTypedInput = async (state: MultiSearchReaderState, frame: MultiSearchFrameRenderer, key: string): Promise<{ cancelled: boolean }> => { + const next = await state.applyTypedInput(key); + + if (!next.cancelled) { + frame.render(); + } + + return next; +}; + +export const moveMultiSearchSessionHighlight = async (state: MultiSearchReaderState, frame: MultiSearchFrameRenderer, action: SearchNavigationAction): Promise => { + await state.move(action); + + frame.render(); +}; + +export const toggleAllMultiSearchSessionDisplayed = (state: MultiSearchReaderState, frame: MultiSearchFrameRenderer): void => { + state.toggleAllDisplayed(); + frame.render(); +}; + +export const toggleMultiSearchSessionHighlighted = (state: MultiSearchReaderState, frame: MultiSearchFrameRenderer): void => { + state.toggleHighlighted(); + frame.render(); +}; diff --git a/packages/tui/src/prompts/search/read-multi/state.ts b/packages/tui/src/prompts/search/read-multi/state.ts new file mode 100644 index 00000000..aa7c12eb --- /dev/null +++ b/packages/tui/src/prompts/search/read-multi/state.ts @@ -0,0 +1,67 @@ +import { createMultiSearchReaderStateContext } from '#tui/prompts/search/read-multi/state/context'; +import { applyMultiSearchTypedInput } from '#tui/prompts/search/read-multi/state/input'; +import { selectedMultiSearchLabels } from '#tui/prompts/search/read-multi/state/labels'; +import type { SearchNavigationAction } from '#tui/prompts/search/keys'; +import type { SearchSelection } from '#tui/prompts/search/selection'; +import type { TypedValueState } from '#tui/typed-value/types'; +import type { Choice, MultiSearchPromptOptions } from '#tui/types'; + +import { + displayedMultiSearchChoices, + markedDisplayedMultiSearchChoiceIndexes, + moveDisplayedMultiSearchHighlight, + toggleAllDisplayedMultiSearchSelection, + toggleHighlightedDisplayedMultiSearchSelection, +} from '#tui/prompts/search/read-multi/state/displayed'; + +export type MultiSearchReaderState = { + applyTypedInput(key: string): Promise<{ cancelled: boolean }>; + displayedChoices(): Array>; + highlighted(): number | null; + markedChoiceIndexes(): Set; + move(action: SearchNavigationAction): Promise; + query(): TypedValueState; + selected(): SearchSelection; + selectedLabels(): string[]; + toggleAllDisplayed(): void; + toggleHighlighted(): void; +}; + +export const createMultiSearchReaderState = async (options: MultiSearchPromptOptions): Promise> => { + const { highlighted, query, selected } = await createMultiSearchReaderStateContext(options); + + return { + async applyTypedInput(key) { + return applyMultiSearchTypedInput(query, highlighted, key); + }, + displayedChoices, + highlighted() { + return highlighted.value(); + }, + markedChoiceIndexes() { + return markedDisplayedMultiSearchChoiceIndexes(query, selected); + }, + async move(action) { + await moveDisplayedMultiSearchHighlight(query, highlighted, action); + }, + query() { + return query.value(); + }, + selected() { + return selected; + }, + selectedLabels() { + return selectedMultiSearchLabels(selected); + }, + toggleAllDisplayed() { + toggleAllDisplayedMultiSearchSelection(query, selected); + }, + toggleHighlighted() { + toggleHighlightedDisplayedMultiSearchSelection(query, selected, highlighted); + }, + }; + + function displayedChoices(): Array> { + return displayedMultiSearchChoices(query); + } +}; diff --git a/packages/tui/src/prompts/search/read-multi/state/context.ts b/packages/tui/src/prompts/search/read-multi/state/context.ts new file mode 100644 index 00000000..39961a34 --- /dev/null +++ b/packages/tui/src/prompts/search/read-multi/state/context.ts @@ -0,0 +1,26 @@ +import { resolveSearchChoices } from '#tui/prompts/search/choices'; +import { createMultiSearchChoiceQuery } from '#tui/prompts/search/read-multi/choice-query'; +import { createMultiSearchHighlightState } from '#tui/prompts/search/read-multi/state/highlight'; +import { createMultiSearchSelection } from '#tui/prompts/search/read-multi/state/selection'; +import type { MultiSearchChoiceQuery } from '#tui/prompts/search/read-multi/choice-query'; +import type { MultiSearchHighlightState } from '#tui/prompts/search/read-multi/state/highlight'; +import type { SearchSelection } from '#tui/prompts/search/selection'; +import type { MultiSearchPromptOptions } from '#tui/types'; + +export type MultiSearchReaderStateContext = { + highlighted: MultiSearchHighlightState; + query: MultiSearchChoiceQuery; + selected: SearchSelection; +}; + +export const createMultiSearchReaderStateContext = async (options: MultiSearchPromptOptions): Promise> => { + const initialChoices = await resolveSearchChoices(options.options, ''); + + const selected = createMultiSearchSelection(initialChoices, options.default); + + return { + highlighted: createMultiSearchHighlightState(options.scroll), + query: createMultiSearchChoiceQuery(options, selected, initialChoices), + selected, + }; +}; diff --git a/packages/tui/src/prompts/search/read-multi/state/displayed.ts b/packages/tui/src/prompts/search/read-multi/state/displayed.ts new file mode 100644 index 00000000..8f6a7bb2 --- /dev/null +++ b/packages/tui/src/prompts/search/read-multi/state/displayed.ts @@ -0,0 +1,29 @@ +import type { SearchNavigationAction } from '#tui/prompts/search/keys'; +import type { MultiSearchChoiceQuery } from '#tui/prompts/search/read-multi/choice-query'; +import type { MultiSearchHighlightState } from '#tui/prompts/search/read-multi/state/highlight'; +import type { SearchSelection } from '#tui/prompts/search/selection'; +import type { Choice } from '#tui/types'; + +import { markedMultiSearchChoiceIndexes, toggleAllDisplayedMultiSearchChoices, toggleHighlightedMultiSearchChoice } from '#tui/prompts/search/read-multi/state/selection'; + +export const displayedMultiSearchChoices = (query: MultiSearchChoiceQuery): Array> => { + return query.displayedChoices(); +}; + +export const markedDisplayedMultiSearchChoiceIndexes = (query: MultiSearchChoiceQuery, selected: SearchSelection): Set => { + return markedMultiSearchChoiceIndexes(displayedMultiSearchChoices(query), selected); +}; + +export const moveDisplayedMultiSearchHighlight = async (query: MultiSearchChoiceQuery, highlighted: MultiSearchHighlightState, action: SearchNavigationAction): Promise => { + await query.resolveChoices(); + + highlighted.move(displayedMultiSearchChoices(query), action); +}; + +export const toggleAllDisplayedMultiSearchSelection = (query: MultiSearchChoiceQuery, selected: SearchSelection): void => { + toggleAllDisplayedMultiSearchChoices(selected, displayedMultiSearchChoices(query)); +}; + +export const toggleHighlightedDisplayedMultiSearchSelection = (query: MultiSearchChoiceQuery, selected: SearchSelection, highlighted: MultiSearchHighlightState): void => { + toggleHighlightedMultiSearchChoice(selected, displayedMultiSearchChoices(query), highlighted.value()); +}; diff --git a/packages/tui/src/prompts/search/read-multi/state/highlight.ts b/packages/tui/src/prompts/search/read-multi/state/highlight.ts new file mode 100644 index 00000000..0a7abae6 --- /dev/null +++ b/packages/tui/src/prompts/search/read-multi/state/highlight.ts @@ -0,0 +1,25 @@ +import { moveSearchHighlight } from '#tui/prompts/search/keys'; +import type { SearchNavigationAction } from '#tui/prompts/search/keys'; +import type { Choice, MultiSearchPromptOptions } from '#tui/types'; + +export type MultiSearchHighlightState = { + clear(): void; + move(choices: Array>, action: SearchNavigationAction): void; + value(): number | null; +}; + +export const createMultiSearchHighlightState = (scroll?: MultiSearchPromptOptions['scroll']): MultiSearchHighlightState => { + let highlighted: number | null = null; + + return { + clear() { + highlighted = null; + }, + move(choices, action) { + highlighted = moveSearchHighlight(choices, highlighted, action, { scroll }); + }, + value() { + return highlighted; + }, + }; +}; diff --git a/packages/tui/src/prompts/search/read-multi/state/input.ts b/packages/tui/src/prompts/search/read-multi/state/input.ts new file mode 100644 index 00000000..9812beb1 --- /dev/null +++ b/packages/tui/src/prompts/search/read-multi/state/input.ts @@ -0,0 +1,10 @@ +import type { MultiSearchChoiceQuery } from '#tui/prompts/search/read-multi/choice-query'; +import type { MultiSearchHighlightState } from '#tui/prompts/search/read-multi/state/highlight'; + +export const applyMultiSearchTypedInput = async (query: MultiSearchChoiceQuery, highlighted: MultiSearchHighlightState, key: string): Promise<{ cancelled: boolean }> => { + const next = await query.applyTypedInput(key); + + highlighted.clear(); + + return next; +}; diff --git a/packages/tui/src/prompts/search/read-multi/state/labels.ts b/packages/tui/src/prompts/search/read-multi/state/labels.ts new file mode 100644 index 00000000..ae45f121 --- /dev/null +++ b/packages/tui/src/prompts/search/read-multi/state/labels.ts @@ -0,0 +1,6 @@ +import { multiSearchSelectedLabels } from '#tui/prompts/search/read-multi/state/selection'; +import type { SearchSelection } from '#tui/prompts/search/selection'; + +export const selectedMultiSearchLabels = (selected: SearchSelection): string[] => { + return multiSearchSelectedLabels(selected); +}; diff --git a/packages/tui/src/prompts/search/read-multi/state/selection.ts b/packages/tui/src/prompts/search/read-multi/state/selection.ts new file mode 100644 index 00000000..b372db2e --- /dev/null +++ b/packages/tui/src/prompts/search/read-multi/state/selection.ts @@ -0,0 +1,24 @@ +import { createInitialSearchSelection, markedSearchChoiceIndexes, toggleSearchChoices } from '#tui/prompts/search/selection'; +import { toggleHighlightedSearchChoice } from '#tui/prompts/search/read-multi/result'; +import type { SearchSelection } from '#tui/prompts/search/selection'; +import type { Choice } from '#tui/types'; + +export const createMultiSearchSelection = (choices: Array>, defaults: T[] | undefined): SearchSelection => { + return createInitialSearchSelection(choices, defaults); +}; + +export const multiSearchSelectedLabels = (selected: SearchSelection): string[] => { + return [...selected.values()]; +}; + +export const markedMultiSearchChoiceIndexes = (choices: Array>, selected: SearchSelection): Set => { + return markedSearchChoiceIndexes(choices, selected); +}; + +export const toggleAllDisplayedMultiSearchChoices = (selected: SearchSelection, choices: Array>): void => { + toggleSearchChoices(selected, choices); +}; + +export const toggleHighlightedMultiSearchChoice = (selected: SearchSelection, choices: Array>, highlighted: number | null): void => { + toggleHighlightedSearchChoice(selected, choices, highlighted); +}; diff --git a/packages/tui/src/prompts/search/read-single.ts b/packages/tui/src/prompts/search/read-single.ts new file mode 100644 index 00000000..a21857af --- /dev/null +++ b/packages/tui/src/prompts/search/read-single.ts @@ -0,0 +1,15 @@ +import { promptEnvironment } from '#tui/environment'; +import { readSearchChoiceInteractive } from '#tui/prompts/search/read-single/interactive'; +import { lineSearchValue } from '#tui/prompts/search/read-single/result'; +import type { SearchChoiceReadResult } from '#tui/prompts/search/read-single/result'; +import type { SearchReadOptions } from '#tui/prompts/search/read-single/types'; + +export const readSearchChoice = async (options: SearchReadOptions, attempt = 0): Promise> => { + const environment = promptEnvironment(); + + if (!environment.input.readKey) { + return { cancelled: false, submitted: false, submittedLabel: '', value: await lineSearchValue(options) }; + } + + return readSearchChoiceInteractive(environment.input.readKey, options, attempt); +}; diff --git a/packages/tui/src/prompts/search/read-single/choice-query.ts b/packages/tui/src/prompts/search/read-single/choice-query.ts new file mode 100644 index 00000000..caa257c9 --- /dev/null +++ b/packages/tui/src/prompts/search/read-single/choice-query.ts @@ -0,0 +1,44 @@ +import { applyTypedKey } from '#tui/typed-value'; +import { resolveSearchChoices } from '#tui/prompts/search/choices'; +import type { SearchReadOptions } from '#tui/prompts/search/read-single/types'; +import type { TypedValueState } from '#tui/typed-value/types'; +import type { Choice } from '#tui/types'; + +export type SingleSearchChoiceQuery = { + applyTypedInput(key: string): Promise<{ cancelled: boolean }>; + choices(): Array>; + resolveChoices(): Promise; + value(): TypedValueState; +}; + +export const createSingleSearchChoiceQuery = (options: SearchReadOptions, initialChoices: Array>): SingleSearchChoiceQuery => { + let query: TypedValueState = { cursor: 0, value: '' }; + let choices: Array> = initialChoices; + + const resolveChoices = async (): Promise => { + choices = await resolveSearchChoices(options.options, query.value); + }; + + return { + async applyTypedInput(key) { + const next = applyTypedKey(query, key); + + if (next.cancelled) { + return { cancelled: true }; + } + + query = { cursor: next.cursor, value: next.value }; + + await resolveChoices(); + + return { cancelled: false }; + }, + choices() { + return choices; + }, + resolveChoices, + value() { + return query; + }, + }; +}; diff --git a/packages/tui/src/prompts/search/read-single/interactive.ts b/packages/tui/src/prompts/search/read-single/interactive.ts new file mode 100644 index 00000000..de104b9e --- /dev/null +++ b/packages/tui/src/prompts/search/read-single/interactive.ts @@ -0,0 +1,58 @@ +import { Key } from '#tui/key'; +import { clearsSearchHighlight, searchNavigationAction } from '#tui/prompts/search/keys'; +import { cancelInteractiveSearchChoice, cancelInteractiveSearchInput } from '#tui/prompts/search/read-single/interactive/cancel'; +import { defaultInteractiveSearchChoice, exhaustedInteractiveSearchChoice, highlightedInteractiveSearchChoice } from '#tui/prompts/search/read-single/interactive/result'; +import { createSearchReaderSession } from '#tui/prompts/search/read-single/session'; +import type { SearchChoiceReadResult } from '#tui/prompts/search/read-single/result'; +import type { SearchReadOptions } from '#tui/prompts/search/read-single/types'; +import type { PromptInput } from '#tui/types'; + +export const readSearchChoiceInteractive = async (readKey: NonNullable, options: SearchReadOptions, attempt: number): Promise> => { + const session = await createSearchReaderSession(options, attempt); + + session.render(); + + while (true) { + const key = await readKey(); + + if (key === null) { + return exhaustedInteractiveSearchChoice(session, options); + } + + if (key === Key.ctrlC) { + return cancelInteractiveSearchChoice(session, options); + } + + const action = searchNavigationAction(key, { controlNavigation: true, lineControls: true }); + + if (action !== null && (action !== 'first' || session.highlighted() !== null) && (action !== 'last' || session.highlighted() !== null)) { + await session.move(action); + + continue; + } + + if (clearsSearchHighlight(key) && session.highlighted() !== null) { + session.clearHighlight(); + continue; + } + + if (key === Key.enter) { + if (session.highlighted() !== null) { + return highlightedInteractiveSearchChoice(session); + } + + if (session.query().value === '' && options.hasDefault === true) { + return defaultInteractiveSearchChoice(session); + } + + session.clearHighlight(); + continue; + } + + const next = await session.applyTypedInput(key); + + if (next.cancelled) { + return cancelInteractiveSearchInput(session, options); + } + } +}; diff --git a/packages/tui/src/prompts/search/read-single/interactive/cancel.ts b/packages/tui/src/prompts/search/read-single/interactive/cancel.ts new file mode 100644 index 00000000..20c26473 --- /dev/null +++ b/packages/tui/src/prompts/search/read-single/interactive/cancel.ts @@ -0,0 +1,23 @@ +import { cancelPrompt } from '#tui/prompt'; +import { eraseRenderedFrame } from '#tui/status/frame'; +import { renderCancelledSearch } from '#tui/prompts/search/render'; +import { cancelledSearchValue } from '#tui/prompts/search/read-single/result'; +import { fallbackSearchDefault } from '#tui/prompts/search/read-single/interactive/result'; +import type { SearchReaderSession } from '#tui/prompts/search/read-single/session'; +import type { SearchChoiceReadResult } from '#tui/prompts/search/read-single/result'; +import type { SearchReadOptions } from '#tui/prompts/search/read-single/types'; + +const cancelInteractiveSearch = async (session: SearchReaderSession, options: SearchReadOptions, value: T | undefined): Promise> => { + eraseRenderedFrame(session.frame()); + renderCancelledSearch(options.message, session.query().value, options.placeholder); + + return { cancelled: true, submitted: false, submittedLabel: '', value: await cancelPrompt(value) }; +}; + +export const cancelInteractiveSearchChoice = async (session: SearchReaderSession, options: SearchReadOptions): Promise> => { + return cancelInteractiveSearch(session, options, cancelledSearchValue(session.choices(), session.highlighted(), options.default)); +}; + +export const cancelInteractiveSearchInput = async (session: SearchReaderSession, options: SearchReadOptions): Promise> => { + return cancelInteractiveSearch(session, options, fallbackSearchDefault(options)); +}; diff --git a/packages/tui/src/prompts/search/read-single/interactive/result.ts b/packages/tui/src/prompts/search/read-single/interactive/result.ts new file mode 100644 index 00000000..66d6fc97 --- /dev/null +++ b/packages/tui/src/prompts/search/read-single/interactive/result.ts @@ -0,0 +1,37 @@ +import type { SearchReaderSession } from '#tui/prompts/search/read-single/session'; +import type { SearchChoiceReadResult } from '#tui/prompts/search/read-single/result'; +import type { SearchReadOptions } from '#tui/prompts/search/read-single/types'; + +export const fallbackSearchDefault = (options: SearchReadOptions): T | undefined => { + return options.hasDefault === true ? options.default : undefined; +}; + +export const exhaustedInteractiveSearchChoice = async (session: SearchReaderSession, options: SearchReadOptions): Promise> => { + if (session.highlighted() !== null) { + return highlightedInteractiveSearchChoice(session); + } + + if (session.query().value === '' && options.hasDefault === true) { + const selected = await defaultInteractiveSearchChoice(session); + + if (selected.value !== undefined) { + return selected; + } + } + + const choice = session.choices().find((candidate) => !candidate.disabled); + + return { cancelled: false, frame: session.frame(), submitted: choice !== undefined, submittedLabel: choice?.label ?? '', value: choice?.value ?? fallbackSearchDefault(options) }; +}; + +export const highlightedInteractiveSearchChoice = (session: SearchReaderSession): SearchChoiceReadResult => { + const selected = session.selectedSelection(); + + return { cancelled: false, frame: session.frame(), submitted: selected.submitted, submittedLabel: selected.label, value: selected.value }; +}; + +export const defaultInteractiveSearchChoice = async (session: SearchReaderSession): Promise> => { + const selected = await session.defaultSelection(); + + return { cancelled: false, frame: session.frame(), submitted: selected.submitted, submittedLabel: selected.label, value: selected.value }; +}; diff --git a/packages/tui/src/prompts/search/read-single/result.ts b/packages/tui/src/prompts/search/read-single/result.ts new file mode 100644 index 00000000..198e0fb2 --- /dev/null +++ b/packages/tui/src/prompts/search/read-single/result.ts @@ -0,0 +1,40 @@ +import { ask } from '#tui/prompt'; +import { choiceByValue } from '#tui/concerns/choices'; +import { resolveLineSearchChoice } from '#tui/prompts/search/line-mode'; +import type { Choice, SearchPromptOptions } from '#tui/types'; + +export type SearchChoiceReadResult = { + cancelled: boolean; + frame?: string; + submitted: boolean; + submittedLabel: string; + value: T | undefined; +}; + +export const lineSearchValue = async (options: SearchPromptOptions): Promise => { + const query = (await ask(options.message, options.hint)).trim(); + + return resolveLineSearchChoice(options, query); +}; + +export const selectedSearchValue = (choices: Array>, highlighted: number | null): T | undefined => { + if (highlighted === null) { + return undefined; + } + + const choice = choices[highlighted]; + + return choice?.disabled ? undefined : choice?.value; +}; + +export const defaultSearchChoice = (choices: Array>, fallback: T | undefined, hasDefault = false): Choice | undefined => { + if (!hasDefault) { + return undefined; + } + + return choiceByValue(choices, fallback); +}; + +export const cancelledSearchValue = (choices: Array>, highlighted: number | null, fallback?: T): T | undefined => { + return selectedSearchValue(choices, highlighted) ?? fallback; +}; diff --git a/packages/tui/src/prompts/search/read-single/session.ts b/packages/tui/src/prompts/search/read-single/session.ts new file mode 100644 index 00000000..8994dad9 --- /dev/null +++ b/packages/tui/src/prompts/search/read-single/session.ts @@ -0,0 +1,60 @@ +import { createSearchReaderSessionFrame } from '#tui/prompts/search/read-single/session/frame'; +import { createSingleSearchReaderState } from '#tui/prompts/search/read-single/state'; +import type { SearchNavigationAction } from '#tui/prompts/search/keys'; +import type { SearchReaderSelection, SearchReadOptions } from '#tui/prompts/search/read-single/types'; +import type { TypedValueState } from '#tui/typed-value/types'; +import type { Choice } from '#tui/types'; + +import { applySearchSessionTypedInput, clearSearchSessionHighlight, moveSearchSessionHighlight } from '#tui/prompts/search/read-single/session/actions'; + +export type SearchReaderSession = { + applyTypedInput(key: string): Promise<{ cancelled: boolean }>; + choices(): Array>; + clearHighlight(): void; + defaultSelection(): Promise>; + frame(): string; + highlighted(): number | null; + move(action: SearchNavigationAction): Promise; + query(): TypedValueState; + render(): void; + selectedSelection(): SearchReaderSelection; +}; + +export const createSearchReaderSession = async (options: SearchReadOptions, attempt: number): Promise> => { + const state = await createSingleSearchReaderState(options, attempt); + + const frame = createSearchReaderSessionFrame(options, state); + + return { + async applyTypedInput(key: string) { + return applySearchSessionTypedInput(state, frame, key); + }, + choices() { + return state.choices(); + }, + clearHighlight() { + clearSearchSessionHighlight(state, frame); + }, + async defaultSelection() { + return state.defaultSelection(); + }, + frame() { + return frame.value(); + }, + highlighted() { + return state.highlighted(); + }, + async move(action) { + await moveSearchSessionHighlight(state, frame, action); + }, + query() { + return state.query(); + }, + render() { + frame.render(); + }, + selectedSelection() { + return state.selectedSelection(); + }, + }; +}; diff --git a/packages/tui/src/prompts/search/read-single/session/actions.ts b/packages/tui/src/prompts/search/read-single/session/actions.ts new file mode 100644 index 00000000..2d60f6a8 --- /dev/null +++ b/packages/tui/src/prompts/search/read-single/session/actions.ts @@ -0,0 +1,24 @@ +import type { SearchNavigationAction } from '#tui/prompts/search/keys'; +import type { SearchReaderSessionFrame } from '#tui/prompts/search/read-single/session/frame'; +import type { SingleSearchReaderState } from '#tui/prompts/search/read-single/state'; + +export const applySearchSessionTypedInput = async (state: SingleSearchReaderState, frame: SearchReaderSessionFrame, key: string): Promise<{ cancelled: boolean }> => { + const next = await state.applyTypedInput(key); + + if (!next.cancelled) { + frame.render(); + } + + return next; +}; + +export const clearSearchSessionHighlight = (state: SingleSearchReaderState, frame: SearchReaderSessionFrame): void => { + state.clearHighlight(); + frame.render(); +}; + +export const moveSearchSessionHighlight = async (state: SingleSearchReaderState, frame: SearchReaderSessionFrame, action: SearchNavigationAction): Promise => { + await state.move(action); + + frame.render(); +}; diff --git a/packages/tui/src/prompts/search/read-single/session/frame.ts b/packages/tui/src/prompts/search/read-single/session/frame.ts new file mode 100644 index 00000000..62895009 --- /dev/null +++ b/packages/tui/src/prompts/search/read-single/session/frame.ts @@ -0,0 +1,28 @@ +import { eraseRenderedFrame } from '#tui/status/frame'; +import { renderSearchChoices } from '#tui/prompts/search/render'; +import type { SingleSearchReaderState } from '#tui/prompts/search/read-single/state'; +import type { SearchReadOptions } from '#tui/prompts/search/read-single/types'; + +export type SearchReaderSessionFrame = { + render(): void; + value(): string; +}; + +export const createSearchReaderSessionFrame = (options: SearchReadOptions, state: SingleSearchReaderState): SearchReaderSessionFrame => { + let frame = ''; + + return { + render() { + if (frame.length > 0) { + eraseRenderedFrame(frame); + } + + const query = state.query(); + + frame = renderSearchChoices(options.message, query.value, query.cursor, state.choices(), state.highlighted(), new Set(), [], options.scroll, options.info, false, options.placeholder); + }, + value() { + return frame; + }, + }; +}; diff --git a/packages/tui/src/prompts/search/read-single/state.ts b/packages/tui/src/prompts/search/read-single/state.ts new file mode 100644 index 00000000..e9ee2506 --- /dev/null +++ b/packages/tui/src/prompts/search/read-single/state.ts @@ -0,0 +1,59 @@ +import { resolveSearchChoices } from '#tui/prompts/search/choices'; +import { createSingleSearchChoiceQuery } from '#tui/prompts/search/read-single/choice-query'; +import { createSingleSearchHighlightState } from '#tui/prompts/search/read-single/state/highlight'; +import { defaultSingleSearchSelection, selectedSingleSearchSelection } from '#tui/prompts/search/read-single/state/selection'; +import type { SearchNavigationAction } from '#tui/prompts/search/keys'; +import type { SearchReaderSelection, SearchReadOptions } from '#tui/prompts/search/read-single/types'; +import type { TypedValueState } from '#tui/typed-value/types'; +import type { Choice } from '#tui/types'; + +export type SingleSearchReaderState = { + applyTypedInput(key: string): Promise<{ cancelled: boolean }>; + choices(): Array>; + clearHighlight(): void; + defaultSelection(): Promise>; + highlighted(): number | null; + move(action: SearchNavigationAction): Promise; + query(): TypedValueState; + selectedSelection(): SearchReaderSelection; +}; + +export const createSingleSearchReaderState = async (options: SearchReadOptions, attempt: number): Promise> => { + const initialChoices = await resolveSearchChoices(options.options, ''); + + const query = createSingleSearchChoiceQuery(options, initialChoices); + const highlighted = createSingleSearchHighlightState(initialChoices, attempt, options.scroll); + + return { + async applyTypedInput(key) { + const next = await query.applyTypedInput(key); + + highlighted.clear(); + + return next; + }, + choices() { + return query.choices(); + }, + clearHighlight() { + highlighted.clear(); + }, + async defaultSelection() { + return defaultSingleSearchSelection(options, query); + }, + highlighted() { + return highlighted.value(); + }, + async move(action) { + await query.resolveChoices(); + + highlighted.move(query.choices(), action); + }, + query() { + return query.value(); + }, + selectedSelection() { + return selectedSingleSearchSelection(query.choices(), highlighted.value()); + }, + }; +}; diff --git a/packages/tui/src/prompts/search/read-single/state/highlight.ts b/packages/tui/src/prompts/search/read-single/state/highlight.ts new file mode 100644 index 00000000..9e62b368 --- /dev/null +++ b/packages/tui/src/prompts/search/read-single/state/highlight.ts @@ -0,0 +1,26 @@ +import { moveSearchHighlight } from '#tui/prompts/search/keys'; +import { initialRetriedSearchHighlight } from '#tui/prompts/search/navigation'; +import type { SearchNavigationAction } from '#tui/prompts/search/keys'; +import type { Choice } from '#tui/types'; + +export type SingleSearchHighlightState = { + clear(): void; + move(choices: Array>, action: SearchNavigationAction): void; + value(): number | null; +}; + +export const createSingleSearchHighlightState = (initialChoices: Array>, attempt: number, scroll?: number): SingleSearchHighlightState => { + let highlighted: number | null = initialRetriedSearchHighlight(initialChoices, attempt); + + return { + clear() { + highlighted = null; + }, + move(choices, action) { + highlighted = moveSearchHighlight(choices, highlighted, action, { attempt, retryFirst: true, scroll }); + }, + value() { + return highlighted; + }, + }; +}; diff --git a/packages/tui/src/prompts/search/read-single/state/selection.ts b/packages/tui/src/prompts/search/read-single/state/selection.ts new file mode 100644 index 00000000..390824da --- /dev/null +++ b/packages/tui/src/prompts/search/read-single/state/selection.ts @@ -0,0 +1,25 @@ +import { choiceValueEquals } from '#tui/concerns/choices'; +import { defaultSearchChoice, selectedSearchValue } from '#tui/prompts/search/read-single/result'; +import type { SingleSearchChoiceQuery } from '#tui/prompts/search/read-single/choice-query'; +import type { SearchReaderSelection, SearchReadOptions } from '#tui/prompts/search/read-single/types'; +import type { Choice } from '#tui/types'; + +export const defaultSingleSearchSelection = async (options: SearchReadOptions, query: SingleSearchChoiceQuery): Promise> => { + await query.resolveChoices(); + + if (query.value().value !== '' || options.hasDefault !== true) { + return { label: '', submitted: false, value: undefined }; + } + + const choice = defaultSearchChoice(query.choices(), options.default, options.hasDefault); + const disabledChoice = query.choices().find((candidate) => candidate.disabled && choiceValueEquals(candidate.value, options.default)); + + return { label: choice?.label ?? '', submitted: choice !== undefined, value: disabledChoice ? undefined : (choice?.value ?? options.default) }; +}; + +export const selectedSingleSearchSelection = (choices: Array>, highlighted: number | null): SearchReaderSelection => { + const choice = highlighted === null ? undefined : choices[highlighted]; + const value = selectedSearchValue(choices, highlighted); + + return { label: choice?.label ?? '', submitted: choice !== undefined && value !== undefined, value }; +}; diff --git a/packages/tui/src/prompts/search/read-single/types.ts b/packages/tui/src/prompts/search/read-single/types.ts new file mode 100644 index 00000000..867bde40 --- /dev/null +++ b/packages/tui/src/prompts/search/read-single/types.ts @@ -0,0 +1,11 @@ +import type { SearchPromptOptions } from '#tui/types'; + +export type SearchReadOptions = SearchPromptOptions & { + hasDefault?: boolean; +}; + +export type SearchReaderSelection = { + label: string; + submitted: boolean; + value: T | undefined; +}; diff --git a/packages/tui/src/prompts/search/render-body.ts b/packages/tui/src/prompts/search/render-body.ts new file mode 100644 index 00000000..71f70664 --- /dev/null +++ b/packages/tui/src/prompts/search/render-body.ts @@ -0,0 +1 @@ +export { renderSearchBody } from '#tui/prompts/search/body/render'; diff --git a/packages/tui/src/prompts/search/render.ts b/packages/tui/src/prompts/search/render.ts new file mode 100644 index 00000000..fe112f2f --- /dev/null +++ b/packages/tui/src/prompts/search/render.ts @@ -0,0 +1,50 @@ +import { promptEnvironment } from '#tui/environment'; +import { joinedInfoDetails, resolveInfo } from '#tui/concerns/info'; +import { renderSearchBody } from '#tui/prompts/search/render-body'; +import { renderBox } from '#tui/theme/box'; +import { cyan, dim, red, strikethrough } from '#tui/theme/styles'; +import type { Choice, MultiSearchPromptOptions, SearchPromptOptions } from '#tui/types'; + +export const renderSearchChoices = ( + message: string, + query: string, + cursor: number, + choices: Array>, + highlighted: number | null, + marked: Set = new Set(), + selectedLabels: string[] = [], + scroll?: number, + info?: SearchPromptOptions['info'] | MultiSearchPromptOptions['info'], + showSelectedSummary = false, + placeholder = '', +): string => { + const text = resolveInfo(info, highlighted === null ? null : (choices[highlighted]?.value ?? null)); + const summary = showSelectedSummary ? selectedSummary(selectedLabels.length, selectedLabels.length - marked.size) : ''; + const details = joinedInfoDetails(text, summary); + const frame = `${renderBox({ body: renderSearchBody(query, cursor, placeholder, choices, highlighted, marked, scroll, showSelectedSummary), borderStyle: cyan, info: details, title: cyan(message) })}\n`; + + promptEnvironment().output.write(frame); + + return frame; +}; + +export const renderSubmittedSearchChoice = (message: string, label: string): void => { + promptEnvironment().output.write(`${renderBox({ body: label, title: dim(message) })}\n`); +}; + +export const renderSubmittedSearchChoices = (message: string, labels: string[]): void => { + promptEnvironment().output.write(`${renderBox({ body: labels.join('\n'), title: dim(message) })}\n`); +}; + +export const renderCancelledSearch = (message: string, query: string, placeholder = ''): void => { + const body = strikethrough(dim(query.length > 0 ? query : placeholder)); + + promptEnvironment().output.write(`${renderBox({ body, borderStyle: red, title: dim(message) })}\n`); + promptEnvironment().error.write(`${red(' ⚠ Cancelled.')}\n`); +}; + +const selectedSummary = (selectedCount: number, hiddenCount: number): string => { + const hidden = hiddenCount > 0 ? ` (${hiddenCount} hidden)` : ''; + + return `${selectedCount} selected${hidden}`; +}; diff --git a/packages/tui/src/prompts/search/run.ts b/packages/tui/src/prompts/search/run.ts new file mode 100644 index 00000000..5da5bf25 --- /dev/null +++ b/packages/tui/src/prompts/search/run.ts @@ -0,0 +1,50 @@ +import { promptUntilValid, promptWithFallback, PromptValidationError } from '#tui/prompt'; +import { activePromptFrame } from '#tui/prompt/active-frame'; +import { createPromptSubmissionState } from '#tui/prompt/submission'; +import { preserveSearchRetryDefault, transformSearchValue, transformedSearchDefault } from '#tui/prompts/search/defaults'; +import { readSearchChoice } from '#tui/prompts/search/read-single'; +import { renderSubmittedSearchChoice } from '#tui/prompts/search/render'; +import type { NormalizedSearchPromptOptions } from '#tui/prompts/search/defaults'; +import type { SearchPromptOptions } from '#tui/types'; + +export const runSearchPrompt = async (options: NormalizedSearchPromptOptions): Promise => { + const validationOptions: SearchPromptOptions = { + ...options, + default: await transformedSearchDefault(options), + }; + + const activeFrame = activePromptFrame(); + const submission = createPromptSubmissionState(''); + + return promptWithFallback('search', options, () => + promptUntilValid( + validationOptions, + async (attempt) => { + submission.reset(); + + const selected = await readSearchChoice(options, attempt); + + activeFrame.set(selected.frame); + + if (selected.value === undefined) { + throw new PromptValidationError('Please select a valid option.'); + } + + submission.capture(selected.submitted, selected.cancelled, selected.submittedLabel); + preserveSearchRetryDefault(options, selected.value); + + return transformSearchValue(options, selected.value); + }, + () => { + activeFrame.clear(); + submission.render((label) => { + renderSubmittedSearchChoice(options.message, label); + }); + }, + () => { + activeFrame.clear(); + submission.reset(); + }, + ), + ); +}; diff --git a/packages/tui/src/prompts/search/search.ts b/packages/tui/src/prompts/search/search.ts new file mode 100644 index 00000000..8c70533e --- /dev/null +++ b/packages/tui/src/prompts/search/search.ts @@ -0,0 +1,31 @@ +import { normalizeSearchPromptOptions } from '#tui/prompts/search/options'; +import { runSearchPrompt } from '#tui/prompts/search/run'; +import type { ChoiceOptions, SearchPromptOptions } from '#tui/types'; + +export function search(options: SearchPromptOptions): Promise; + +export function search( + label: string, + options: SearchPromptOptions['options'], + placeholder?: string, + scroll?: number, + validate?: SearchPromptOptions['validate'], + hint?: string, + required?: SearchPromptOptions['required'], + transform?: SearchPromptOptions['transform'], + info?: SearchPromptOptions['info'], +): Promise; + +export async function search( + optionsOrLabel: SearchPromptOptions | string, + source?: ChoiceOptions | ((query: string) => Promise> | ChoiceOptions), + placeholder = '', + scroll = 5, + validate: SearchPromptOptions['validate'] = undefined, + hint = '', + required: SearchPromptOptions['required'] = true, + transform: SearchPromptOptions['transform'] = undefined, + info: SearchPromptOptions['info'] = '', +): Promise { + return runSearchPrompt(normalizeSearchPromptOptions(optionsOrLabel, source, placeholder, scroll, validate, hint, required, transform, info)); +} diff --git a/packages/tui/src/prompts/search/selection.ts b/packages/tui/src/prompts/search/selection.ts new file mode 100644 index 00000000..b9906c7b --- /dev/null +++ b/packages/tui/src/prompts/search/selection.ts @@ -0,0 +1,73 @@ +import { choiceValueEquals } from '#tui/concerns/choices'; +import type { Choice } from '#tui/types'; + +export type SearchSelection = Map; + +export const createInitialSearchSelection = (choices: Array>, defaults: T[] = []): SearchSelection => { + const selected = new Map(); + + for (const value of defaults) { + const choice = choices.find((candidate) => choiceValueEquals(candidate.value, value)); + + if (choice?.disabled) { + continue; + } + + selected.set(choice?.value ?? value, choice?.label ?? String(value)); + } + + return selected; +}; + +export const displayedSearchChoices = (choices: Array>, selected: SearchSelection, query: string): Array> => { + if (query.trim() !== '') { + return choices; + } + + const selectedChoices = [...selected.entries()].filter(([value]) => !choices.some((choice) => choiceValueEquals(choice.value, value))).map(([value, label]) => ({ label, value })); + + return [...selectedChoices, ...choices]; +}; + +export const selectableSearchChoices = (choices: Array>): Array> => choices.filter((choice) => !choice.disabled); + +export const markedSearchChoiceIndexes = (choices: Array>, selected: SearchSelection): Set => { + return new Set(choices.flatMap((choice, index) => (!choice.disabled && [...selected.keys()].some((value) => choiceValueEquals(choice.value, value)) ? [index] : []))); +}; + +export const toggleSearchChoice = (selected: SearchSelection, choice: Choice): void => { + if (choice.disabled) { + return; + } + + const selectedValue = [...selected.keys()].find((value) => choiceValueEquals(choice.value, value)); + + if (selectedValue !== undefined) { + selected.delete(selectedValue); + + return; + } + + selected.set(choice.value, choice.label); +}; + +export const toggleSearchChoices = (selected: SearchSelection, choices: Array>): void => { + const selectable = selectableSearchChoices(choices); + const allSelected = selectable.every((choice) => [...selected.keys()].some((value) => choiceValueEquals(choice.value, value))); + + if (allSelected) { + for (const choice of selectable) { + const selectedValue = [...selected.keys()].find((value) => choiceValueEquals(choice.value, value)); + + if (selectedValue !== undefined) { + selected.delete(selectedValue); + } + } + + return; + } + + for (const choice of selectable) { + selected.set(choice.value, choice.label); + } +}; diff --git a/packages/tui/src/prompts/search/validators/defaults.ts b/packages/tui/src/prompts/search/validators/defaults.ts new file mode 100644 index 00000000..01179151 --- /dev/null +++ b/packages/tui/src/prompts/search/validators/defaults.ts @@ -0,0 +1,13 @@ +import { z } from 'zod'; + +const multiSearchDefaultSchema = (): z.ZodType => z.array(z.unknown()) as z.ZodType; + +export const parseMultiSearchDefault = (value: unknown): T[] => { + const parsed = multiSearchDefaultSchema().safeParse(value); + + if (!parsed.success) { + throw new TypeError('Default selected values must be an array.'); + } + + return parsed.data; +}; diff --git a/packages/tui/src/prompts/search/validators/options.ts b/packages/tui/src/prompts/search/validators/options.ts new file mode 100644 index 00000000..046e1ff3 --- /dev/null +++ b/packages/tui/src/prompts/search/validators/options.ts @@ -0,0 +1,10 @@ +import { z } from 'zod'; +import type { SearchPromptOptions } from '#tui/types'; + +const disallowedRequiredSchema = z.literal(false); + +export const assertSearchOptions = (options: SearchPromptOptions): void => { + if (disallowedRequiredSchema.safeParse(options.required).success) { + throw new TypeError('Argument [required] must be true or a string.'); + } +}; diff --git a/packages/tui/src/prompts/search/validators/overload.ts b/packages/tui/src/prompts/search/validators/overload.ts new file mode 100644 index 00000000..36c27cd1 --- /dev/null +++ b/packages/tui/src/prompts/search/validators/overload.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; + +const searchPromptLabelSchema = z.string(); + +export const isSearchPromptLabel = (value: unknown): value is string => { + return searchPromptLabelSchema.safeParse(value).success; +}; diff --git a/packages/tui/src/prompts/search/validators/source.ts b/packages/tui/src/prompts/search/validators/source.ts new file mode 100644 index 00000000..a3c40101 --- /dev/null +++ b/packages/tui/src/prompts/search/validators/source.ts @@ -0,0 +1,40 @@ +import { z } from 'zod'; +import { choiceOptionsSchema } from '#tui/concerns/validators/choice'; +import { functionSchema } from '#tui/validators/function'; +import type { ChoiceOptions, MaybePromise, SearchPromptOptions } from '#tui/types'; + +type SearchChoiceSourceCallback = (query: string) => MaybePromise>; + +const searchChoiceSourceCallbackSchema = (): z.ZodType> => functionSchema>(); + +const searchChoiceSourceSchema = (): z.ZodType | SearchChoiceSourceCallback> => z.union([choiceOptionsSchema(), searchChoiceSourceCallbackSchema()]); + +export const isSearchChoiceSourceCallback = (source: SearchPromptOptions['options']): source is SearchChoiceSourceCallback => { + return searchChoiceSourceCallbackSchema().safeParse(source).success; +}; + +export const parseSearchChoiceSource = (source: unknown): ChoiceOptions | SearchChoiceSourceCallback => { + const parsed = searchChoiceSourceSchema().safeParse(source); + + if (!parsed.success) { + throw new TypeError('Search choices must be an array, record, or callback.'); + } + + return parsed.data; +}; + +export const resolveSearchChoiceSourceOptions = async (source: SearchPromptOptions['options'], query: string): Promise> => { + const parsed = parseSearchChoiceSource(source); + + if (!isSearchChoiceSourceCallback(parsed)) { + return parsed; + } + + const options = choiceOptionsSchema().safeParse(await parsed(query)); + + if (!options.success) { + throw new TypeError('Search callbacks must return choice options.'); + } + + return options.data; +}; diff --git a/packages/tui/src/prompts/select/confirm-options.ts b/packages/tui/src/prompts/select/confirm-options.ts new file mode 100644 index 00000000..f60f9e74 --- /dev/null +++ b/packages/tui/src/prompts/select/confirm-options.ts @@ -0,0 +1,66 @@ +import { hasConfirmDefaultArgument, isSelectPromptLabel, parseConfirmDefault } from '#tui/prompts/select/validators/overload'; +import { hasPromptDefault } from '#tui/validators/default'; +import type { ConfirmPromptOptions } from '#tui/types'; + +export type NormalizedConfirmPromptOptions = ConfirmPromptOptions & { + default: boolean; + hasDefault: boolean; +}; + +export const confirmHasDefault = (messageOrOptions: string | ConfirmPromptOptions, argumentCount: number, defaultValue?: boolean): boolean => { + return isSelectPromptLabel(messageOrOptions) ? hasConfirmDefaultArgument(argumentCount, defaultValue) : hasPromptDefault(messageOrOptions); +}; + +export const preserveConfirmRetryDefault = (options: NormalizedConfirmPromptOptions, value: boolean | undefined): void => { + if (value === undefined) { + return; + } + + options.default = value; + options.hasDefault = true; +}; + +export const normalizeConfirmPromptOptions = ( + messageOrOptions: string | ConfirmPromptOptions, + defaultValue: boolean, + yes: string, + no: string, + required: boolean | string, + validate: ConfirmPromptOptions['validate'], + hint: string, + transform: ConfirmPromptOptions['transform'], + hasDefault: boolean, +): NormalizedConfirmPromptOptions => { + if (!isSelectPromptLabel(messageOrOptions)) { + return { + ...messageOrOptions, + default: hasDefault ? parseConfirmDefault(messageOrOptions.default, true) : true, + hasDefault, + }; + } + + return { + default: hasDefault ? defaultValue : true, + hasDefault, + hint, + label: messageOrOptions, + message: messageOrOptions, + no, + required, + transform, + validate, + yes, + }; +}; + +export const transformConfirmValue = async (options: Pick, value: boolean): Promise => { + return options.transform ? options.transform(value) : value; +}; + +export const transformedConfirmDefault = async (options: NormalizedConfirmPromptOptions): Promise => { + try { + return await transformConfirmValue(options, options.default); + } catch { + return options.default; + } +}; diff --git a/packages/tui/src/prompts/select/confirm-run.ts b/packages/tui/src/prompts/select/confirm-run.ts new file mode 100644 index 00000000..bca1688f --- /dev/null +++ b/packages/tui/src/prompts/select/confirm-run.ts @@ -0,0 +1,45 @@ +import { promptUntilValid, promptWithFallback } from '#tui/prompt'; +import { activePromptFrame } from '#tui/prompt/active-frame'; +import { createPromptSubmissionState } from '#tui/prompt/submission'; +import { preserveConfirmRetryDefault, transformedConfirmDefault, transformConfirmValue } from '#tui/prompts/select/confirm-options'; +import { readConfirm } from '#tui/prompts/select/read-confirm'; +import { renderSubmittedConfirm } from '#tui/prompts/select/render-confirm'; +import type { NormalizedConfirmPromptOptions } from '#tui/prompts/select/confirm-options'; +import type { ConfirmPromptOptions } from '#tui/types'; + +export const runConfirmPrompt = async (options: NormalizedConfirmPromptOptions): Promise => { + const validationOptions: ConfirmPromptOptions = { + ...options, + default: await transformedConfirmDefault(options), + }; + + const activeFrame = activePromptFrame(); + const submission = createPromptSubmissionState(undefined); + + return promptWithFallback('confirm', options, () => + promptUntilValid( + validationOptions, + async () => { + submission.reset(); + + const answer = await readConfirm(options); + + activeFrame.set(answer.frame); + submission.capture(answer.submitted, answer.cancelled, undefined); + + return transformConfirmValue(options, answer.value); + }, + (value) => { + activeFrame.clear(); + submission.render(() => { + renderSubmittedConfirm(options, value); + }); + }, + (value) => { + preserveConfirmRetryDefault(options, value); + activeFrame.clear(); + submission.reset(); + }, + ), + ); +}; diff --git a/packages/tui/src/prompts/select/confirm.ts b/packages/tui/src/prompts/select/confirm.ts new file mode 100644 index 00000000..e95786a7 --- /dev/null +++ b/packages/tui/src/prompts/select/confirm.ts @@ -0,0 +1,29 @@ +import { confirmHasDefault, normalizeConfirmPromptOptions } from '#tui/prompts/select/confirm-options'; +import { runConfirmPrompt } from '#tui/prompts/select/confirm-run'; +import type { ConfirmPromptOptions } from '#tui/types'; + +export function confirm(options: ConfirmPromptOptions): Promise; + +export function confirm( + label: string, + defaultValue?: boolean, + yes?: string, + no?: string, + required?: boolean | string, + validate?: ConfirmPromptOptions['validate'], + hint?: string, + transform?: ConfirmPromptOptions['transform'], +): Promise; + +export async function confirm( + message: string | ConfirmPromptOptions, + defaultValue = true, + yes = 'Yes', + no = 'No', + required: boolean | string = false, + validate: ConfirmPromptOptions['validate'] = undefined, + hint = '', + transform: ConfirmPromptOptions['transform'] = undefined, +): Promise { + return runConfirmPrompt(normalizeConfirmPromptOptions(message, defaultValue, yes, no, required, validate, hint, transform, confirmHasDefault(message, arguments.length, defaultValue))); +} diff --git a/packages/tui/src/prompts/select/defaults.ts b/packages/tui/src/prompts/select/defaults.ts new file mode 100644 index 00000000..39502cca --- /dev/null +++ b/packages/tui/src/prompts/select/defaults.ts @@ -0,0 +1,41 @@ +import { parsePromptDefault } from '#tui/validators/default'; +import type { MultiSelectPromptOptions, SelectPromptOptions } from '#tui/types'; + +export type NormalizedSelectPromptOptions = SelectPromptOptions & { + hasDefault: boolean; +}; + +export const transformSelectValue = async (options: Pick, 'transform'>, value: T): Promise => { + return options.transform ? options.transform(value) : value; +}; + +export const preserveSelectRetryDefault = (options: NormalizedSelectPromptOptions, value: T): void => { + options.default = value; + options.hasDefault = true; +}; + +export const preserveMultiSelectRetryDefault = (options: MultiSelectPromptOptions & { default: T[] }, value: T[]): void => { + options.default = value; +}; + +export const transformedSelectDefault = async (options: NormalizedSelectPromptOptions): Promise => { + if (!options.hasDefault) { + return undefined; + } + + const rawDefault = parsePromptDefault(options.default); + + try { + return await transformSelectValue(options, rawDefault); + } catch { + return rawDefault; + } +}; + +export const transformedMultiSelectDefault = async (options: MultiSelectPromptOptions & { default: T[] }): Promise => { + try { + return options.transform ? await options.transform(options.default) : options.default; + } catch { + return options.default; + } +}; diff --git a/packages/tui/src/prompts/select/index.ts b/packages/tui/src/prompts/select/index.ts new file mode 100644 index 00000000..9166d706 --- /dev/null +++ b/packages/tui/src/prompts/select/index.ts @@ -0,0 +1,3 @@ +export { confirm } from '#tui/prompts/select/confirm'; +export { multiselect } from '#tui/prompts/select/multiselect'; +export { select } from '#tui/prompts/select/select'; diff --git a/packages/tui/src/prompts/select/keys.ts b/packages/tui/src/prompts/select/keys.ts new file mode 100644 index 00000000..36a12d9f --- /dev/null +++ b/packages/tui/src/prompts/select/keys.ts @@ -0,0 +1,62 @@ +import { Key, oneOf } from '#tui/key'; +import { firstEnabledIndex, nextEnabledIndex } from '#tui/concerns/choices'; +import { lastEnabledChoiceIndex, nextChoiceKeys, pageEnabledChoiceIndex, previousChoiceKeys } from '#tui/prompts/select/navigation'; +import type { Choice } from '#tui/types'; + +export type SelectNavigationAction = 'first' | 'last' | 'next' | 'page-next' | 'page-previous' | 'previous'; + +type SelectNavigationOptions = { + lineControls?: boolean; +}; + +export const selectNavigationAction = (key: string, options: SelectNavigationOptions = {}): SelectNavigationAction | null => { + if (nextChoiceKeys(key)) { + return 'next'; + } + + if (previousChoiceKeys(key)) { + return 'previous'; + } + + if (key === Key.pageDown) { + return 'page-next'; + } + + if (key === Key.pageUp) { + return 'page-previous'; + } + + if (oneOf(options.lineControls === true ? [Key.home, Key.ctrlA] : [Key.home], key)) { + return 'first'; + } + + if (oneOf(options.lineControls === true ? [Key.end, Key.ctrlE] : [Key.end], key)) { + return 'last'; + } + + return null; +}; + +export const moveSelectHighlight = (choices: Array>, selected: number, action: SelectNavigationAction, scroll?: number): number => { + if (action === 'next') { + return nextEnabledIndex(choices, selected, 1); + } + + if (action === 'previous') { + return nextEnabledIndex(choices, selected, -1); + } + + if (action === 'page-next') { + return pageEnabledChoiceIndex(choices, selected, 1, scroll); + } + + if (action === 'page-previous') { + return pageEnabledChoiceIndex(choices, selected, -1, scroll); + } + + if (action === 'first') { + return firstEnabledIndex(choices); + } + + return lastEnabledChoiceIndex(choices); +}; diff --git a/packages/tui/src/prompts/select/multiple.ts b/packages/tui/src/prompts/select/multiple.ts new file mode 100644 index 00000000..20d20943 --- /dev/null +++ b/packages/tui/src/prompts/select/multiple.ts @@ -0,0 +1,59 @@ +import { choiceValueEquals, findChoice } from '#tui/concerns/choices'; +import { parseChoiceAnswerList } from '#tui/concerns/validators/choice-answer'; +import { PromptValidationError } from '#tui/prompt'; +import type { Choice } from '#tui/types'; + +export const choicesFromCommaSeparated = (choices: Array>, answer: string): T[] => { + const parts = parseChoiceAnswerList(answer); + + const selected = parts.map((part) => findChoice(choices, part)).filter((choice): choice is Choice => choice !== undefined && !choice.disabled); + + if (selected.length !== parts.length) { + throw new PromptValidationError('Please select valid options.'); + } + + return selected.map((choice) => choice.value); +}; + +export const markedChoiceIndexes = (choices: Array>, defaults: T[] = []): Set => { + return new Set(choices.flatMap((choice, index) => (!choice.disabled && defaults.some((value) => choiceValueEquals(choice.value, value)) ? [index] : []))); +}; + +export const markedChoiceValues = (choices: Array>, marked: Set): T[] => { + return [...marked].map((index) => choices[index]?.value).filter((value): value is T => value !== undefined); +}; + +export const defaultChoiceValues = (choices: Array>, defaults: T[] = []): T[] => { + return defaults.flatMap((value) => { + const choice = choices.find((candidate) => choiceValueEquals(candidate.value, value)); + + if (choice?.disabled) { + return []; + } + + return [choice?.value ?? value]; + }); +}; + +export const toggleMarkedChoice = (choices: Array>, marked: Set, index: number): Set => { + const next = new Set(marked); + + if (next.has(index)) { + next.delete(index); + } else if (!choices[index]?.disabled) { + next.add(index); + } + + return next; +}; + +export const toggleAllEnabledChoices = (choices: Array>, marked: Set): Set => { + const enabledIndexes = choices.flatMap((choice, index) => (choice.disabled ? [] : [index])); + const allEnabledMarked = enabledIndexes.every((index) => marked.has(index)); + + if (allEnabledMarked) { + return new Set(); + } + + return new Set(enabledIndexes); +}; diff --git a/packages/tui/src/prompts/select/multiselect.ts b/packages/tui/src/prompts/select/multiselect.ts new file mode 100644 index 00000000..8de52e50 --- /dev/null +++ b/packages/tui/src/prompts/select/multiselect.ts @@ -0,0 +1,31 @@ +import { normalizeMultiSelectPromptOptions } from '#tui/prompts/select/multiselect/options'; +import { runMultiSelectPrompt } from '#tui/prompts/select/multiselect/run'; +import type { ChoiceOptions, MultiSelectPromptOptions } from '#tui/types'; + +export function multiselect(options: MultiSelectPromptOptions): Promise; + +export function multiselect( + label: string, + options: ChoiceOptions, + defaultValue?: T[], + scroll?: number, + required?: MultiSelectPromptOptions['required'], + validate?: MultiSelectPromptOptions['validate'], + hint?: string, + transform?: MultiSelectPromptOptions['transform'], + info?: MultiSelectPromptOptions['info'], +): Promise; + +export async function multiselect( + optionsOrLabel: MultiSelectPromptOptions | string, + source?: ChoiceOptions, + defaultValue: T[] = [], + scroll = 5, + required: MultiSelectPromptOptions['required'] = false, + validate: MultiSelectPromptOptions['validate'] = undefined, + hint = 'Use the space bar to select options.', + transform: MultiSelectPromptOptions['transform'] = undefined, + info: MultiSelectPromptOptions['info'] = '', +): Promise { + return runMultiSelectPrompt(normalizeMultiSelectPromptOptions(optionsOrLabel, source, defaultValue, scroll, required, validate, hint, transform, info)); +} diff --git a/packages/tui/src/prompts/select/multiselect/options.ts b/packages/tui/src/prompts/select/multiselect/options.ts new file mode 100644 index 00000000..8312d77f --- /dev/null +++ b/packages/tui/src/prompts/select/multiselect/options.ts @@ -0,0 +1,25 @@ +import { isSelectPromptLabel, parseSelectChoiceOptions } from '#tui/prompts/select/validators/overload'; +import { parseMultiSelectDefault } from '#tui/prompts/select/validators/defaults'; +import type { ChoiceOptions, MultiSelectPromptOptions } from '#tui/types'; + +export type NormalizedMultiSelectPromptOptions = MultiSelectPromptOptions & { + default: T[]; +}; + +export const normalizeMultiSelectPromptOptions = ( + optionsOrLabel: MultiSelectPromptOptions | string, + source: ChoiceOptions | undefined, + defaultValue: T[], + scroll: number, + required: MultiSelectPromptOptions['required'], + validate: MultiSelectPromptOptions['validate'], + hint: string, + transform: MultiSelectPromptOptions['transform'], + info: MultiSelectPromptOptions['info'], +): NormalizedMultiSelectPromptOptions => { + const options = isSelectPromptLabel(optionsOrLabel) + ? { message: optionsOrLabel, label: optionsOrLabel, options: parseSelectChoiceOptions(source), default: defaultValue, scroll, required, validate, hint, transform, info } + : optionsOrLabel; + + return { ...options, default: parseMultiSelectDefault(options.default ?? []) }; +}; diff --git a/packages/tui/src/prompts/select/multiselect/run.ts b/packages/tui/src/prompts/select/multiselect/run.ts new file mode 100644 index 00000000..c8e6a01b --- /dev/null +++ b/packages/tui/src/prompts/select/multiselect/run.ts @@ -0,0 +1,46 @@ +import { normalizeChoices } from '#tui/concerns/choices'; +import { promptUntilValid, promptWithFallback } from '#tui/prompt'; +import { activePromptFrame } from '#tui/prompt/active-frame'; +import { createPromptSubmissionState } from '#tui/prompt/submission'; +import { preserveMultiSelectRetryDefault, transformedMultiSelectDefault } from '#tui/prompts/select/defaults'; +import { readMultipleChoices } from '#tui/prompts/select/read-multiple'; +import { renderSubmittedChoices } from '#tui/prompts/select/render'; +import type { NormalizedMultiSelectPromptOptions } from '#tui/prompts/select/multiselect/options'; + +export const runMultiSelectPrompt = async (promptOptions: NormalizedMultiSelectPromptOptions): Promise => { + const validationOptions: NormalizedMultiSelectPromptOptions = { + ...promptOptions, + default: await transformedMultiSelectDefault(promptOptions), + }; + + const choices = normalizeChoices(promptOptions.options); + const activeFrame = activePromptFrame(); + const submission = createPromptSubmissionState([]); + + return promptWithFallback('multiselect', promptOptions, () => + promptUntilValid( + validationOptions, + async () => { + submission.reset(); + + const selected = await readMultipleChoices(promptOptions.message, choices, promptOptions.default, promptOptions.hint, promptOptions.scroll, promptOptions.info); + + activeFrame.set(selected.frame); + submission.capture(selected.submitted, selected.cancelled, selected.submittedLabels); + preserveMultiSelectRetryDefault(promptOptions, selected.value); + + return promptOptions.transform ? promptOptions.transform(selected.value) : selected.value; + }, + () => { + activeFrame.clear(); + submission.render((labels) => { + renderSubmittedChoices(promptOptions.message, labels); + }); + }, + () => { + activeFrame.clear(); + submission.reset(); + }, + ), + ); +}; diff --git a/packages/tui/src/prompts/select/navigation.ts b/packages/tui/src/prompts/select/navigation.ts new file mode 100644 index 00000000..f8cf57b3 --- /dev/null +++ b/packages/tui/src/prompts/select/navigation.ts @@ -0,0 +1,54 @@ +import { parseChoiceAnswerIndex } from '#tui/concerns/validators/choice-answer'; +import { Key } from '#tui/key'; +import { parseScrollSize } from '#tui/concerns/validators/scroll'; +import type { Choice } from '#tui/types'; + +export const parseChoiceIndex = (key: string): number | null => parseChoiceAnswerIndex(key); + +export const previousChoiceKeys = (key: string): boolean => { + return key === Key.up || key === Key.upArrow || key === Key.left || key === Key.leftArrow || key === Key.shiftTab || key === Key.ctrlP || key === Key.ctrlB || key === 'k' || key === 'h'; +}; + +export const nextChoiceKeys = (key: string): boolean => { + return key === Key.down || key === Key.downArrow || key === Key.right || key === Key.rightArrow || key === Key.tab || key === Key.ctrlN || key === Key.ctrlF || key === 'j' || key === 'l'; +}; + +export const lastEnabledChoiceIndex = (choices: Array>): number => { + let selected = choices.length - 1; + + while (choices[selected]?.disabled && selected > 0) { + selected -= 1; + } + + return selected; +}; + +export const pageSize = (scroll?: number): number => { + return parseScrollSize(scroll, 10); +}; + +export const pageIndex = (total: number, current: number, direction: 1 | -1, scroll?: number): number => { + if (total <= 0) { + return 0; + } + + return Math.max(0, Math.min(total - 1, current + pageSize(scroll) * direction)); +}; + +export const pageEnabledChoiceIndex = (choices: Array>, current: number, direction: 1 | -1, scroll?: number): number => { + const target = pageIndex(choices.length, current, direction, scroll); + + for (let index = target; index >= 0 && index < choices.length; index += direction) { + if (!choices[index]?.disabled) { + return index; + } + } + + for (let index = target; index >= 0 && index < choices.length; index -= direction) { + if (!choices[index]?.disabled) { + return index; + } + } + + return current; +}; diff --git a/packages/tui/src/prompts/select/options.ts b/packages/tui/src/prompts/select/options.ts new file mode 100644 index 00000000..083ddddb --- /dev/null +++ b/packages/tui/src/prompts/select/options.ts @@ -0,0 +1,41 @@ +import { hasSelectDefaultArgument, isSelectPromptLabel, parseSelectChoiceOptions } from '#tui/prompts/select/validators/overload'; +import { hasPromptDefault } from '#tui/validators/default'; +import type { NormalizedSelectPromptOptions } from '#tui/prompts/select/defaults'; +import type { ChoiceOptions, SelectPromptOptions } from '#tui/types'; + +export const selectHasDefault = (optionsOrLabel: SelectPromptOptions | string, argumentCount: number, defaultValue?: T): boolean => { + return isSelectPromptLabel(optionsOrLabel) ? hasSelectDefaultArgument(argumentCount, defaultValue) : hasPromptDefault(optionsOrLabel); +}; + +export const normalizeSelectPromptOptions = ( + optionsOrLabel: SelectPromptOptions | string, + source: ChoiceOptions | undefined, + defaultValue: T | undefined, + scroll: number, + validate: SelectPromptOptions['validate'], + hint: string, + required: SelectPromptOptions['required'], + transform: SelectPromptOptions['transform'], + info: SelectPromptOptions['info'], + hasDefault: boolean, +): NormalizedSelectPromptOptions => { + const isLabel = isSelectPromptLabel(optionsOrLabel); + + if (!isLabel) { + return { ...optionsOrLabel, hasDefault }; + } + + return { + default: defaultValue, + hasDefault, + hint, + info, + label: optionsOrLabel, + message: optionsOrLabel, + options: parseSelectChoiceOptions(source), + required, + scroll, + transform, + validate, + }; +}; diff --git a/packages/tui/src/prompts/select/read-confirm.ts b/packages/tui/src/prompts/select/read-confirm.ts new file mode 100644 index 00000000..06ae5201 --- /dev/null +++ b/packages/tui/src/prompts/select/read-confirm.ts @@ -0,0 +1,59 @@ +import { promptEnvironment } from '#tui/environment'; +import { Key } from '#tui/key'; +import { cancelPrompt } from '#tui/prompt'; +import { rejectPromptRevert } from '#tui/prompt/revert'; +import { confirmDirectValue, isConfirmToggleKey } from '#tui/prompts/select/read-confirm/keys'; +import { readLineConfirm } from '#tui/prompts/select/read-confirm/line-mode'; +import { createConfirmReaderSession } from '#tui/prompts/select/read-confirm/session'; +import type { ConfirmReadOptions, ConfirmReadResult } from '#tui/prompts/select/read-confirm/types'; + +export type { ConfirmReadOptions, ConfirmReadResult } from '#tui/prompts/select/read-confirm/types'; + +export const readConfirm = async (options: ConfirmReadOptions): Promise => { + const environment = promptEnvironment(); + + if (!environment.input.readKey) { + return readLineConfirm(options); + } + + const session = createConfirmReaderSession(options); + + while (true) { + const key = await environment.input.readKey(); + + if (key === null) { + const submitted = session.submission(); + + return { cancelled: false, frame: submitted.frame, submitted: false, value: submitted.value }; + } + + const directValue = confirmDirectValue(key); + + if (directValue !== null) { + session.set(directValue); + continue; + } + + if (isConfirmToggleKey(key)) { + session.toggle(); + continue; + } + + if (key === Key.enter) { + const submitted = session.submission(); + + return { cancelled: false, frame: submitted.frame, submitted: true, value: submitted.value }; + } + + if (key === Key.ctrlC) { + session.cancel(); + + return { cancelled: true, submitted: false, value: await cancelPrompt(session.value()) }; + } + + if (key === Key.ctrlU) { + rejectPromptRevert(); + continue; + } + } +}; diff --git a/packages/tui/src/prompts/select/read-confirm/keys.ts b/packages/tui/src/prompts/select/read-confirm/keys.ts new file mode 100644 index 00000000..c14bace7 --- /dev/null +++ b/packages/tui/src/prompts/select/read-confirm/keys.ts @@ -0,0 +1,37 @@ +import { Key } from '#tui/key'; + +const confirmToggleKeys = new Set([ + Key.tab, + Key.up, + Key.upArrow, + Key.down, + Key.downArrow, + Key.left, + Key.leftArrow, + Key.right, + Key.rightArrow, + Key.ctrlP, + Key.ctrlF, + Key.ctrlN, + Key.ctrlB, + 'h', + 'j', + 'k', + 'l', +]); + +export const isConfirmToggleKey = (key: string): boolean => confirmToggleKeys.has(key); + +export const confirmDirectValue = (key: string): boolean | null => { + const normalizedKey = key.toLowerCase(); + + if (normalizedKey === 'y') { + return true; + } + + if (normalizedKey === 'n') { + return false; + } + + return null; +}; diff --git a/packages/tui/src/prompts/select/read-confirm/line-mode.ts b/packages/tui/src/prompts/select/read-confirm/line-mode.ts new file mode 100644 index 00000000..41f49b19 --- /dev/null +++ b/packages/tui/src/prompts/select/read-confirm/line-mode.ts @@ -0,0 +1,16 @@ +import { ask } from '#tui/prompt'; +import type { ConfirmReadOptions, ConfirmReadResult } from '#tui/prompts/select/read-confirm/types'; + +const confirmQuestionSuffix = (options: ConfirmReadOptions): string => { + return options.hasDefault === true && options.default === false ? ' [y/N]' : ' [Y/n]'; +}; + +export const readLineConfirm = async (options: ConfirmReadOptions): Promise => { + const answer = (await ask(`${options.message}${confirmQuestionSuffix(options)}`, options.hint)).trim().toLowerCase(); + + if (answer === '') { + return { cancelled: false, submitted: false, value: options.default ?? true }; + } + + return { cancelled: false, submitted: false, value: ['y', 'yes', options.yes?.toLowerCase()].includes(answer) }; +}; diff --git a/packages/tui/src/prompts/select/read-confirm/session.ts b/packages/tui/src/prompts/select/read-confirm/session.ts new file mode 100644 index 00000000..91861643 --- /dev/null +++ b/packages/tui/src/prompts/select/read-confirm/session.ts @@ -0,0 +1,46 @@ +import { eraseRenderedFrame } from '#tui/status/frame'; +import { renderActiveConfirm, renderCancelledConfirm } from '#tui/prompts/select/render-confirm'; +import type { ConfirmReadOptions } from '#tui/prompts/select/read-confirm/types'; + +export type ConfirmReaderSession = { + cancel(): void; + frame(): string; + set(value: boolean): void; + submission(): { frame: string; value: boolean }; + toggle(): void; + value(): boolean; +}; + +export const createConfirmReaderSession = (options: ConfirmReadOptions): ConfirmReaderSession => { + let confirmed = options.default ?? true; + let frame = renderActiveConfirm(options, confirmed); + + const redraw = (): void => { + eraseRenderedFrame(frame); + frame = renderActiveConfirm(options, confirmed); + }; + + return { + cancel() { + eraseRenderedFrame(frame); + renderCancelledConfirm(options, confirmed); + }, + frame() { + return frame; + }, + set(value) { + confirmed = value; + redraw(); + }, + submission() { + return { frame, value: confirmed }; + }, + toggle() { + confirmed = !confirmed; + redraw(); + }, + value() { + return confirmed; + }, + }; +}; diff --git a/packages/tui/src/prompts/select/read-confirm/types.ts b/packages/tui/src/prompts/select/read-confirm/types.ts new file mode 100644 index 00000000..55533143 --- /dev/null +++ b/packages/tui/src/prompts/select/read-confirm/types.ts @@ -0,0 +1,12 @@ +import type { ConfirmPromptOptions } from '#tui/types'; + +export type ConfirmReadOptions = ConfirmPromptOptions & { + hasDefault?: boolean; +}; + +export type ConfirmReadResult = { + cancelled: boolean; + frame?: string; + submitted: boolean; + value: boolean; +}; diff --git a/packages/tui/src/prompts/select/read-multiple.ts b/packages/tui/src/prompts/select/read-multiple.ts new file mode 100644 index 00000000..c97789c0 --- /dev/null +++ b/packages/tui/src/prompts/select/read-multiple.ts @@ -0,0 +1,24 @@ +import { promptEnvironment } from '#tui/environment'; +import { readMultipleChoicesInteractive } from '#tui/prompts/select/read-multiple/interactive'; +import { readLineMultipleChoices } from '#tui/prompts/select/read-multiple/line-mode'; +import type { MultipleChoicesReadResult } from '#tui/prompts/select/read-multiple/types'; +import type { Choice, MultiSelectPromptOptions } from '#tui/types'; + +export type { MultipleChoicesReadResult } from '#tui/prompts/select/read-multiple/types'; + +export const readMultipleChoices = async ( + message: string, + choices: Array>, + defaults: T[] = [], + hint?: string, + scroll?: number, + info?: MultiSelectPromptOptions['info'], +): Promise> => { + const environment = promptEnvironment(); + + if (!environment.input.readKey) { + return readLineMultipleChoices(message, choices, defaults, hint); + } + + return readMultipleChoicesInteractive(async () => environment.input.readKey?.() ?? null, message, choices, defaults, scroll, info); +}; diff --git a/packages/tui/src/prompts/select/read-multiple/cancel.ts b/packages/tui/src/prompts/select/read-multiple/cancel.ts new file mode 100644 index 00000000..d4b480e3 --- /dev/null +++ b/packages/tui/src/prompts/select/read-multiple/cancel.ts @@ -0,0 +1,17 @@ +import { cancelPrompt } from '#tui/prompt'; +import { eraseRenderedFrame } from '#tui/status/frame'; +import { markedChoiceValues } from '#tui/prompts/select/multiple'; +import { cancelledMultipleChoicesResult } from '#tui/prompts/select/read-multiple/result'; +import type { MultipleChoicesReaderSession } from '#tui/prompts/select/read-multiple/session'; +import type { MultipleChoicesReadResult } from '#tui/prompts/select/read-multiple/types'; +import { renderCancelledChoices } from '#tui/prompts/select/render'; +import type { Choice } from '#tui/types'; + +export const cancelMultipleChoices = async (message: string, choices: Array>, session: MultipleChoicesReaderSession, scroll?: number): Promise> => { + eraseRenderedFrame(session.frame()); + renderCancelledChoices(message, choices, session.selected(), session.marked(), scroll); + + const value = markedChoiceValues(choices, session.marked()); + + return cancelledMultipleChoicesResult(choices, session.marked(), await cancelPrompt(value)); +}; diff --git a/packages/tui/src/prompts/select/read-multiple/interactive.ts b/packages/tui/src/prompts/select/read-multiple/interactive.ts new file mode 100644 index 00000000..1a4852b9 --- /dev/null +++ b/packages/tui/src/prompts/select/read-multiple/interactive.ts @@ -0,0 +1,46 @@ +import { Key } from '#tui/key'; +import { cancelMultipleChoices } from '#tui/prompts/select/read-multiple/cancel'; +import { applyMultipleChoicesKey } from '#tui/prompts/select/read-multiple/keys'; +import { multipleChoicesResult } from '#tui/prompts/select/read-multiple/result'; +import { createMultipleChoicesReaderSession } from '#tui/prompts/select/read-multiple/session'; +import type { MultipleChoicesReadResult } from '#tui/prompts/select/read-multiple/types'; +import type { Choice, MultiSelectPromptOptions } from '#tui/types'; + +export const readMultipleChoicesInteractive = async ( + readKey: () => Promise, + message: string, + choices: Array>, + defaults: T[] = [], + scroll?: number, + info?: MultiSelectPromptOptions['info'], +): Promise> => { + const session = createMultipleChoicesReaderSession(message, choices, defaults, scroll, info); + + session.render(); + + while (true) { + const key = await readKey(); + + if (key === null) { + return multipleChoicesResult(choices, session.marked(), false, false, session.frame()); + } + + if (key === Key.ctrlC) { + return cancelMultipleChoices(message, choices, session, scroll); + } + + const applied = applyMultipleChoicesKey(key, choices, session); + + if (applied.handled) { + if (applied.result) { + return applied.result; + } + + continue; + } + + if (key === Key.enter) { + return multipleChoicesResult(choices, session.marked(), true, false, session.frame()); + } + } +}; diff --git a/packages/tui/src/prompts/select/read-multiple/keys.ts b/packages/tui/src/prompts/select/read-multiple/keys.ts new file mode 100644 index 00000000..6d34f8c4 --- /dev/null +++ b/packages/tui/src/prompts/select/read-multiple/keys.ts @@ -0,0 +1,51 @@ +import { Key } from '#tui/key'; +import { selectNavigationAction } from '#tui/prompts/select/keys'; +import { choicesFromCommaSeparated } from '#tui/prompts/select/multiple'; +import { parseChoiceIndex } from '#tui/prompts/select/navigation'; +import { multipleChoicesValueResult } from '#tui/prompts/select/read-multiple/result'; +import type { MultipleChoicesReaderSession } from '#tui/prompts/select/read-multiple/session'; +import type { MultipleChoicesReadResult } from '#tui/prompts/select/read-multiple/types'; +import type { Choice } from '#tui/types'; + +export type MultipleChoicesKeyResult = + | { + handled: false; + } + | { + handled: true; + result?: MultipleChoicesReadResult; + }; + +export const applyMultipleChoicesKey = (key: string, choices: Array>, session: MultipleChoicesReaderSession): MultipleChoicesKeyResult => { + if (key.includes(',')) { + return { handled: true, result: multipleChoicesValueResult(choicesFromCommaSeparated(choices, key)) }; + } + + const numeric = parseChoiceIndex(key); + + if (numeric !== null && session.toggleIndex(numeric - 1)) { + return { handled: true }; + } + + const action = selectNavigationAction(key); + + if (action !== null) { + session.move(action); + + return { handled: true }; + } + + if (key === Key.ctrlA) { + session.toggleAll(); + + return { handled: true }; + } + + if (key === Key.space) { + session.toggleSelected(); + + return { handled: true }; + } + + return { handled: false }; +}; diff --git a/packages/tui/src/prompts/select/read-multiple/line-mode.ts b/packages/tui/src/prompts/select/read-multiple/line-mode.ts new file mode 100644 index 00000000..d53c9aa0 --- /dev/null +++ b/packages/tui/src/prompts/select/read-multiple/line-mode.ts @@ -0,0 +1,16 @@ +import { ask } from '#tui/prompt'; +import { renderChoices } from '#tui/theme'; +import { choicesFromCommaSeparated, defaultChoiceValues } from '#tui/prompts/select/multiple'; +import { multipleChoicesValueResult } from '#tui/prompts/select/read-multiple/result'; +import type { MultipleChoicesReadResult } from '#tui/prompts/select/read-multiple/types'; +import type { Choice } from '#tui/types'; + +export const readLineMultipleChoices = async (message: string, choices: Array>, defaults: T[] = [], hint?: string): Promise> => { + const rendered = renderChoices(choices); + + const answer = await ask(`${message}\n${rendered}\n`, hint); + + const value = answer.trim() === '' ? defaultChoiceValues(choices, defaults) : choicesFromCommaSeparated(choices, answer); + + return multipleChoicesValueResult(value); +}; diff --git a/packages/tui/src/prompts/select/read-multiple/result.ts b/packages/tui/src/prompts/select/read-multiple/result.ts new file mode 100644 index 00000000..d80a0fe0 --- /dev/null +++ b/packages/tui/src/prompts/select/read-multiple/result.ts @@ -0,0 +1,32 @@ +import { markedChoiceValues } from '#tui/prompts/select/multiple'; +import type { MultipleChoicesReadResult } from '#tui/prompts/select/read-multiple/types'; +import type { Choice } from '#tui/types'; + +export const markedChoiceLabels = (choices: Array>, marked: Set): string[] => { + return [...marked] + .sort((left, right) => left - right) + .map((index) => choices[index]?.label) + .filter((label): label is string => label !== undefined); +}; + +export const multipleChoicesResult = (choices: Array>, marked: Set, submitted: boolean, cancelled = false, frame?: string): MultipleChoicesReadResult => ({ + cancelled, + frame, + submitted, + submittedLabels: markedChoiceLabels(choices, marked), + value: markedChoiceValues(choices, marked), +}); + +export const multipleChoicesValueResult = (value: T[], submitted = false): MultipleChoicesReadResult => ({ + cancelled: false, + submitted, + submittedLabels: [], + value, +}); + +export const cancelledMultipleChoicesResult = (choices: Array>, marked: Set, value: T[]): MultipleChoicesReadResult => ({ + cancelled: true, + submitted: false, + submittedLabels: markedChoiceLabels(choices, marked), + value, +}); diff --git a/packages/tui/src/prompts/select/read-multiple/session.ts b/packages/tui/src/prompts/select/read-multiple/session.ts new file mode 100644 index 00000000..d7c6b3f5 --- /dev/null +++ b/packages/tui/src/prompts/select/read-multiple/session.ts @@ -0,0 +1,62 @@ +import { createMultipleChoicesSessionFrame } from '#tui/prompts/select/read-multiple/session/frame'; +import { createMultipleChoicesSessionState } from '#tui/prompts/select/read-multiple/session/state'; +import type { SelectNavigationAction } from '#tui/prompts/select/keys'; +import type { Choice, MultiSelectPromptOptions } from '#tui/types'; + +export type MultipleChoicesReaderSession = { + frame(): string; + marked(): Set; + move(action: SelectNavigationAction): void; + render(): void; + selected(): number; + toggleAll(): void; + toggleIndex(index: number): boolean; + toggleSelected(): void; +}; + +export const createMultipleChoicesReaderSession = ( + message: string, + choices: Array>, + defaults: T[], + scroll?: number, + info?: MultiSelectPromptOptions['info'], +): MultipleChoicesReaderSession => { + const state = createMultipleChoicesSessionState(choices, defaults, scroll); + const frame = createMultipleChoicesSessionFrame(message, choices, scroll, info); + + const render = (): void => frame.render(state.selected(), state.marked()); + + return { + frame() { + return frame.value(); + }, + marked() { + return state.marked(); + }, + move(action) { + state.move(action); + render(); + }, + render, + selected() { + return state.selected(); + }, + toggleAll() { + state.toggleAll(); + render(); + }, + toggleIndex(index) { + if (!state.toggleIndex(index)) { + return false; + } + + render(); + + return true; + }, + toggleSelected() { + state.toggleSelected(); + render(); + }, + }; +}; diff --git a/packages/tui/src/prompts/select/read-multiple/session/frame.ts b/packages/tui/src/prompts/select/read-multiple/session/frame.ts new file mode 100644 index 00000000..d9788092 --- /dev/null +++ b/packages/tui/src/prompts/select/read-multiple/session/frame.ts @@ -0,0 +1,25 @@ +import { eraseRenderedFrame } from '#tui/status/frame'; +import { renderMultipleChoices } from '#tui/prompts/select/render'; +import type { Choice, MultiSelectPromptOptions } from '#tui/types'; + +export type MultipleChoicesSessionFrame = { + render(selected: number, marked: Set): void; + value(): string; +}; + +export const createMultipleChoicesSessionFrame = (message: string, choices: Array>, scroll?: number, info?: MultiSelectPromptOptions['info']): MultipleChoicesSessionFrame => { + let frame = ''; + + return { + render(selected, marked) { + if (frame.length > 0) { + eraseRenderedFrame(frame); + } + + frame = renderMultipleChoices(message, choices, selected, marked, scroll, info); + }, + value() { + return frame; + }, + }; +}; diff --git a/packages/tui/src/prompts/select/read-multiple/session/state.ts b/packages/tui/src/prompts/select/read-multiple/session/state.ts new file mode 100644 index 00000000..5cd6dbfb --- /dev/null +++ b/packages/tui/src/prompts/select/read-multiple/session/state.ts @@ -0,0 +1,46 @@ +import { firstEnabledIndex } from '#tui/concerns/choices'; +import { moveSelectHighlight } from '#tui/prompts/select/keys'; +import { markedChoiceIndexes, toggleAllEnabledChoices, toggleMarkedChoice } from '#tui/prompts/select/multiple'; +import type { SelectNavigationAction } from '#tui/prompts/select/keys'; +import type { Choice } from '#tui/types'; + +export type MultipleChoicesSessionState = { + marked(): Set; + move(action: SelectNavigationAction): void; + selected(): number; + toggleAll(): void; + toggleIndex(index: number): boolean; + toggleSelected(): void; +}; + +export const createMultipleChoicesSessionState = (choices: Array>, defaults: T[], scroll?: number): MultipleChoicesSessionState => { + let selected = firstEnabledIndex(choices); + let marked = markedChoiceIndexes(choices, defaults); + + return { + marked() { + return marked; + }, + move(action) { + selected = moveSelectHighlight(choices, selected, action, scroll); + }, + selected() { + return selected; + }, + toggleAll() { + marked = toggleAllEnabledChoices(choices, marked); + }, + toggleIndex(index) { + if (!choices[index] || choices[index]?.disabled) { + return false; + } + + marked = toggleMarkedChoice(choices, marked, index); + + return true; + }, + toggleSelected() { + marked = toggleMarkedChoice(choices, marked, selected); + }, + }; +}; diff --git a/packages/tui/src/prompts/select/read-multiple/types.ts b/packages/tui/src/prompts/select/read-multiple/types.ts new file mode 100644 index 00000000..f51ebbdb --- /dev/null +++ b/packages/tui/src/prompts/select/read-multiple/types.ts @@ -0,0 +1,7 @@ +export type MultipleChoicesReadResult = { + cancelled: boolean; + frame?: string; + submitted: boolean; + submittedLabels: string[]; + value: T[]; +}; diff --git a/packages/tui/src/prompts/select/read-selected.ts b/packages/tui/src/prompts/select/read-selected.ts new file mode 100644 index 00000000..6de56ce6 --- /dev/null +++ b/packages/tui/src/prompts/select/read-selected.ts @@ -0,0 +1,25 @@ +import { promptEnvironment } from '#tui/environment'; +import { readSelectedChoiceInteractive } from '#tui/prompts/select/read-selected/interactive'; +import { readSelectedChoiceLineMode } from '#tui/prompts/select/read-selected/line-mode'; +import type { SelectedChoiceReadResult } from '#tui/prompts/select/read-selected/types'; +import type { Choice, SelectPromptOptions } from '#tui/types'; + +export type { SelectedChoiceReadResult } from '#tui/prompts/select/read-selected/types'; + +export const readSelectedChoice = async ( + message: string, + choices: Array>, + defaultValue?: T, + hasDefault = false, + hint?: string, + scroll?: number, + info?: SelectPromptOptions['info'], +): Promise> => { + const environment = promptEnvironment(); + + if (!environment.input.readKey) { + return readSelectedChoiceLineMode(message, choices, defaultValue, hasDefault, hint); + } + + return readSelectedChoiceInteractive(environment.input.readKey, message, choices, defaultValue, hasDefault, scroll, info); +}; diff --git a/packages/tui/src/prompts/select/read-selected/interactive.ts b/packages/tui/src/prompts/select/read-selected/interactive.ts new file mode 100644 index 00000000..794f8e7f --- /dev/null +++ b/packages/tui/src/prompts/select/read-selected/interactive.ts @@ -0,0 +1,51 @@ +import { Key } from '#tui/key'; +import { selectNavigationAction } from '#tui/prompts/select/keys'; +import { parseChoiceIndex } from '#tui/prompts/select/navigation'; +import { cancelSelectedChoice } from '#tui/prompts/select/read-selected/interactive/cancel'; +import { exhaustedSelectedChoice, indexedSelectedChoice, submittedSelectedChoice } from '#tui/prompts/select/read-selected/interactive/result'; +import { createSelectedChoiceReaderSession } from '#tui/prompts/select/read-selected/session'; +import type { SelectedChoiceReadResult } from '#tui/prompts/select/read-selected/types'; +import type { Choice, PromptInput, SelectPromptOptions } from '#tui/types'; + +export const readSelectedChoiceInteractive = async ( + readKey: NonNullable, + message: string, + choices: Array>, + defaultValue?: T, + hasDefault = false, + scroll?: number, + info?: SelectPromptOptions['info'], +): Promise> => { + const session = createSelectedChoiceReaderSession(message, choices, defaultValue, hasDefault, scroll, info); + + session.render(); + + while (true) { + const key = await readKey(); + + if (key === null) { + return exhaustedSelectedChoice(choices, session.selected()); + } + + if (key === Key.ctrlC) { + return cancelSelectedChoice(message, choices, session, defaultValue, scroll); + } + + const numeric = parseChoiceIndex(key); + + if (numeric !== null && choices[numeric - 1] && !choices[numeric - 1]?.disabled) { + return indexedSelectedChoice(choices, numeric - 1, session.frame()); + } + + const action = selectNavigationAction(key, { lineControls: true }); + + if (action !== null) { + session.move(action); + continue; + } + + if (key === Key.enter) { + return submittedSelectedChoice(choices, session); + } + } +}; diff --git a/packages/tui/src/prompts/select/read-selected/interactive/cancel.ts b/packages/tui/src/prompts/select/read-selected/interactive/cancel.ts new file mode 100644 index 00000000..b0cb7cb4 --- /dev/null +++ b/packages/tui/src/prompts/select/read-selected/interactive/cancel.ts @@ -0,0 +1,26 @@ +import { cancelPrompt } from '#tui/prompt'; +import { parseChoiceValue } from '#tui/concerns/validators/choice'; +import { eraseRenderedFrame } from '#tui/status/frame'; +import { cancelledSelectedChoiceResult } from '#tui/prompts/select/read-selected/result'; +import { renderCancelledChoice } from '#tui/prompts/select/render'; +import type { SelectedChoiceReaderSession } from '#tui/prompts/select/read-selected/session'; +import type { SelectedChoiceReadResult } from '#tui/prompts/select/read-selected/types'; +import type { Choice } from '#tui/types'; + +export const cancelSelectedChoice = async ( + message: string, + choices: Array>, + session: SelectedChoiceReaderSession, + defaultValue?: T, + scroll?: number, +): Promise> => { + eraseRenderedFrame(session.frame()); + renderCancelledChoice(message, choices, session.selected(), scroll); + + const choice = choices[session.selected()]; + const fallback = choice?.value ?? defaultValue; + + const value = parseChoiceValue(await cancelPrompt(fallback)); + + return cancelledSelectedChoiceResult(choice, value); +}; diff --git a/packages/tui/src/prompts/select/read-selected/interactive/result.ts b/packages/tui/src/prompts/select/read-selected/interactive/result.ts new file mode 100644 index 00000000..970ecef1 --- /dev/null +++ b/packages/tui/src/prompts/select/read-selected/interactive/result.ts @@ -0,0 +1,22 @@ +import { selectedChoiceAt, selectedChoiceResult } from '#tui/prompts/select/read-selected/result'; +import type { SelectedChoiceReaderSession } from '#tui/prompts/select/read-selected/session'; +import type { SelectedChoiceReadResult } from '#tui/prompts/select/read-selected/types'; +import type { Choice } from '#tui/types'; + +export const exhaustedSelectedChoice = (choices: Array>, selected: number): SelectedChoiceReadResult => { + const choice = selectedChoiceAt(choices, selected); + + return selectedChoiceResult(choice, false); +}; + +export const indexedSelectedChoice = (choices: Array>, index: number, frame: string): SelectedChoiceReadResult => { + const choice = selectedChoiceAt(choices, index); + + return selectedChoiceResult(choice, true, false, frame); +}; + +export const submittedSelectedChoice = (choices: Array>, session: SelectedChoiceReaderSession): SelectedChoiceReadResult => { + const choice = selectedChoiceAt(choices, session.selected()); + + return selectedChoiceResult(choice, true, false, session.frame()); +}; diff --git a/packages/tui/src/prompts/select/read-selected/line-mode.ts b/packages/tui/src/prompts/select/read-selected/line-mode.ts new file mode 100644 index 00000000..3f98fd77 --- /dev/null +++ b/packages/tui/src/prompts/select/read-selected/line-mode.ts @@ -0,0 +1,34 @@ +import { ask } from '#tui/prompt'; +import { renderChoices } from '#tui/theme'; +import { findChoice } from '#tui/concerns/choices'; +import { invalidSelectedChoice, selectedChoiceByDefault, selectedChoiceResult } from '#tui/prompts/select/read-selected/result'; +import type { SelectedChoiceReadResult } from '#tui/prompts/select/read-selected/types'; +import type { Choice } from '#tui/types'; + +export const readSelectedChoiceLineMode = async ( + message: string, + choices: Array>, + defaultValue: T | undefined, + hasDefault: boolean, + hint?: string, +): Promise> => { + const rendered = renderChoices(choices); + + const answer = await ask(`${message}\n${rendered}\n`, hint); + + if (answer.trim() === '' && hasDefault) { + const choice = selectedChoiceByDefault(choices, defaultValue, hasDefault); + + if (choice) { + return selectedChoiceResult(choice, false); + } + } + + const choice = findChoice(choices, answer); + + if (!choice || choice.disabled) { + throw invalidSelectedChoice(); + } + + return selectedChoiceResult(choice, false); +}; diff --git a/packages/tui/src/prompts/select/read-selected/result.ts b/packages/tui/src/prompts/select/read-selected/result.ts new file mode 100644 index 00000000..4b86ef9a --- /dev/null +++ b/packages/tui/src/prompts/select/read-selected/result.ts @@ -0,0 +1,46 @@ +import { PromptValidationError } from '#tui/prompt'; +import { choiceByValue, choiceValueEquals, firstEnabledIndex } from '#tui/concerns/choices'; +import type { SelectedChoiceReadResult } from '#tui/prompts/select/read-selected/types'; +import type { Choice } from '#tui/types'; + +export const invalidSelectedChoice = (): PromptValidationError => new PromptValidationError('Please select a valid option.'); + +export const defaultChoiceIndex = (choices: Array>, defaultValue: T | undefined, hasDefault = false): number => { + if (!hasDefault) { + return firstEnabledIndex(choices); + } + + const index = choices.findIndex((choice) => !choice.disabled && choiceValueEquals(choice.value, defaultValue)); + + return index === -1 ? firstEnabledIndex(choices) : index; +}; + +export const selectedChoiceByDefault = (choices: Array>, defaultValue: T | undefined, hasDefault = false): Choice | undefined => { + return hasDefault ? choiceByValue(choices, defaultValue) : undefined; +}; + +export const selectedChoiceAt = (choices: Array>, index: number): Choice => { + const choice = choices[index]; + + if (!choice || choice.disabled) { + throw invalidSelectedChoice(); + } + + return choice; +}; + +export const selectedChoiceResult = (choice: Choice, submitted: boolean, cancelled = false, frame?: string): SelectedChoiceReadResult => ({ + cancelled, + frame, + submitted, + submittedLabel: choice.label, + value: choice.value, +}); + +export const cancelledSelectedChoiceResult = (choice: Choice | undefined, cancelledValue: T, frame?: string): SelectedChoiceReadResult => ({ + cancelled: true, + frame, + submitted: false, + submittedLabel: choice?.label ?? '', + value: cancelledValue, +}); diff --git a/packages/tui/src/prompts/select/read-selected/session.ts b/packages/tui/src/prompts/select/read-selected/session.ts new file mode 100644 index 00000000..648ba064 --- /dev/null +++ b/packages/tui/src/prompts/select/read-selected/session.ts @@ -0,0 +1,47 @@ +import { eraseRenderedFrame } from '#tui/status/frame'; +import { moveSelectHighlight } from '#tui/prompts/select/keys'; +import { defaultChoiceIndex } from '#tui/prompts/select/read-selected/result'; +import { renderSelectedChoice } from '#tui/prompts/select/render'; +import type { SelectNavigationAction } from '#tui/prompts/select/keys'; +import type { Choice, SelectPromptOptions } from '#tui/types'; + +export type SelectedChoiceReaderSession = { + frame(): string; + move(action: SelectNavigationAction): void; + render(): void; + selected(): number; +}; + +export const createSelectedChoiceReaderSession = ( + message: string, + choices: Array>, + defaultValue: T | undefined, + hasDefault: boolean, + scroll?: number, + info?: SelectPromptOptions['info'], +): SelectedChoiceReaderSession => { + let selected = defaultChoiceIndex(choices, defaultValue, hasDefault); + let frame = ''; + + function render(): void { + if (frame.length > 0) { + eraseRenderedFrame(frame); + } + + frame = renderSelectedChoice(message, choices, selected, scroll, info); + } + + return { + frame() { + return frame; + }, + move(action) { + selected = moveSelectHighlight(choices, selected, action, scroll); + render(); + }, + render, + selected() { + return selected; + }, + }; +}; diff --git a/packages/tui/src/prompts/select/read-selected/types.ts b/packages/tui/src/prompts/select/read-selected/types.ts new file mode 100644 index 00000000..e91ff6d2 --- /dev/null +++ b/packages/tui/src/prompts/select/read-selected/types.ts @@ -0,0 +1,7 @@ +export type SelectedChoiceReadResult = { + cancelled: boolean; + frame?: string; + submitted: boolean; + submittedLabel: string; + value: T; +}; diff --git a/packages/tui/src/prompts/select/render-confirm.ts b/packages/tui/src/prompts/select/render-confirm.ts new file mode 100644 index 00000000..f4e46a26 --- /dev/null +++ b/packages/tui/src/prompts/select/render-confirm.ts @@ -0,0 +1,41 @@ +import { promptEnvironment } from '#tui/environment'; +import { renderBox } from '#tui/theme/box'; +import { cyan, dim, green, red } from '#tui/theme/styles'; +import type { ConfirmPromptOptions } from '#tui/types'; + +const confirmLabel = (options: ConfirmPromptOptions, confirmed: boolean): string => { + return confirmed ? (options.yes ?? 'Yes') : (options.no ?? 'No'); +}; + +const renderConfirmOptions = (options: ConfirmPromptOptions, confirmed: boolean): string => { + const yes = options.yes ?? 'Yes'; + const no = options.no ?? 'No'; + + return confirmed ? `${green('●')} ${yes} / ○ ${no}` : `○ ${yes} / ${green('●')} ${no}`; +}; + +const renderCancelledConfirmOptions = (options: ConfirmPromptOptions, confirmed: boolean): string => { + const yes = options.yes ?? 'Yes'; + const no = options.no ?? 'No'; + + return confirmed ? `● ${yes} / ○ ${no}` : `○ ${yes} / ● ${no}`; +}; + +export const renderActiveConfirm = (options: ConfirmPromptOptions, confirmed: boolean): string => { + const frame = `${renderBox({ body: renderConfirmOptions(options, confirmed), borderStyle: cyan, info: options.hint, title: cyan(options.message) })}\n`; + + promptEnvironment().output.write(frame); + + return frame; +}; + +export const renderSubmittedConfirm = (options: ConfirmPromptOptions, confirmed: boolean): void => { + promptEnvironment().output.write(`${renderBox({ body: confirmLabel(options, confirmed), title: dim(options.message) })}\n`); +}; + +export const renderCancelledConfirm = (options: ConfirmPromptOptions, confirmed: boolean): void => { + const environment = promptEnvironment(); + + environment.output.write(`${renderBox({ body: renderCancelledConfirmOptions(options, confirmed), borderStyle: red, title: options.message })}\n`); + environment.error.write(`${red(' ⚠ Cancelled.')}\n`); +}; diff --git a/packages/tui/src/prompts/select/render-rows.ts b/packages/tui/src/prompts/select/render-rows.ts new file mode 100644 index 00000000..fc619816 --- /dev/null +++ b/packages/tui/src/prompts/select/render-rows.ts @@ -0,0 +1,60 @@ +import { choiceLabel } from '#tui/concerns/choice-label'; +import { renderChoiceWindowRows } from '#tui/prompts/select/render-rows/window'; +import { cyan, dim, strikethrough } from '#tui/theme/styles'; +import type { Choice } from '#tui/types'; + +export const renderActiveChoiceRows = (choices: Array>, selected: number, scroll?: number): string => { + return renderChoiceWindowRows(choices, selected, scroll, (choice, index) => { + const label = choiceLabel(choice); + + if (index === selected) { + return `${cyan('›')} ${cyan('●')} ${label} `; + } + + return ` ${dim('○')} ${dim(label)} `; + }); +}; + +export const renderActiveChecklistRows = (choices: Array>, selected: number, marked: Set, scroll?: number): string => { + return renderChoiceWindowRows(choices, selected, scroll, (choice, index) => { + const active = index === selected; + const checked = marked.has(index); + const label = choiceLabel(choice); + + if (active && checked) { + return `${cyan('› ◼')} ${label} `; + } + + if (active) { + return `${cyan('›')} ◻ ${label} `; + } + + if (checked) { + return ` ${cyan('◼')} ${dim(label)} `; + } + + return ` ${dim('◻')} ${dim(label)} `; + }); +}; + +export const renderCancelledChoiceRows = (choices: Array>, selected: number, scroll?: number): string => { + return renderChoiceWindowRows(choices, selected, scroll, (choice, index) => { + const label = choiceLabel(choice); + + if (index === selected) { + return `${dim(`› ● ${strikethrough(label)}`)} `; + } + + return `${dim(` ○ ${strikethrough(label)}`)} `; + }); +}; + +export const renderCancelledChecklistRows = (choices: Array>, selected: number, marked: Set, scroll?: number): string => { + return renderChoiceWindowRows(choices, selected, scroll, (choice, index) => { + const pointer = index === selected ? '›' : ' '; + const marker = marked.has(index) ? '◼' : '◻'; + const label = choiceLabel(choice); + + return `${dim(`${pointer} ${marker} ${strikethrough(label)}`)} `; + }); +}; diff --git a/packages/tui/src/prompts/select/render-rows/window.ts b/packages/tui/src/prompts/select/render-rows/window.ts new file mode 100644 index 00000000..13214a2f --- /dev/null +++ b/packages/tui/src/prompts/select/render-rows/window.ts @@ -0,0 +1,10 @@ +import { choiceWindow } from '#tui/concerns/choices'; +import { renderScrollbarRows } from '#tui/concerns/scrollbar'; +import type { Choice } from '#tui/types'; + +export const renderChoiceWindowRows = (choices: Array>, selected: number, scroll: number | undefined, renderRow: (choice: Choice, index: number) => string): string => { + const window = choiceWindow(choices.length, selected, scroll); + const rows = choices.slice(window.start, window.end).map((choice, offset) => renderRow(choice, window.start + offset)); + + return renderScrollbarRows(rows, window.start, window.end - window.start, choices.length).join('\n'); +}; diff --git a/packages/tui/src/prompts/select/render.ts b/packages/tui/src/prompts/select/render.ts new file mode 100644 index 00000000..a002fc66 --- /dev/null +++ b/packages/tui/src/prompts/select/render.ts @@ -0,0 +1,44 @@ +import { promptEnvironment } from '#tui/environment'; +import { joinedInfoDetails, resolveInfo } from '#tui/concerns/info'; +import { renderActiveChecklistRows, renderActiveChoiceRows, renderCancelledChecklistRows, renderCancelledChoiceRows } from '#tui/prompts/select/render-rows'; +import { renderBox } from '#tui/theme/box'; +import { cyan, dim, red } from '#tui/theme/styles'; +import type { Choice, MultiSelectPromptOptions, SelectPromptOptions } from '#tui/types'; + +export const renderSelectedChoice = (message: string, choices: Array>, selected: number, scroll: number | undefined, info: SelectPromptOptions['info']): string => { + const text = resolveInfo(info, choices[selected]?.value ?? null); + const frame = `${renderBox({ body: renderActiveChoiceRows(choices, selected, scroll), borderStyle: cyan, info: text, title: cyan(message) })}\n`; + + promptEnvironment().output.write(frame); + + return frame; +}; + +export const renderSubmittedChoice = (message: string, label: string): void => { + promptEnvironment().output.write(`${renderBox({ body: label, title: dim(message) })}\n`); +}; + +export const renderSubmittedChoices = (message: string, labels: string[]): void => { + promptEnvironment().output.write(`${renderBox({ body: labels.join('\n'), title: dim(message) })}\n`); +}; + +export const renderCancelledChoice = (message: string, choices: Array>, selected: number, scroll?: number): void => { + promptEnvironment().output.write(`${renderBox({ body: renderCancelledChoiceRows(choices, selected, scroll), borderStyle: red, title: message })}\n`); + promptEnvironment().error.write(`${red(' ⚠ Cancelled.')}\n`); +}; + +export const renderCancelledChoices = (message: string, choices: Array>, selected: number, marked: Set, scroll?: number): void => { + promptEnvironment().output.write(`${renderBox({ body: renderCancelledChecklistRows(choices, selected, marked, scroll), borderStyle: red, title: message })}\n`); + promptEnvironment().error.write(`${red(' ⚠ Cancelled.')}\n`); +}; + +export const renderMultipleChoices = (message: string, choices: Array>, selected: number, marked: Set, scroll?: number, info?: MultiSelectPromptOptions['info']): string => { + const text = resolveInfo(info, choices[selected]?.value ?? null); + const summary = scroll !== undefined && choices.length > scroll ? `${marked.size} selected` : ''; + const details = joinedInfoDetails(text, summary); + const frame = `${renderBox({ body: renderActiveChecklistRows(choices, selected, marked, scroll), borderStyle: cyan, info: details, title: cyan(message) })}\n`; + + promptEnvironment().output.write(frame); + + return frame; +}; diff --git a/packages/tui/src/prompts/select/run.ts b/packages/tui/src/prompts/select/run.ts new file mode 100644 index 00000000..d4e81847 --- /dev/null +++ b/packages/tui/src/prompts/select/run.ts @@ -0,0 +1,60 @@ +import { normalizeChoices } from '#tui/concerns/choices'; +import { promptUntilValid, promptWithFallback } from '#tui/prompt'; +import { activePromptFrame } from '#tui/prompt/active-frame'; +import { createPromptSubmissionState } from '#tui/prompt/submission'; +import { preserveSelectRetryDefault, transformSelectValue, transformedSelectDefault } from '#tui/prompts/select/defaults'; +import { readSelectedChoice } from '#tui/prompts/select/read-selected'; +import { renderSubmittedChoice } from '#tui/prompts/select/render'; +import { assertSelectOptions } from '#tui/prompts/select/validators/options'; +import type { NormalizedSelectPromptOptions } from '#tui/prompts/select/defaults'; +import type { SelectPromptOptions } from '#tui/types'; + +export const runSelectPrompt = async (options: NormalizedSelectPromptOptions): Promise => { + assertSelectOptions(options); + + const promptOptions: NormalizedSelectPromptOptions = { ...options, required: options.required ?? true }; + + const validationOptions: SelectPromptOptions = { + ...promptOptions, + default: await transformedSelectDefault(promptOptions), + }; + + const choices = normalizeChoices(options.options); + const activeFrame = activePromptFrame(); + const submission = createPromptSubmissionState(''); + + return promptWithFallback('select', promptOptions, () => + promptUntilValid( + validationOptions, + async () => { + submission.reset(); + + const selected = await readSelectedChoice( + promptOptions.message, + choices, + promptOptions.default, + promptOptions.hasDefault, + promptOptions.hint, + promptOptions.scroll, + promptOptions.info, + ); + + activeFrame.set(selected.frame); + submission.capture(selected.submitted, selected.cancelled, selected.submittedLabel); + preserveSelectRetryDefault(promptOptions, selected.value); + + return transformSelectValue(promptOptions, selected.value); + }, + () => { + activeFrame.clear(); + submission.render((label) => { + renderSubmittedChoice(promptOptions.message, label); + }); + }, + () => { + activeFrame.clear(); + submission.reset(); + }, + ), + ); +}; diff --git a/packages/tui/src/prompts/select/select.ts b/packages/tui/src/prompts/select/select.ts new file mode 100644 index 00000000..8c1ea79d --- /dev/null +++ b/packages/tui/src/prompts/select/select.ts @@ -0,0 +1,33 @@ +import { normalizeSelectPromptOptions, selectHasDefault } from '#tui/prompts/select/options'; +import { runSelectPrompt } from '#tui/prompts/select/run'; +import type { ChoiceOptions, SelectPromptOptions } from '#tui/types'; + +export function select(options: SelectPromptOptions): Promise; + +export function select( + label: string, + options: ChoiceOptions, + defaultValue?: T, + scroll?: number, + validate?: SelectPromptOptions['validate'], + hint?: string, + required?: SelectPromptOptions['required'], + transform?: SelectPromptOptions['transform'], + info?: SelectPromptOptions['info'], +): Promise; + +export async function select( + optionsOrLabel: SelectPromptOptions | string, + source?: ChoiceOptions, + defaultValue?: T, + scroll = 5, + validate: SelectPromptOptions['validate'] = undefined, + hint = '', + required: SelectPromptOptions['required'] = true, + transform: SelectPromptOptions['transform'] = undefined, + info: SelectPromptOptions['info'] = '', +): Promise { + return runSelectPrompt( + normalizeSelectPromptOptions(optionsOrLabel, source, defaultValue, scroll, validate, hint, required, transform, info, selectHasDefault(optionsOrLabel, arguments.length, defaultValue)), + ); +} diff --git a/packages/tui/src/prompts/select/validators/defaults.ts b/packages/tui/src/prompts/select/validators/defaults.ts new file mode 100644 index 00000000..08fccce1 --- /dev/null +++ b/packages/tui/src/prompts/select/validators/defaults.ts @@ -0,0 +1,13 @@ +import { z } from 'zod'; + +const multiSelectDefaultSchema = (): z.ZodType => z.array(z.unknown()) as z.ZodType; + +export const parseMultiSelectDefault = (value: unknown): T[] => { + const parsed = multiSelectDefaultSchema().safeParse(value); + + if (!parsed.success) { + throw new TypeError('Default selected values must be an array.'); + } + + return parsed.data; +}; diff --git a/packages/tui/src/prompts/select/validators/options.ts b/packages/tui/src/prompts/select/validators/options.ts new file mode 100644 index 00000000..c109c45b --- /dev/null +++ b/packages/tui/src/prompts/select/validators/options.ts @@ -0,0 +1,10 @@ +import { z } from 'zod'; +import type { SelectPromptOptions } from '#tui/types'; + +const disallowedRequiredSchema = z.literal(false); + +export const assertSelectOptions = (options: SelectPromptOptions): void => { + if (disallowedRequiredSchema.safeParse(options.required).success) { + throw new TypeError('Argument [required] must be true or a string.'); + } +}; diff --git a/packages/tui/src/prompts/select/validators/overload.ts b/packages/tui/src/prompts/select/validators/overload.ts new file mode 100644 index 00000000..470f42d2 --- /dev/null +++ b/packages/tui/src/prompts/select/validators/overload.ts @@ -0,0 +1,34 @@ +import { z } from 'zod'; +import { choiceOptionsSchema } from '#tui/concerns/validators/choice'; +import type { ChoiceOptions } from '#tui/types'; + +const selectPromptLabelSchema = z.string(); +const confirmDefaultSchema = z.boolean(); + +export const isSelectPromptLabel = (value: unknown): value is string => { + return selectPromptLabelSchema.safeParse(value).success; +}; + +export const hasSelectDefaultArgument = (argumentCount: number, defaultValue: T | undefined): boolean => { + return argumentCount >= 3 && defaultValue !== undefined; +}; + +export const hasConfirmDefaultArgument = (argumentCount: number, defaultValue: boolean | undefined): boolean => { + return argumentCount >= 2 && defaultValue !== undefined; +}; + +export const parseSelectChoiceOptions = (value: unknown): ChoiceOptions => { + const parsed = choiceOptionsSchema().safeParse(value); + + if (!parsed.success) { + throw new TypeError('Choice options must be an array or record.'); + } + + return parsed.data; +}; + +export const parseConfirmDefault = (value: unknown, fallback: boolean): boolean => { + const parsed = confirmDefaultSchema.safeParse(value); + + return parsed.success ? parsed.data : fallback; +}; diff --git a/packages/tui/src/prompts/suggest/autocomplete-prompt.ts b/packages/tui/src/prompts/suggest/autocomplete-prompt.ts new file mode 100644 index 00000000..6ad1202f --- /dev/null +++ b/packages/tui/src/prompts/suggest/autocomplete-prompt.ts @@ -0,0 +1,39 @@ +import { runTextSuggestionPrompt } from '#tui/prompts/suggest/lifecycle'; +import { readAutocompleteValue } from '#tui/prompts/suggest/read-autocomplete'; +import { renderSubmittedAutocomplete } from '#tui/prompts/suggest/render-autocomplete'; +import { suggestOptions } from '#tui/prompts/suggest/options'; +import { isSuggestPromptLabel } from '#tui/prompts/suggest/validators/overload'; +import type { SuggestOptions } from '#tui/prompts/suggest/options'; +import type { MaybePromise, TextPromptOptions } from '#tui/types'; + +export function autocomplete(options: SuggestOptions): Promise; + +export function autocomplete( + label: string, + options?: string[] | ((query: string) => MaybePromise), + placeholder?: string, + defaultValue?: string, + required?: boolean | string, + validate?: TextPromptOptions['validate'], + hint?: string, + transform?: TextPromptOptions['transform'], + info?: SuggestOptions['info'], +): Promise; + +export async function autocomplete( + message: string | SuggestOptions, + source: string[] | ((query: string) => MaybePromise) = [], + placeholder = '', + defaultValue = '', + required: boolean | string = false, + validate: TextPromptOptions['validate'] = undefined, + hint = '', + transform: TextPromptOptions['transform'] = undefined, + info: SuggestOptions['info'] = '', +): Promise { + const options = isSuggestPromptLabel(message) + ? suggestOptions({ message, label: message, options: source, placeholder, default: defaultValue, required, validate, hint, transform, info }) + : suggestOptions(message); + + return runTextSuggestionPrompt('autocomplete', options, readAutocompleteValue, renderSubmittedAutocomplete); +} diff --git a/packages/tui/src/prompts/suggest/autocomplete.ts b/packages/tui/src/prompts/suggest/autocomplete.ts new file mode 100644 index 00000000..c0c5976e --- /dev/null +++ b/packages/tui/src/prompts/suggest/autocomplete.ts @@ -0,0 +1,32 @@ +import { Key } from '#tui/key'; +import { characterLength } from '#tui/typed-value/characters'; +import { nextAutocompleteHighlight } from '#tui/prompts/suggest/navigation'; +import type { TypedValueState } from '#tui/typed-value/types'; + +export const autocompleteNavigationDirection = (key: string): 1 | -1 | null => { + if (key === Key.up || key === Key.upArrow) { + return -1; + } + + if (key === Key.down || key === Key.downArrow) { + return 1; + } + + return null; +}; + +export const moveAutocompleteHighlight = (matches: string[], highlighted: number, direction: 1 | -1): number => nextAutocompleteHighlight(matches, highlighted, direction); + +export const canAcceptAutocomplete = (state: TypedValueState): boolean => state.cursor >= characterLength(state.value); + +export const acceptAutocompleteMatch = (state: TypedValueState, match: string | undefined, requireGrowth: boolean): TypedValueState | null => { + if (match === undefined) { + return null; + } + + if (requireGrowth && characterLength(match) <= characterLength(state.value)) { + return null; + } + + return { cursor: characterLength(match), value: match }; +}; diff --git a/packages/tui/src/prompts/suggest/ghost-text.ts b/packages/tui/src/prompts/suggest/ghost-text.ts new file mode 100644 index 00000000..5b77a6e1 --- /dev/null +++ b/packages/tui/src/prompts/suggest/ghost-text.ts @@ -0,0 +1,38 @@ +import { dim, inverse } from '#tui/theme/styles'; +import { characterLength, characters, fromCharacters } from '#tui/typed-value/characters'; +import { valueWithCursor } from '#tui/typed-value/cursor'; +import type { TypedValueState } from '#tui/typed-value/types'; + +const startsWithInput = (match: string, value: string): boolean => { + const normalizedValue = value.toLowerCase(); + + return match.toLowerCase().startsWith(normalizedValue); +}; + +export const autocompleteGhostText = (value: string, match: string | undefined): string => { + if (value.length === 0 || match === undefined || !startsWithInput(match, value) || characterLength(match) <= characterLength(value)) { + return ''; + } + + return fromCharacters(characters(match).slice(characterLength(value))); +}; + +export const autocompleteDisplayValue = (state: TypedValueState, match: string | undefined, placeholder = ''): string => { + if (state.value.length === 0) { + return placeholder; + } + + if (state.cursor < characterLength(state.value)) { + return valueWithCursor(state.value, state.cursor); + } + + const ghostText = autocompleteGhostText(state.value, match); + + if (ghostText.length === 0) { + return valueWithCursor(state.value, state.cursor); + } + + const [cursorCharacter = '', ...remainingGhost] = characters(ghostText); + + return `${state.value}${inverse(cursorCharacter)}${dim(fromCharacters(remainingGhost))}`; +}; diff --git a/packages/tui/src/prompts/suggest/index.ts b/packages/tui/src/prompts/suggest/index.ts new file mode 100644 index 00000000..6b291309 --- /dev/null +++ b/packages/tui/src/prompts/suggest/index.ts @@ -0,0 +1,3 @@ +export { autocomplete } from '#tui/prompts/suggest/autocomplete-prompt'; +export { suggest } from '#tui/prompts/suggest/suggest'; +export type { SuggestOptions } from '#tui/prompts/suggest/options'; diff --git a/packages/tui/src/prompts/suggest/keys.ts b/packages/tui/src/prompts/suggest/keys.ts new file mode 100644 index 00000000..e9b644fb --- /dev/null +++ b/packages/tui/src/prompts/suggest/keys.ts @@ -0,0 +1,58 @@ +import { Key, oneOf } from '#tui/key'; +import { firstSuggestionHighlight, lastSuggestionHighlight, nextSuggestionHighlight, pageSuggestionHighlight } from '#tui/prompts/suggest/navigation'; + +export type SuggestNavigationAction = 'first' | 'last' | 'next' | 'page-next' | 'page-previous' | 'previous'; + +export const suggestNavigationAction = (key: string): SuggestNavigationAction | null => { + if (key === Key.tab || key === Key.down || key === Key.downArrow || key === Key.ctrlN) { + return 'next'; + } + + if (key === Key.up || key === Key.upArrow || key === Key.ctrlP || key === Key.shiftTab) { + return 'previous'; + } + + if (key === Key.pageDown) { + return 'page-next'; + } + + if (key === Key.pageUp) { + return 'page-previous'; + } + + if (oneOf([Key.home, Key.ctrlA], key)) { + return 'first'; + } + + if (oneOf([Key.end, Key.ctrlE], key)) { + return 'last'; + } + + return null; +}; + +export const clearsSuggestionHighlight = (key: string): boolean => oneOf([Key.left, Key.leftArrow, Key.right, Key.rightArrow, Key.ctrlB, Key.ctrlF], key) !== undefined; + +export const moveSuggestionHighlight = (matches: string[], highlighted: number | null, action: SuggestNavigationAction, scroll?: number): number | null => { + if (action === 'next') { + return nextSuggestionHighlight(matches, highlighted, 1); + } + + if (action === 'previous') { + return nextSuggestionHighlight(matches, highlighted, -1); + } + + if (action === 'page-next') { + return pageSuggestionHighlight(matches, highlighted, 1, scroll); + } + + if (action === 'page-previous') { + return pageSuggestionHighlight(matches, highlighted, -1, scroll); + } + + if (action === 'first') { + return firstSuggestionHighlight(matches); + } + + return lastSuggestionHighlight(matches); +}; diff --git a/packages/tui/src/prompts/suggest/lifecycle.ts b/packages/tui/src/prompts/suggest/lifecycle.ts new file mode 100644 index 00000000..e389e847 --- /dev/null +++ b/packages/tui/src/prompts/suggest/lifecycle.ts @@ -0,0 +1,56 @@ +import { promptUntilValid, promptWithFallback } from '#tui/prompt'; +import { activePromptFrame } from '#tui/prompt/active-frame'; +import { createPromptSubmissionState } from '#tui/prompt/submission'; +import { transformedTextDefault } from '#tui/prompts/text-default'; +import type { TextSuggestionReadResult } from '#tui/prompts/suggest/read-result'; +import type { SuggestOptions } from '#tui/prompts/suggest/options'; + +export type TextSuggestionPromptName = 'autocomplete' | 'suggest'; + +type ReadTextSuggestionValue = (options: SuggestOptions) => Promise; + +type RenderSubmittedTextSuggestion = (message: string, value: string) => void; + +export const runTextSuggestionPrompt = async ( + name: TextSuggestionPromptName, + options: SuggestOptions, + readValue: ReadTextSuggestionValue, + renderSubmitted: RenderSubmittedTextSuggestion, +): Promise => { + const validationOptions: SuggestOptions = { + ...options, + default: await transformedTextDefault(options), + }; + + const activeFrame = activePromptFrame(); + const submission = createPromptSubmissionState(undefined); + + return promptWithFallback(name, options, () => + promptUntilValid( + validationOptions, + async () => { + submission.reset(); + + const answer = await readValue(options); + + const value = answer.value === '' && options.default !== undefined ? options.default : answer.value; + + activeFrame.set(answer.frame); + submission.capture(answer.rendered, answer.cancelled, undefined); + + return options.transform ? options.transform(value) : value; + }, + (value) => { + activeFrame.clear(); + submission.render(() => { + renderSubmitted(options.message, value); + }); + }, + (value) => { + options.default = value; + activeFrame.clear(); + submission.reset(); + }, + ), + ); +}; diff --git a/packages/tui/src/prompts/suggest/navigation.ts b/packages/tui/src/prompts/suggest/navigation.ts new file mode 100644 index 00000000..0394f6a4 --- /dev/null +++ b/packages/tui/src/prompts/suggest/navigation.ts @@ -0,0 +1,35 @@ +import { pageIndex } from '#tui/prompts/select/navigation'; + +export const nextSuggestionHighlight = (matches: string[], highlighted: number | null, direction: 1 | -1): number | null => { + if (matches.length === 0) { + return null; + } + + const current = highlighted ?? (direction === 1 ? -1 : matches.length); + + return (current + direction + matches.length) % matches.length; +}; + +export const pageSuggestionHighlight = (matches: string[], highlighted: number | null, direction: 1 | -1, scroll?: number): number | null => { + if (matches.length === 0) { + return null; + } + + if (highlighted === null) { + return direction === 1 ? 0 : Math.max(0, matches.length - 1); + } + + return pageIndex(matches.length, highlighted, direction, scroll); +}; + +export const firstSuggestionHighlight = (matches: string[]): number | null => { + return matches.length === 0 ? null : 0; +}; + +export const lastSuggestionHighlight = (matches: string[]): number | null => { + return matches.length === 0 ? null : matches.length - 1; +}; + +export const nextAutocompleteHighlight = (matches: string[], highlighted: number, direction: 1 | -1): number => { + return matches.length === 0 ? 0 : (highlighted + direction + matches.length) % matches.length; +}; diff --git a/packages/tui/src/prompts/suggest/options.ts b/packages/tui/src/prompts/suggest/options.ts new file mode 100644 index 00000000..0b58028b --- /dev/null +++ b/packages/tui/src/prompts/suggest/options.ts @@ -0,0 +1,27 @@ +import { isSuggestPromptLabel } from '#tui/prompts/suggest/validators/overload'; +import type { MaybePromise, TextPromptOptions } from '#tui/types'; + +export type SuggestOptions = TextPromptOptions & { + options: string[] | ((query: string) => MaybePromise); + scroll?: number; + info?: string | ((value: string | null) => string | null | undefined); +}; + +export const suggestOptions = ( + message: string | SuggestOptions, + options: string[] | ((query: string) => MaybePromise) = [], + placeholder = '', + defaultValue = '', + scroll = 5, + required: boolean | string = false, + validate: TextPromptOptions['validate'] = undefined, + hint = '', + transform: TextPromptOptions['transform'] = undefined, + info: SuggestOptions['info'] = '', +): SuggestOptions => { + if (isSuggestPromptLabel(message)) { + return { message, label: message, options, placeholder, default: defaultValue, scroll, required, validate, hint, transform, info }; + } + + return { ...message, default: message.default ?? '' }; +}; diff --git a/packages/tui/src/prompts/suggest/read-autocomplete.ts b/packages/tui/src/prompts/suggest/read-autocomplete.ts new file mode 100644 index 00000000..e1d8b282 --- /dev/null +++ b/packages/tui/src/prompts/suggest/read-autocomplete.ts @@ -0,0 +1,62 @@ +import { promptEnvironment } from '#tui/environment'; +import { Key } from '#tui/key'; +import { ask } from '#tui/prompt'; +import { autocompleteNavigationDirection, canAcceptAutocomplete } from '#tui/prompts/suggest/autocomplete'; +import { cancelAutocompleteValue } from '#tui/prompts/suggest/read-autocomplete/cancel'; +import { suggestionReadResult } from '#tui/prompts/suggest/read-result'; +import { createAutocompleteReaderSession } from '#tui/prompts/suggest/read-autocomplete/session'; +import { currentAutocompleteValue } from '#tui/prompts/suggest/read-autocomplete/submission'; +import type { TextSuggestionReadResult } from '#tui/prompts/suggest/read-result'; +import type { SuggestOptions } from '#tui/prompts/suggest/options'; + +export type AutocompleteReadResult = TextSuggestionReadResult; + +export const readAutocompleteValue = async (options: SuggestOptions): Promise => { + const environment = promptEnvironment(); + + if (!environment.input.readKey) { + return suggestionReadResult(await ask(options.message, options.hint), false); + } + + const session = await createAutocompleteReaderSession(options); + + session.render(); + + while (true) { + const key = await environment.input.readKey(); + + if (key === null) { + return currentAutocompleteValue(session); + } + + const direction = autocompleteNavigationDirection(key); + + if (direction !== null) { + await session.move(direction); + + continue; + } + + if (key === Key.tab && canAcceptAutocomplete(session.state())) { + await session.acceptHighlighted(true); + + continue; + } + + if ((key === Key.right || key === Key.rightArrow) && canAcceptAutocomplete(session.state())) { + await session.acceptHighlighted(false); + + continue; + } + + const next = await session.applyTypedInput(key); + + if (next.submitted) { + return currentAutocompleteValue(session); + } + + if (next.cancelled) { + return cancelAutocompleteValue(options, session); + } + } +}; diff --git a/packages/tui/src/prompts/suggest/read-autocomplete/cancel.ts b/packages/tui/src/prompts/suggest/read-autocomplete/cancel.ts new file mode 100644 index 00000000..a965c900 --- /dev/null +++ b/packages/tui/src/prompts/suggest/read-autocomplete/cancel.ts @@ -0,0 +1,16 @@ +import { cancelPrompt } from '#tui/prompt'; +import { renderCancelledAutocomplete } from '#tui/prompts/suggest/render-autocomplete'; +import { suggestionReadResult } from '#tui/prompts/suggest/read-result'; +import { eraseRenderedFrame } from '#tui/status/frame'; +import type { AutocompleteReaderSession } from '#tui/prompts/suggest/read-autocomplete/session'; +import type { SuggestOptions } from '#tui/prompts/suggest/options'; +import type { TextSuggestionReadResult } from '#tui/prompts/suggest/read-result'; + +export const cancelAutocompleteValue = async (options: SuggestOptions, session: AutocompleteReaderSession): Promise => { + const value = session.state().value; + + eraseRenderedFrame(session.frame()); + renderCancelledAutocomplete(options.message, value, options.placeholder); + + return suggestionReadResult(await cancelPrompt(value), true, true); +}; diff --git a/packages/tui/src/prompts/suggest/read-autocomplete/frame.ts b/packages/tui/src/prompts/suggest/read-autocomplete/frame.ts new file mode 100644 index 00000000..a8848a2c --- /dev/null +++ b/packages/tui/src/prompts/suggest/read-autocomplete/frame.ts @@ -0,0 +1,32 @@ +import { eraseRenderedFrame } from '#tui/status/frame'; +import { renderAutocomplete } from '#tui/prompts/suggest/render-autocomplete'; +import type { SuggestOptions } from '#tui/prompts/suggest/options'; +import type { TypedValueState } from '#tui/typed-value/types'; + +type AutocompleteFrameRenderOptions = { + highlighted: number; + matches: string[]; + state: TypedValueState; +}; + +type AutocompleteFrameRenderer = { + current(): string; + render(options: AutocompleteFrameRenderOptions): void; +}; + +export const createAutocompleteFrameRenderer = (options: SuggestOptions): AutocompleteFrameRenderer => { + let frame = ''; + + return { + current() { + return frame; + }, + render({ highlighted, matches, state }) { + if (frame.length > 0) { + eraseRenderedFrame(frame); + } + + frame = renderAutocomplete(options.message, state, matches, highlighted, options.hint, options.placeholder, options.info); + }, + }; +}; diff --git a/packages/tui/src/prompts/suggest/read-autocomplete/session.ts b/packages/tui/src/prompts/suggest/read-autocomplete/session.ts new file mode 100644 index 00000000..9a9529b6 --- /dev/null +++ b/packages/tui/src/prompts/suggest/read-autocomplete/session.ts @@ -0,0 +1,54 @@ +import { createAutocompleteFrameRenderer } from '#tui/prompts/suggest/read-autocomplete/frame'; +import { createAutocompleteReaderState } from '#tui/prompts/suggest/read-autocomplete/state'; +import type { TypedValueState } from '#tui/typed-value/types'; +import type { SuggestOptions } from '#tui/prompts/suggest/options'; + +export type AutocompleteReaderSession = { + acceptHighlighted(requireGrowth: boolean): Promise; + applyTypedInput(key: string): Promise<{ cancelled: boolean; submitted: boolean }>; + frame(): string; + move(direction: 1 | -1): Promise; + render(): void; + state(): TypedValueState; +}; + +export const createAutocompleteReaderSession = async (options: SuggestOptions): Promise => { + const state = await createAutocompleteReaderState(options); + + const frame = createAutocompleteFrameRenderer(options); + + function render(): void { + frame.render({ highlighted: state.highlighted(), matches: state.matches(), state: state.value() }); + } + + return { + async acceptHighlighted(requireGrowth) { + await state.acceptHighlighted(requireGrowth); + + render(); + }, + async applyTypedInput(key) { + const next = await state.applyTypedInput(key); + + if (next.submitted || next.cancelled) { + return next; + } + + render(); + + return next; + }, + frame() { + return frame.current(); + }, + async move(direction) { + await state.move(direction); + + render(); + }, + render, + state() { + return state.value(); + }, + }; +}; diff --git a/packages/tui/src/prompts/suggest/read-autocomplete/state.ts b/packages/tui/src/prompts/suggest/read-autocomplete/state.ts new file mode 100644 index 00000000..e00748c4 --- /dev/null +++ b/packages/tui/src/prompts/suggest/read-autocomplete/state.ts @@ -0,0 +1,61 @@ +import { acceptAutocompleteMatch } from '#tui/prompts/suggest/autocomplete'; +import { createAutocompleteHighlightState } from '#tui/prompts/suggest/read-autocomplete/state/highlight'; +import { createSuggestQueryState } from '#tui/prompts/suggest/state/query'; +import type { SuggestOptions } from '#tui/prompts/suggest/options'; +import type { TypedValueState } from '#tui/typed-value/types'; + +export type AutocompleteReaderState = { + acceptHighlighted(requireGrowth: boolean): Promise; + applyTypedInput(key: string): Promise<{ cancelled: boolean; submitted: boolean }>; + highlighted(): number; + matches(): string[]; + move(direction: 1 | -1): Promise; + value(): TypedValueState; +}; + +export const createAutocompleteReaderState = async (options: SuggestOptions): Promise => { + const query = await createSuggestQueryState(options); + + const highlighted = createAutocompleteHighlightState(); + + return { + async acceptHighlighted(requireGrowth) { + await query.resolve(); + + const next = acceptAutocompleteMatch(query.value(), query.matches()[highlighted.value()], requireGrowth); + + if (next !== null) { + await query.set(next); + } else { + highlighted.reset(); + } + }, + async applyTypedInput(key) { + const next = await query.applyTypedInput(key); + + if (next.submitted || next.cancelled) { + return { cancelled: next.cancelled, submitted: next.submitted }; + } + + if (next.changed) { + highlighted.reset(); + } + + return { cancelled: false, submitted: false }; + }, + highlighted() { + return highlighted.value(); + }, + matches() { + return query.matches(); + }, + async move(direction) { + await query.resolve(); + + highlighted.move(query.matches(), direction); + }, + value() { + return query.value(); + }, + }; +}; diff --git a/packages/tui/src/prompts/suggest/read-autocomplete/state/highlight.ts b/packages/tui/src/prompts/suggest/read-autocomplete/state/highlight.ts new file mode 100644 index 00000000..eb3ca479 --- /dev/null +++ b/packages/tui/src/prompts/suggest/read-autocomplete/state/highlight.ts @@ -0,0 +1,23 @@ +import { moveAutocompleteHighlight } from '#tui/prompts/suggest/autocomplete'; + +export type AutocompleteHighlightState = { + move(matches: string[], direction: 1 | -1): void; + reset(): void; + value(): number; +}; + +export const createAutocompleteHighlightState = (): AutocompleteHighlightState => { + let highlighted = 0; + + return { + move(matches, direction) { + highlighted = moveAutocompleteHighlight(matches, highlighted, direction); + }, + reset() { + highlighted = 0; + }, + value() { + return highlighted; + }, + }; +}; diff --git a/packages/tui/src/prompts/suggest/read-autocomplete/submission.ts b/packages/tui/src/prompts/suggest/read-autocomplete/submission.ts new file mode 100644 index 00000000..a574284c --- /dev/null +++ b/packages/tui/src/prompts/suggest/read-autocomplete/submission.ts @@ -0,0 +1,7 @@ +import { suggestionReadResult } from '#tui/prompts/suggest/read-result'; +import type { AutocompleteReaderSession } from '#tui/prompts/suggest/read-autocomplete/session'; +import type { TextSuggestionReadResult } from '#tui/prompts/suggest/read-result'; + +export const currentAutocompleteValue = (session: AutocompleteReaderSession): TextSuggestionReadResult => { + return suggestionReadResult(session.state().value, true, false, session.frame()); +}; diff --git a/packages/tui/src/prompts/suggest/read-result.ts b/packages/tui/src/prompts/suggest/read-result.ts new file mode 100644 index 00000000..9472c8e3 --- /dev/null +++ b/packages/tui/src/prompts/suggest/read-result.ts @@ -0,0 +1,21 @@ +import { characterLength } from '#tui/typed-value/characters'; +import type { TypedValueState } from '#tui/typed-value'; + +export type TextSuggestionReadResult = { + cancelled: boolean; + frame?: string; + rendered: boolean; + value: string; +}; + +export const initialSuggestionState = (value = ''): TypedValueState => ({ + cursor: characterLength(value), + value, +}); + +export const suggestionReadResult = (value: string, rendered: boolean, cancelled = false, frame?: string): TextSuggestionReadResult => ({ + cancelled, + frame, + rendered, + value, +}); diff --git a/packages/tui/src/prompts/suggest/read.ts b/packages/tui/src/prompts/suggest/read.ts new file mode 100644 index 00000000..b02ba5b6 --- /dev/null +++ b/packages/tui/src/prompts/suggest/read.ts @@ -0,0 +1,59 @@ +import { promptEnvironment } from '#tui/environment'; +import { Key } from '#tui/key'; +import { ask } from '#tui/prompt'; +import { clearsSuggestionHighlight, suggestNavigationAction } from '#tui/prompts/suggest/keys'; +import { cancelSuggestionValue } from '#tui/prompts/suggest/read/cancel'; +import { currentSuggestionValue, highlightedSuggestionValue } from '#tui/prompts/suggest/read/submission'; +import { suggestionReadResult } from '#tui/prompts/suggest/read-result'; +import { createSuggestReaderSession } from '#tui/prompts/suggest/read/session'; +import type { TextSuggestionReadResult } from '#tui/prompts/suggest/read-result'; +import type { SuggestOptions } from '#tui/prompts/suggest/options'; + +export type SuggestReadResult = TextSuggestionReadResult; + +export const readSuggestionValue = async (options: SuggestOptions): Promise => { + const environment = promptEnvironment(); + + if (!environment.input.readKey) { + return suggestionReadResult(await ask(options.message, options.hint), false); + } + + const session = await createSuggestReaderSession(options); + + session.render(); + + while (true) { + const key = await environment.input.readKey(); + + if (key === null) { + return highlightedSuggestionValue(session); + } + + const action = suggestNavigationAction(key); + + if (action !== null && (action !== 'first' || session.highlighted() !== null) && (action !== 'last' || session.highlighted() !== null)) { + await session.move(action); + + continue; + } + + if (clearsSuggestionHighlight(key) && session.highlighted() !== null) { + session.clearHighlight(); + continue; + } + + if (key === Key.enter) { + return highlightedSuggestionValue(session); + } + + const next = await session.applyTypedInput(key); + + if (next.submitted) { + return currentSuggestionValue(session); + } + + if (next.cancelled) { + return cancelSuggestionValue(options, session); + } + } +}; diff --git a/packages/tui/src/prompts/suggest/read/cancel.ts b/packages/tui/src/prompts/suggest/read/cancel.ts new file mode 100644 index 00000000..fec4286d --- /dev/null +++ b/packages/tui/src/prompts/suggest/read/cancel.ts @@ -0,0 +1,16 @@ +import { cancelPrompt } from '#tui/prompt'; +import { renderCancelledSuggestion } from '#tui/prompts/suggest/render'; +import { suggestionReadResult } from '#tui/prompts/suggest/read-result'; +import { eraseRenderedFrame } from '#tui/status/frame'; +import type { SuggestOptions } from '#tui/prompts/suggest/options'; +import type { TextSuggestionReadResult } from '#tui/prompts/suggest/read-result'; +import type { SuggestReaderSession } from '#tui/prompts/suggest/read/session'; + +export const cancelSuggestionValue = async (options: SuggestOptions, session: SuggestReaderSession): Promise => { + const value = session.state().value; + + eraseRenderedFrame(session.frame()); + renderCancelledSuggestion(options.message, value, options.placeholder); + + return suggestionReadResult(await cancelPrompt(value), true, true); +}; diff --git a/packages/tui/src/prompts/suggest/read/frame.ts b/packages/tui/src/prompts/suggest/read/frame.ts new file mode 100644 index 00000000..f77f3aa6 --- /dev/null +++ b/packages/tui/src/prompts/suggest/read/frame.ts @@ -0,0 +1,32 @@ +import { eraseRenderedFrame } from '#tui/status/frame'; +import { renderSuggestions } from '#tui/prompts/suggest/render'; +import type { SuggestOptions } from '#tui/prompts/suggest/options'; +import type { TypedValueState } from '#tui/typed-value/types'; + +type SuggestFrameRenderOptions = { + highlighted: number | null; + matches: string[]; + state: TypedValueState; +}; + +type SuggestFrameRenderer = { + current(): string; + render(options: SuggestFrameRenderOptions): void; +}; + +export const createSuggestFrameRenderer = (options: SuggestOptions): SuggestFrameRenderer => { + let frame = ''; + + return { + current() { + return frame; + }, + render({ highlighted, matches, state }) { + if (frame.length > 0) { + eraseRenderedFrame(frame); + } + + frame = renderSuggestions(options.message, state.value, state.cursor, matches, highlighted, options.scroll, options.info, options.placeholder); + }, + }; +}; diff --git a/packages/tui/src/prompts/suggest/read/session.ts b/packages/tui/src/prompts/suggest/read/session.ts new file mode 100644 index 00000000..40282405 --- /dev/null +++ b/packages/tui/src/prompts/suggest/read/session.ts @@ -0,0 +1,62 @@ +import { createSuggestFrameRenderer } from '#tui/prompts/suggest/read/frame'; +import { createSuggestReaderState } from '#tui/prompts/suggest/read/state'; +import type { SuggestNavigationAction } from '#tui/prompts/suggest/keys'; +import type { SuggestOptions } from '#tui/prompts/suggest/options'; +import type { TypedValueState } from '#tui/typed-value/types'; + +export type SuggestReaderSession = { + applyTypedInput(key: string): Promise<{ cancelled: boolean; submitted: boolean }>; + clearHighlight(): void; + frame(): string; + highlighted(): number | null; + matches(): string[]; + move(action: SuggestNavigationAction): Promise; + render(): void; + state(): TypedValueState; +}; + +export const createSuggestReaderSession = async (options: SuggestOptions): Promise => { + const state = await createSuggestReaderState(options); + + const frame = createSuggestFrameRenderer(options); + + function render(): void { + frame.render({ highlighted: state.highlighted(), matches: state.matches(), state: state.value() }); + } + + return { + async applyTypedInput(key) { + const next = await state.applyTypedInput(key); + + if (next.submitted || next.cancelled) { + return next; + } + + render(); + + return next; + }, + clearHighlight() { + state.clearHighlight(); + render(); + }, + frame() { + return frame.current(); + }, + highlighted() { + return state.highlighted(); + }, + matches() { + return state.matches(); + }, + async move(action) { + await state.move(action); + + render(); + }, + render, + state() { + return state.value(); + }, + }; +}; diff --git a/packages/tui/src/prompts/suggest/read/state.ts b/packages/tui/src/prompts/suggest/read/state.ts new file mode 100644 index 00000000..7fa27137 --- /dev/null +++ b/packages/tui/src/prompts/suggest/read/state.ts @@ -0,0 +1,53 @@ +import { createSuggestHighlightState } from '#tui/prompts/suggest/read/state/highlight'; +import { createSuggestQueryState } from '#tui/prompts/suggest/state/query'; +import type { SuggestNavigationAction } from '#tui/prompts/suggest/keys'; +import type { SuggestOptions } from '#tui/prompts/suggest/options'; +import type { TypedValueState } from '#tui/typed-value/types'; + +export type SuggestReaderState = { + applyTypedInput(key: string): Promise<{ cancelled: boolean; submitted: boolean }>; + clearHighlight(): void; + highlighted(): number | null; + matches(): string[]; + move(action: SuggestNavigationAction): Promise; + value(): TypedValueState; +}; + +export const createSuggestReaderState = async (options: SuggestOptions): Promise => { + const query = await createSuggestQueryState(options); + + const highlighted = createSuggestHighlightState(); + + return { + async applyTypedInput(key) { + const next = await query.applyTypedInput(key); + + if (next.submitted || next.cancelled) { + return { cancelled: next.cancelled, submitted: next.submitted }; + } + + if (next.changed) { + highlighted.clear(); + } + + return { cancelled: false, submitted: false }; + }, + clearHighlight() { + highlighted.clear(); + }, + highlighted() { + return highlighted.value(); + }, + matches() { + return query.matches(); + }, + async move(action) { + await query.resolve(); + + highlighted.move(query.matches(), action, options.scroll); + }, + value() { + return query.value(); + }, + }; +}; diff --git a/packages/tui/src/prompts/suggest/read/state/highlight.ts b/packages/tui/src/prompts/suggest/read/state/highlight.ts new file mode 100644 index 00000000..ddf43524 --- /dev/null +++ b/packages/tui/src/prompts/suggest/read/state/highlight.ts @@ -0,0 +1,24 @@ +import { moveSuggestionHighlight } from '#tui/prompts/suggest/keys'; +import type { SuggestNavigationAction } from '#tui/prompts/suggest/keys'; + +export type SuggestHighlightState = { + clear(): void; + move(matches: string[], action: SuggestNavigationAction, scroll?: number): void; + value(): number | null; +}; + +export const createSuggestHighlightState = (): SuggestHighlightState => { + let highlighted: number | null = null; + + return { + clear() { + highlighted = null; + }, + move(matches, action, scroll) { + highlighted = moveSuggestionHighlight(matches, highlighted, action, scroll); + }, + value() { + return highlighted; + }, + }; +}; diff --git a/packages/tui/src/prompts/suggest/read/submission.ts b/packages/tui/src/prompts/suggest/read/submission.ts new file mode 100644 index 00000000..64233990 --- /dev/null +++ b/packages/tui/src/prompts/suggest/read/submission.ts @@ -0,0 +1,14 @@ +import { suggestionReadResult } from '#tui/prompts/suggest/read-result'; +import type { TextSuggestionReadResult } from '#tui/prompts/suggest/read-result'; +import type { SuggestReaderSession } from '#tui/prompts/suggest/read/session'; + +export const currentSuggestionValue = (session: SuggestReaderSession): TextSuggestionReadResult => { + return suggestionReadResult(session.state().value, true, false, session.frame()); +}; + +export const highlightedSuggestionValue = (session: SuggestReaderSession): TextSuggestionReadResult => { + const highlighted = session.highlighted(); + const match = highlighted === null ? undefined : session.matches()[highlighted]; + + return suggestionReadResult(match ?? session.state().value, true, false, session.frame()); +}; diff --git a/packages/tui/src/prompts/suggest/render-autocomplete.ts b/packages/tui/src/prompts/suggest/render-autocomplete.ts new file mode 100644 index 00000000..5634957d --- /dev/null +++ b/packages/tui/src/prompts/suggest/render-autocomplete.ts @@ -0,0 +1,29 @@ +import { promptEnvironment } from '#tui/environment'; +import { joinedInfoDetails, resolveInfo } from '#tui/concerns/info'; +import { autocompleteDisplayValue } from '#tui/prompts/suggest/ghost-text'; +import { renderBox } from '#tui/theme/box'; +import { cyan, dim, red, strikethrough } from '#tui/theme/styles'; +import type { SuggestOptions } from '#tui/prompts/suggest/options'; +import type { TypedValueState } from '#tui/typed-value/types'; + +export const renderAutocomplete = (message: string, state: TypedValueState, matches: string[], highlighted: number, hint = '', placeholder = '', info?: SuggestOptions['info']): string => { + const value = state.value.length === 0 ? dim(placeholder) : autocompleteDisplayValue(state, matches[highlighted], placeholder); + const details = joinedInfoDetails(hint, resolveInfo(info, matches[highlighted] ?? null)); + const frame = `${renderBox({ body: value, borderStyle: cyan, info: details, title: cyan(message) })}\n`; + + promptEnvironment().output.write(frame); + + return frame; +}; + +export const renderSubmittedAutocomplete = (message: string, value: string): void => { + promptEnvironment().output.write(`${renderBox({ body: value, title: dim(message) })}\n`); +}; + +export const renderCancelledAutocomplete = (message: string, value: string, placeholder = ''): void => { + const environment = promptEnvironment(); + const displayValue = value.length > 0 ? value : placeholder; + + environment.output.write(`${renderBox({ body: strikethrough(dim(displayValue)), borderStyle: red, title: message })}\n`); + environment.error.write(`${red(' ⚠ Cancelled.')}\n`); +}; diff --git a/packages/tui/src/prompts/suggest/render.ts b/packages/tui/src/prompts/suggest/render.ts new file mode 100644 index 00000000..5213ece6 --- /dev/null +++ b/packages/tui/src/prompts/suggest/render.ts @@ -0,0 +1,35 @@ +import { promptEnvironment } from '#tui/environment'; +import { resolveInfo } from '#tui/concerns/info'; +import { renderSuggestBody } from '#tui/prompts/suggest/render/body'; +import { renderBox } from '#tui/theme/box'; +import { cyan, dim, red, strikethrough } from '#tui/theme/styles'; +import type { SuggestOptions } from '#tui/prompts/suggest/options'; + +export const renderSuggestions = ( + message: string, + value: string, + cursor: number, + matches: string[], + highlighted: number | null, + scroll?: number, + info?: SuggestOptions['info'], + placeholder = '', +): string => { + const text = resolveInfo(info, highlighted === null ? null : (matches[highlighted] ?? null)); + const frame = `${renderBox({ body: renderSuggestBody(value, cursor, placeholder, matches, highlighted, scroll), borderStyle: cyan, info: text, title: cyan(message) })}\n`; + + promptEnvironment().output.write(frame); + + return frame; +}; + +export const renderSubmittedSuggestion = (message: string, value: string): void => { + promptEnvironment().output.write(`${renderBox({ body: value, title: dim(message) })}\n`); +}; + +export const renderCancelledSuggestion = (message: string, value: string, placeholder = ''): void => { + const displayValue = value.length > 0 ? value : placeholder; + + promptEnvironment().output.write(`${renderBox({ body: strikethrough(dim(displayValue)), borderStyle: red, title: message })}\n`); + promptEnvironment().error.write(`${red(' ⚠ Cancelled.')}\n`); +}; diff --git a/packages/tui/src/prompts/suggest/render/body.ts b/packages/tui/src/prompts/suggest/render/body.ts new file mode 100644 index 00000000..089bc87e --- /dev/null +++ b/packages/tui/src/prompts/suggest/render/body.ts @@ -0,0 +1,14 @@ +import { dim } from '#tui/theme/styles'; +import { valueWithCursor } from '#tui/typed-value/cursor'; +import { renderSuggestRows } from '#tui/prompts/suggest/render/rows'; + +export const renderSuggestBody = (value: string, cursor: number, placeholder: string, matches: string[], highlighted: number | null, scroll?: number): string => { + const query = value.length > 0 ? valueWithCursor(value, cursor) : dim(placeholder); + const rows = renderSuggestRows(matches, highlighted, scroll); + + if (matches.length === 0) { + return [query, dim(' No results.')].join('\n'); + } + + return rows.length > 0 ? [query, rows].join('\n') : query; +}; diff --git a/packages/tui/src/prompts/suggest/render/rows.ts b/packages/tui/src/prompts/suggest/render/rows.ts new file mode 100644 index 00000000..4e6748d5 --- /dev/null +++ b/packages/tui/src/prompts/suggest/render/rows.ts @@ -0,0 +1,19 @@ +import { choiceWindow } from '#tui/concerns/choices'; +import { renderScrollbarRows } from '#tui/concerns/scrollbar'; +import { cyan, dim } from '#tui/theme/styles'; + +export const renderSuggestRows = (matches: string[], highlighted: number | null, scroll?: number): string => { + const window = choiceWindow(matches.length, highlighted ?? 0, scroll); + + const rows = matches.slice(window.start, window.end).map((match, offset) => { + const index = window.start + offset; + + if (index === highlighted) { + return `${cyan('›')} ${match} `; + } + + return ` ${dim(match)} `; + }); + + return renderScrollbarRows(rows, window.start, window.end - window.start, matches.length).join('\n'); +}; diff --git a/packages/tui/src/prompts/suggest/resolve.ts b/packages/tui/src/prompts/suggest/resolve.ts new file mode 100644 index 00000000..aae7f4b6 --- /dev/null +++ b/packages/tui/src/prompts/suggest/resolve.ts @@ -0,0 +1,14 @@ +import { resolveSuggestionSource } from '#tui/prompts/suggest/validators/source'; +import type { SuggestOptions } from '#tui/prompts/suggest/options'; + +export const resolveSuggestions = async (source: SuggestOptions['options'], query: string): Promise => { + const resolved = await resolveSuggestionSource(source, query); + + if (!resolved.filter) { + return [...resolved.options]; + } + + const normalizedQuery = query.toLowerCase(); + + return resolved.options.filter((option) => option.toLowerCase().startsWith(normalizedQuery)); +}; diff --git a/packages/tui/src/prompts/suggest/state/query.ts b/packages/tui/src/prompts/suggest/state/query.ts new file mode 100644 index 00000000..aad2a208 --- /dev/null +++ b/packages/tui/src/prompts/suggest/state/query.ts @@ -0,0 +1,57 @@ +import { applyTypedKey } from '#tui/typed-value'; +import { initialSuggestionState } from '#tui/prompts/suggest/read-result'; +import { resolveSuggestions } from '#tui/prompts/suggest/resolve'; +import type { SuggestOptions } from '#tui/prompts/suggest/options'; +import type { TypedValueState } from '#tui/typed-value/types'; + +export type SuggestQueryInputResult = { + cancelled: boolean; + changed: boolean; + submitted: boolean; +}; + +export type SuggestQueryState = { + applyTypedInput(key: string): Promise; + matches(): string[]; + resolve(): Promise; + set(next: TypedValueState): Promise; + value(): TypedValueState; +}; + +export const createSuggestQueryState = async (options: SuggestOptions): Promise => { + let value = initialSuggestionState(options.default ?? ''); + + let matches = await resolveSuggestions(options.options, value.value); + + const resolve = async (): Promise => { + matches = await resolveSuggestions(options.options, value.value); + }; + + return { + async applyTypedInput(key) { + const next = applyTypedKey(value, key); + + if (next.submitted || next.cancelled) { + return { cancelled: next.cancelled, changed: false, submitted: next.submitted }; + } + + value = { cursor: next.cursor, value: next.value }; + + await resolve(); + + return { cancelled: false, changed: true, submitted: false }; + }, + matches() { + return matches; + }, + resolve, + async set(next) { + value = next; + + await resolve(); + }, + value() { + return value; + }, + }; +}; diff --git a/packages/tui/src/prompts/suggest/suggest.ts b/packages/tui/src/prompts/suggest/suggest.ts new file mode 100644 index 00000000..fe624723 --- /dev/null +++ b/packages/tui/src/prompts/suggest/suggest.ts @@ -0,0 +1,38 @@ +import { runTextSuggestionPrompt } from '#tui/prompts/suggest/lifecycle'; +import { readSuggestionValue } from '#tui/prompts/suggest/read'; +import { renderSubmittedSuggestion } from '#tui/prompts/suggest/render'; +import { suggestOptions } from '#tui/prompts/suggest/options'; +import type { SuggestOptions } from '#tui/prompts/suggest/options'; +import type { MaybePromise, TextPromptOptions } from '#tui/types'; + +export function suggest(options: SuggestOptions): Promise; + +export function suggest( + label: string, + options: string[] | ((query: string) => MaybePromise), + placeholder?: string, + defaultValue?: string, + scroll?: number, + required?: boolean | string, + validate?: TextPromptOptions['validate'], + hint?: string, + transform?: TextPromptOptions['transform'], + info?: SuggestOptions['info'], +): Promise; + +export async function suggest( + message: string | SuggestOptions, + source: string[] | ((query: string) => MaybePromise) = [], + placeholder = '', + defaultValue = '', + scroll = 5, + required: boolean | string = false, + validate: TextPromptOptions['validate'] = undefined, + hint = '', + transform: TextPromptOptions['transform'] = undefined, + info: SuggestOptions['info'] = '', +): Promise { + const options = suggestOptions(message, source, placeholder, defaultValue, scroll, required, validate, hint, transform, info); + + return runTextSuggestionPrompt('suggest', options, readSuggestionValue, renderSubmittedSuggestion); +} diff --git a/packages/tui/src/prompts/suggest/validators/overload.ts b/packages/tui/src/prompts/suggest/validators/overload.ts new file mode 100644 index 00000000..bdefbeb4 --- /dev/null +++ b/packages/tui/src/prompts/suggest/validators/overload.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; + +const suggestPromptLabelSchema = z.string(); + +export const isSuggestPromptLabel = (value: unknown): value is string => { + return suggestPromptLabelSchema.safeParse(value).success; +}; diff --git a/packages/tui/src/prompts/suggest/validators/source.ts b/packages/tui/src/prompts/suggest/validators/source.ts new file mode 100644 index 00000000..2d757c3a --- /dev/null +++ b/packages/tui/src/prompts/suggest/validators/source.ts @@ -0,0 +1,44 @@ +import { z } from 'zod'; +import { functionSchema } from '#tui/validators/function'; +import type { MaybePromise } from '#tui/types'; + +type SuggestionSourceCallback = (query: string) => MaybePromise; + +export type ResolvedSuggestionSource = { + filter: boolean; + options: string[]; +}; + +const suggestionOptionsSchema = z.array(z.string()); +const suggestionSourceCallbackSchema: z.ZodType = functionSchema(); +const suggestionSourceSchema = z.union([suggestionOptionsSchema, suggestionSourceCallbackSchema]); + +export const isSuggestionSourceCallback = (source: string[] | SuggestionSourceCallback): source is SuggestionSourceCallback => { + return suggestionSourceCallbackSchema.safeParse(source).success; +}; + +export const parseSuggestionSource = (source: unknown): string[] | SuggestionSourceCallback => { + const parsed = suggestionSourceSchema.safeParse(source); + + if (!parsed.success) { + throw new TypeError('Suggestion source must be an array of strings or callback.'); + } + + return parsed.data; +}; + +export const resolveSuggestionSource = async (source: string[] | SuggestionSourceCallback, query: string): Promise => { + const parsed = parseSuggestionSource(source); + + if (isSuggestionSourceCallback(parsed)) { + const options = suggestionOptionsSchema.safeParse(await parsed(query)); + + if (!options.success) { + throw new TypeError('Suggestion source callbacks must return an array of strings.'); + } + + return { filter: false, options: options.data }; + } + + return { filter: true, options: parsed }; +}; diff --git a/packages/tui/src/prompts/text-default.ts b/packages/tui/src/prompts/text-default.ts new file mode 100644 index 00000000..4d1a5710 --- /dev/null +++ b/packages/tui/src/prompts/text-default.ts @@ -0,0 +1,11 @@ +import type { TextPromptOptions } from '#tui/types'; + +export const transformedTextDefault = async (options: Pick): Promise => { + const value = options.default ?? ''; + + try { + return options.transform ? await options.transform(value) : value; + } catch { + return value; + } +}; diff --git a/packages/tui/src/prompts/text.ts b/packages/tui/src/prompts/text.ts new file mode 100644 index 00000000..28311c71 --- /dev/null +++ b/packages/tui/src/prompts/text.ts @@ -0,0 +1,37 @@ +import { normalizeTextPromptOptions } from '#tui/prompts/text/options'; +import { runTextPrompt } from '#tui/prompts/text/run'; +import type { TextPromptOptions } from '#tui/types'; + +export function text(options: TextPromptOptions): Promise; + +export function text( + label: string, + placeholder?: string, + defaultValue?: string, + required?: boolean | string, + validate?: TextPromptOptions['validate'], + hint?: string, + transform?: TextPromptOptions['transform'], +): Promise; + +export async function text( + message: string | TextPromptOptions, + placeholder = '', + defaultValue = '', + required: boolean | string = false, + validate: TextPromptOptions['validate'] = undefined, + hint = '', + transform: TextPromptOptions['transform'] = undefined, +): Promise { + return runTextPrompt( + normalizeTextPromptOptions({ + defaultValue, + hint, + message, + placeholder, + required, + transform, + validate, + }), + ); +} diff --git a/packages/tui/src/prompts/text/options.ts b/packages/tui/src/prompts/text/options.ts new file mode 100644 index 00000000..18620151 --- /dev/null +++ b/packages/tui/src/prompts/text/options.ts @@ -0,0 +1,30 @@ +import { textOptions } from '#tui/concerns/text-options'; +import { isTextPromptLabel } from '#tui/concerns/validators/text-options'; +import type { TextPromptOptions } from '#tui/types'; + +export type TextPromptArgumentOptions = { + defaultValue: string; + hint: string; + message: string | TextPromptOptions; + placeholder: string; + required: boolean | string; + transform?: TextPromptOptions['transform']; + validate?: TextPromptOptions['validate']; +}; + +export const normalizeTextPromptOptions = (options: TextPromptArgumentOptions): TextPromptOptions => { + if (!isTextPromptLabel(options.message)) { + return textOptions(options.message); + } + + return textOptions({ + default: options.defaultValue, + hint: options.hint, + label: options.message, + message: options.message, + placeholder: options.placeholder, + required: options.required, + transform: options.transform, + validate: options.validate, + }); +}; diff --git a/packages/tui/src/prompts/text/run.ts b/packages/tui/src/prompts/text/run.ts new file mode 100644 index 00000000..80d6094e --- /dev/null +++ b/packages/tui/src/prompts/text/run.ts @@ -0,0 +1,50 @@ +import { promptUntilValid, promptWithFallback } from '#tui/prompt'; +import { activePromptFrame } from '#tui/prompt/active-frame'; +import { createPromptSubmissionState } from '#tui/prompt/submission'; +import { transformedTextDefault } from '#tui/prompts/text-default'; +import { readTypedValue } from '#tui/typed-value'; +import { renderSubmittedTypedValue } from '#tui/typed-value/render'; +import type { TextPromptOptions } from '#tui/types'; + +export const runTextPrompt = async (options: TextPromptOptions): Promise => { + const validationOptions: TextPromptOptions = { + ...options, + default: await transformedTextDefault(options), + }; + + const activeFrame = activePromptFrame(); + const submission = createPromptSubmissionState(undefined); + + return promptWithFallback('text', options, () => + promptUntilValid( + validationOptions, + async () => { + submission.reset(); + + const answer = await readTypedValue(options.message, { + default: options.default, + hint: options.hint, + placeholder: options.placeholder, + }); + + const value = answer.value === '' && options.default !== undefined ? options.default : answer.value; + + activeFrame.set(answer.frame); + submission.capture(!answer.cancelled, answer.cancelled, undefined); + + return options.transform ? options.transform(value) : value; + }, + (value) => { + activeFrame.clear(); + submission.render(() => { + renderSubmittedTypedValue(options.message, value); + }); + }, + (value) => { + options.default = value; + activeFrame.clear(); + submission.reset(); + }, + ), + ); +}; diff --git a/packages/tui/src/prompts/textarea.ts b/packages/tui/src/prompts/textarea.ts new file mode 100644 index 00000000..7b91271a --- /dev/null +++ b/packages/tui/src/prompts/textarea.ts @@ -0,0 +1,40 @@ +import { normalizeTextareaPromptOptions } from '#tui/prompts/textarea/options'; +import { runTextareaPrompt } from '#tui/prompts/textarea/run'; +import type { TextareaPromptOptions } from '#tui/types'; + +export function textarea(options: TextareaPromptOptions): Promise; + +export function textarea( + label: string, + placeholder?: string, + defaultValue?: string, + required?: boolean | string, + validate?: TextareaPromptOptions['validate'], + hint?: string, + rows?: number, + transform?: TextareaPromptOptions['transform'], +): Promise; + +export async function textarea( + message: string | TextareaPromptOptions, + placeholder = '', + defaultValue = '', + required: boolean | string = false, + validate: TextareaPromptOptions['validate'] = undefined, + hint = '', + rows = 5, + transform: TextareaPromptOptions['transform'] = undefined, +): Promise { + const options = normalizeTextareaPromptOptions({ + defaultValue, + hint, + message, + placeholder, + required, + rows, + transform, + validate, + }); + + return runTextareaPrompt(options, rows); +} diff --git a/packages/tui/src/prompts/textarea/options.ts b/packages/tui/src/prompts/textarea/options.ts new file mode 100644 index 00000000..103b1985 --- /dev/null +++ b/packages/tui/src/prompts/textarea/options.ts @@ -0,0 +1,35 @@ +import { isTextPromptLabel } from '#tui/concerns/validators/text-options'; +import type { TextareaPromptOptions } from '#tui/types'; + +export type TextareaPromptArgumentOptions = { + defaultValue: string; + hint: string; + message: string | TextareaPromptOptions; + placeholder: string; + required: boolean | string; + rows: number; + transform?: TextareaPromptOptions['transform']; + validate?: TextareaPromptOptions['validate']; +}; + +export const normalizeTextareaPromptOptions = (options: TextareaPromptArgumentOptions): TextareaPromptOptions => { + if (!isTextPromptLabel(options.message)) { + return { + ...options.message, + default: options.message.default ?? '', + rows: options.message.rows ?? options.rows, + }; + } + + return { + default: options.defaultValue, + hint: options.hint, + label: options.message, + message: options.message, + placeholder: options.placeholder, + required: options.required, + rows: options.rows, + transform: options.transform, + validate: options.validate, + }; +}; diff --git a/packages/tui/src/prompts/textarea/run.ts b/packages/tui/src/prompts/textarea/run.ts new file mode 100644 index 00000000..1fd0fbf1 --- /dev/null +++ b/packages/tui/src/prompts/textarea/run.ts @@ -0,0 +1,52 @@ +import { promptUntilValid, promptWithFallback } from '#tui/prompt'; +import { activePromptFrame } from '#tui/prompt/active-frame'; +import { createPromptSubmissionState } from '#tui/prompt/submission'; +import { transformedTextDefault } from '#tui/prompts/text-default'; +import { readTypedValue } from '#tui/typed-value'; +import { renderSubmittedTextareaFrame } from '#tui/typed-value/textarea-frame'; +import type { TextareaPromptOptions } from '#tui/types'; + +export const runTextareaPrompt = async (options: TextareaPromptOptions, fallbackRows: number): Promise => { + const validationOptions: TextareaPromptOptions = { + ...options, + default: await transformedTextDefault(options), + }; + + const activeFrame = activePromptFrame(); + const submission = createPromptSubmissionState(undefined); + + return promptWithFallback('textarea', options, () => + promptUntilValid( + validationOptions, + async () => { + submission.reset(); + + const answer = await readTypedValue(options.message, { + allowNewLine: true, + default: options.default, + hint: options.hint, + placeholder: options.placeholder, + rows: options.rows ?? fallbackRows, + }); + + const value = answer.value === '' && options.default !== undefined ? options.default : answer.value; + + activeFrame.set(answer.frame); + submission.capture(!answer.cancelled, answer.cancelled, undefined); + + return options.transform ? options.transform(value) : value; + }, + (value) => { + activeFrame.clear(); + submission.render(() => { + renderSubmittedTextareaFrame(options.message, value); + }); + }, + (value) => { + options.default = value; + activeFrame.clear(); + submission.reset(); + }, + ), + ); +}; diff --git a/packages/tui/src/status.ts b/packages/tui/src/status.ts new file mode 100644 index 00000000..6defe50c --- /dev/null +++ b/packages/tui/src/status.ts @@ -0,0 +1,6 @@ +export { progress, Progress } from '#tui/status/progress'; +export type { ProgressSignalTarget } from '#tui/status/progress'; +export { spin } from '#tui/status/spinner'; +export { Logger, task } from '#tui/status/task'; +export type { TaskDefinition } from '#tui/status/task'; +export { Stream, stream } from '#tui/status/stream'; diff --git a/packages/tui/src/status/frame.ts b/packages/tui/src/status/frame.ts new file mode 100644 index 00000000..8949913e --- /dev/null +++ b/packages/tui/src/status/frame.ts @@ -0,0 +1,15 @@ +import { erasePreviousLines } from '#tui/terminal'; + +export const renderedFrameLineCount = (frame: string): number => { + if (frame.length === 0) { + return 0; + } + + const lines = frame.split('\n').length; + + return frame.endsWith('\n') ? lines - 1 : lines; +}; + +export const eraseRenderedFrame = (frame: string): void => { + erasePreviousLines(renderedFrameLineCount(frame)); +}; diff --git a/packages/tui/src/status/progress.ts b/packages/tui/src/status/progress.ts new file mode 100644 index 00000000..34c69c71 --- /dev/null +++ b/packages/tui/src/status/progress.ts @@ -0,0 +1,35 @@ +import { Progress } from '#tui/status/progress/progress'; +import { runProgressSteps } from '#tui/status/progress/run'; +import { progressValues } from '#tui/status/progress/steps'; +import type { MaybePromise } from '#tui/types'; + +import { isProgressTotalArgument, progressMessageArgument, progressStepsArgument } from '#tui/status/progress/validators/arguments'; + +export { Progress }; +export type { ProgressSignalTarget } from '#tui/status/progress/progress'; + +export function progress(total: number, message?: string): Progress; + +export function progress(label: string, steps: Iterable | number): Progress; + +export function progress(label: string, steps: Iterable | number, callback: (step: T | number, progress: Progress) => MaybePromise, hint?: string): Promise; + +export function progress( + labelOrTotal: string | number, + stepsOrMessage?: Iterable | number | string, + callback?: (step: T | number, progress: Progress) => MaybePromise, + hint = '', +): Progress | Promise { + if (isProgressTotalArgument(labelOrTotal)) { + return new Progress(labelOrTotal, progressMessageArgument(stepsOrMessage)); + } + + const values = progressValues(progressStepsArgument(stepsOrMessage)); + const bar = new Progress(values.length, labelOrTotal, hint); + + if (!callback) { + return bar; + } + + return runProgressSteps(bar, values, callback); +} diff --git a/packages/tui/src/status/progress/controller.ts b/packages/tui/src/status/progress/controller.ts new file mode 100644 index 00000000..42686c51 --- /dev/null +++ b/packages/tui/src/status/progress/controller.ts @@ -0,0 +1,42 @@ +import { ProgressRenderer } from '#tui/status/progress/renderer'; +import { ProgressState } from '#tui/status/progress/state'; +import { ProgressTerminalLifecycle } from '#tui/status/progress/terminal'; +import type { ProgressSignalTarget } from '#tui/status/progress/terminal'; + +export class ProgressController { + readonly renderer: ProgressRenderer; + readonly state: ProgressState; + readonly terminal: ProgressTerminalLifecycle; + + constructor(total: number, message: string, hint: string, signalTarget: ProgressSignalTarget, handleSignal: () => void) { + this.state = new ProgressState(total, message, hint); + this.terminal = new ProgressTerminalLifecycle(signalTarget, handleSignal); + this.renderer = new ProgressRenderer(this.terminal); + } + + activate(): void { + this.state.activate(); + this.render(); + } + + advance(step: number): void { + this.state.advance(step); + this.render(); + } + + fail(): void { + this.state.fail(); + this.render(); + this.terminal.restore(); + } + + finish(): void { + this.state.finish(); + this.render(); + this.terminal.restore(); + } + + render(): void { + this.renderer.render(this.state.snapshot()); + } +} diff --git a/packages/tui/src/status/progress/labels.ts b/packages/tui/src/status/progress/labels.ts new file mode 100644 index 00000000..bd1b84a8 --- /dev/null +++ b/packages/tui/src/status/progress/labels.ts @@ -0,0 +1,9 @@ +import type { ProgressController } from '#tui/status/progress/controller'; + +export const setProgressLabel = (controller: ProgressController, value: string): void => { + controller.state.label(value); +}; + +export const setProgressHint = (controller: ProgressController, value: string): void => { + controller.state.hint(value); +}; diff --git a/packages/tui/src/status/progress/progress.ts b/packages/tui/src/status/progress/progress.ts new file mode 100644 index 00000000..372c1cbe --- /dev/null +++ b/packages/tui/src/status/progress/progress.ts @@ -0,0 +1,68 @@ +import { ProgressController } from '#tui/status/progress/controller'; +import { setProgressHint, setProgressLabel } from '#tui/status/progress/labels'; +import { progressPromptError } from '#tui/status/progress/prompt'; +import { progressCurrent, progressPercentageValue, progressValue } from '#tui/status/progress/readback'; +import type { ProgressSignalTarget } from '#tui/status/progress/terminal'; + +export type { ProgressSignalTarget } from '#tui/status/progress/terminal'; + +export class Progress { + readonly #controller: ProgressController; + readonly total: number; + #handleSignal = (): void => { + this.fail(); + }; + + constructor(total: number, message = 'Progress', hint = '', signalTarget: ProgressSignalTarget = process) { + this.#controller = new ProgressController(total, message, hint, signalTarget, this.#handleSignal); + this.total = this.#controller.state.total; + } + + start(): void { + this.#controller.activate(); + } + + advance(step = 1): void { + this.#controller.advance(step); + } + + finish(): void { + this.#controller.finish(); + } + + fail(): void { + this.#controller.fail(); + } + + label(value: string): this { + setProgressLabel(this.#controller, value); + + return this; + } + + hint(value: string): this { + setProgressHint(this.#controller, value); + + return this; + } + + percentage(): number { + return progressPercentageValue(this.#controller); + } + + current(): number { + return progressCurrent(this.#controller); + } + + value(): boolean { + return progressValue(); + } + + prompt(): never { + throw progressPromptError(); + } + + render(): void { + this.#controller.render(); + } +} diff --git a/packages/tui/src/status/progress/prompt.ts b/packages/tui/src/status/progress/prompt.ts new file mode 100644 index 00000000..95f3a5e4 --- /dev/null +++ b/packages/tui/src/status/progress/prompt.ts @@ -0,0 +1 @@ +export const progressPromptError = (): Error => new Error('Progress Bar cannot be prompted.'); diff --git a/packages/tui/src/status/progress/readback.ts b/packages/tui/src/status/progress/readback.ts new file mode 100644 index 00000000..a658855f --- /dev/null +++ b/packages/tui/src/status/progress/readback.ts @@ -0,0 +1,11 @@ +import type { ProgressController } from '#tui/status/progress/controller'; + +export const progressCurrent = (controller: ProgressController): number => { + return controller.state.current(); +}; + +export const progressPercentageValue = (controller: ProgressController): number => { + return controller.state.percentage(); +}; + +export const progressValue = (): boolean => true; diff --git a/packages/tui/src/status/progress/render.ts b/packages/tui/src/status/progress/render.ts new file mode 100644 index 00000000..f6bfe93b --- /dev/null +++ b/packages/tui/src/status/progress/render.ts @@ -0,0 +1,37 @@ +import { renderBox } from '#tui/theme/box'; +import { clampedProgressCurrent, progressRatio } from '#tui/status/progress/state/value'; +import { progressFrameWidth } from '#tui/status/progress/validators/frame'; + +export type ProgressFrameState = 'active' | 'cancel' | 'error' | 'submit'; + +export type ProgressFrameOptions = { + current: number; + hint: string; + label: string; + state?: ProgressFrameState; + total: number; + width?: number; +}; + +export const progressPercentage = (current: number, total: number): number => { + return progressRatio(current, total); +}; + +const formatProgressNumber = (value: number): string => new Intl.NumberFormat('en-US').format(value); + +export const formatProgressFraction = (current: number, total: number): string => { + return `${formatProgressNumber(clampedProgressCurrent(current, total))} / ${formatProgressNumber(total)}`; +}; + +export const renderProgressFrame = ({ current, hint, label, state = 'active', total, width = 60 }: ProgressFrameOptions): string => { + const fillWidth = progressFrameWidth(width); + const filled = Math.ceil(fillWidth * progressPercentage(current, total)); + const body = '█'.repeat(filled); + const lines = [renderBox({ body, info: formatProgressFraction(current, total), title: label, width: fillWidth })]; + + if (state === 'active') { + lines.push(hint ? ` ${hint}` : ''); + } + + return `${lines.join('\n')}\n`; +}; diff --git a/packages/tui/src/status/progress/renderer.ts b/packages/tui/src/status/progress/renderer.ts new file mode 100644 index 00000000..ea413cde --- /dev/null +++ b/packages/tui/src/status/progress/renderer.ts @@ -0,0 +1,16 @@ +import { promptEnvironment } from '#tui/environment'; +import { renderProgressFrame } from '#tui/status/progress/render'; +import type { ProgressStateSnapshot } from '#tui/status/progress/state'; +import type { ProgressTerminalLifecycle } from '#tui/status/progress/terminal'; + +export class ProgressRenderer { + constructor(private readonly terminal: ProgressTerminalLifecycle) {} + + render(snapshot: ProgressStateSnapshot): void { + const frame = renderProgressFrame(snapshot); + + this.terminal.beginRender(); + promptEnvironment().output.write(frame); + this.terminal.commitFrame(frame); + } +} diff --git a/packages/tui/src/status/progress/run.ts b/packages/tui/src/status/progress/run.ts new file mode 100644 index 00000000..569599d5 --- /dev/null +++ b/packages/tui/src/status/progress/run.ts @@ -0,0 +1,34 @@ +import type { MaybePromise } from '#tui/types'; + +type ProgressRunner = { + advance(): void; + fail(): void; + finish(): void; + start(): void; +}; + +export const runProgressSteps = async ( + progress: TProgress, + values: Array, + callback: (step: T | number, progress: TProgress) => MaybePromise, +): Promise => { + const results: R[] = []; + + progress.start(); + + try { + for (const value of values) { + results.push(await callback(value, progress)); + + progress.advance(); + } + } catch (error) { + progress.fail(); + + throw error; + } + + progress.finish(); + + return results; +}; diff --git a/packages/tui/src/status/progress/state.ts b/packages/tui/src/status/progress/state.ts new file mode 100644 index 00000000..6fd84ea9 --- /dev/null +++ b/packages/tui/src/status/progress/state.ts @@ -0,0 +1,59 @@ +import { progressStateSnapshot } from '#tui/status/progress/state/snapshot'; +import { clampedProgressCurrent, nextProgressCurrent, progressRatio } from '#tui/status/progress/state/value'; +import { parseProgressTotal } from '#tui/status/validators/progress'; +import type { ProgressFrameState } from '#tui/status/progress/render'; +import type { ProgressStateSnapshot } from '#tui/status/progress/state/snapshot'; + +export type { ProgressStateSnapshot } from '#tui/status/progress/state/snapshot'; + +export class ProgressState { + #current = 0; + #hint: string; + #label: string; + #state: ProgressFrameState = 'active'; + readonly total: number; + + constructor(total: number, label: string, hint: string) { + this.total = parseProgressTotal(total); + this.#label = label; + this.#hint = hint; + } + + activate(): void { + this.#state = 'active'; + } + + advance(step: number): void { + this.#state = 'active'; + this.#current = nextProgressCurrent(this.#current, this.total, step); + } + + fail(): void { + this.#state = 'error'; + } + + finish(): void { + this.#current = this.total; + this.#state = 'submit'; + } + + hint(value: string): void { + this.#hint = value; + } + + label(value: string): void { + this.#label = value; + } + + current(): number { + return clampedProgressCurrent(this.#current, this.total); + } + + percentage(): number { + return progressRatio(this.#current, this.total); + } + + snapshot(): ProgressStateSnapshot { + return progressStateSnapshot(this.#current, this.total, this.#label, this.#hint, this.#state); + } +} diff --git a/packages/tui/src/status/progress/state/snapshot.ts b/packages/tui/src/status/progress/state/snapshot.ts new file mode 100644 index 00000000..9f7fb04e --- /dev/null +++ b/packages/tui/src/status/progress/state/snapshot.ts @@ -0,0 +1,17 @@ +import type { ProgressFrameState } from '#tui/status/progress/render'; + +export type ProgressStateSnapshot = { + current: number; + hint: string; + label: string; + state: ProgressFrameState; + total: number; +}; + +export const progressStateSnapshot = (current: number, total: number, label: string, hint: string, state: ProgressFrameState): ProgressStateSnapshot => ({ + current, + hint, + label, + state, + total, +}); diff --git a/packages/tui/src/status/progress/state/value.ts b/packages/tui/src/status/progress/state/value.ts new file mode 100644 index 00000000..6f80cf9d --- /dev/null +++ b/packages/tui/src/status/progress/state/value.ts @@ -0,0 +1,17 @@ +import { parseProgressStep } from '#tui/status/validators/progress'; + +export const nextProgressCurrent = (current: number, total: number, step: number): number => { + return Math.max(0, Math.min(current + parseProgressStep(step), total)); +}; + +export const clampedProgressCurrent = (current: number, total: number): number => { + return Math.max(0, Math.min(current, total)); +}; + +export const progressRatio = (current: number, total: number): number => { + if (total <= 0) { + return 0; + } + + return clampedProgressCurrent(current, total) / total; +}; diff --git a/packages/tui/src/status/progress/steps.ts b/packages/tui/src/status/progress/steps.ts new file mode 100644 index 00000000..d606dc5c --- /dev/null +++ b/packages/tui/src/status/progress/steps.ts @@ -0,0 +1,7 @@ +import { parseProgressValuesInput } from '#tui/status/validators/progress'; + +export const progressValues = (steps: Iterable | number): Array => { + const parsed = parseProgressValuesInput(steps); + + return parsed.kind === 'total' ? Array.from({ length: parsed.total }, (_, index) => index) : Array.from(parsed.steps); +}; diff --git a/packages/tui/src/status/progress/terminal.ts b/packages/tui/src/status/progress/terminal.ts new file mode 100644 index 00000000..f1837e6d --- /dev/null +++ b/packages/tui/src/status/progress/terminal.ts @@ -0,0 +1,55 @@ +import { eraseRenderedFrame } from '#tui/status/frame'; +import { ProgressSignalHandlers } from '#tui/status/progress/terminal/signals'; +import { hideCursor, showCursor } from '#tui/terminal'; +import type { ProgressSignalTarget } from '#tui/status/progress/terminal/signals'; + +export type { ProgressSignalTarget } from '#tui/status/progress/terminal/signals'; + +export class ProgressTerminalLifecycle { + #cursorHidden = false; + #renderedFrame: string | null = null; + readonly #signals: ProgressSignalHandlers; + + constructor(signalTarget: ProgressSignalTarget, handleSignal: () => void) { + this.#signals = new ProgressSignalHandlers(signalTarget, handleSignal); + } + + beginRender(): void { + if (!this.#cursorHidden) { + try { + hideCursor(); + this.#signals.attach(); + this.#cursorHidden = true; + } catch (error) { + showCursor(); + + throw error; + } + } + + if (this.#renderedFrame) { + eraseRenderedFrame(this.#renderedFrame); + this.#renderedFrame = null; + } + } + + commitFrame(frame: string): void { + this.#renderedFrame = frame; + } + + restore(): void { + try { + if (this.#renderedFrame) { + eraseRenderedFrame(this.#renderedFrame); + this.#renderedFrame = null; + } + } finally { + if (this.#cursorHidden) { + showCursor(); + this.#cursorHidden = false; + } + + this.#signals.detach(); + } + } +} diff --git a/packages/tui/src/status/progress/terminal/signals.ts b/packages/tui/src/status/progress/terminal/signals.ts new file mode 100644 index 00000000..fbcfad72 --- /dev/null +++ b/packages/tui/src/status/progress/terminal/signals.ts @@ -0,0 +1,41 @@ +import { parseStatusSignalTarget } from '#tui/status/validators/signal-target'; +import type { StatusSignalTarget } from '#tui/status/validators/signal-target'; + +const progressSignals = ['SIGINT', 'SIGTERM'] as const; + +export type ProgressSignalTarget = StatusSignalTarget; + +export class ProgressSignalHandlers { + #attached = false; + readonly #handleSignal: () => void; + readonly #target: ProgressSignalTarget; + + constructor(target: ProgressSignalTarget, handleSignal: () => void) { + this.#target = parseStatusSignalTarget(target); + this.#handleSignal = handleSignal; + } + + attach(): void { + if (this.#attached) { + return; + } + + for (const signal of progressSignals) { + this.#target.on(signal, this.#handleSignal); + } + + this.#attached = true; + } + + detach(): void { + if (!this.#attached) { + return; + } + + for (const signal of progressSignals) { + this.#target.off(signal, this.#handleSignal); + } + + this.#attached = false; + } +} diff --git a/packages/tui/src/status/progress/validators/arguments.ts b/packages/tui/src/status/progress/validators/arguments.ts new file mode 100644 index 00000000..2cf51bbf --- /dev/null +++ b/packages/tui/src/status/progress/validators/arguments.ts @@ -0,0 +1,28 @@ +import { z } from 'zod'; +import { iterableSchema } from '#tui/validators/iterable'; + +const progressTotalArgumentSchema = z.union([z.number(), z.literal(Number.POSITIVE_INFINITY), z.literal(Number.NEGATIVE_INFINITY)]); +const progressMessageArgumentSchema = z.string(); + +const progressStepsArgumentSchema = () => z.union([progressTotalArgumentSchema, iterableSchema()]); + +export const isProgressTotalArgument = (value: unknown): value is number => { + return progressTotalArgumentSchema.safeParse(value).success; +}; + +export const progressMessageArgument = (value: unknown): string | undefined => { + const parsed = progressMessageArgumentSchema.safeParse(value); + + return parsed.success ? parsed.data : undefined; +}; + +export const progressStepsArgument = (value: Iterable | number | string | undefined): Iterable | number => { + const steps = value ?? 0; + const parsed = progressStepsArgumentSchema().safeParse(steps); + + if (!parsed.success || progressMessageArgumentSchema.safeParse(steps).success) { + throw new Error('Progress steps must be an iterable or a number.'); + } + + return parsed.data; +}; diff --git a/packages/tui/src/status/progress/validators/frame.ts b/packages/tui/src/status/progress/validators/frame.ts new file mode 100644 index 00000000..0c5a0a10 --- /dev/null +++ b/packages/tui/src/status/progress/validators/frame.ts @@ -0,0 +1,13 @@ +import { z } from 'zod'; + +const progressFrameWidthSchema = z.number().finite(); + +export const progressFrameWidth = (width: unknown): number => { + const parsed = progressFrameWidthSchema.safeParse(width); + + if (!parsed.success) { + return 0; + } + + return Math.max(0, Math.trunc(parsed.data)); +}; diff --git a/packages/tui/src/status/signals.ts b/packages/tui/src/status/signals.ts new file mode 100644 index 00000000..1190cce3 --- /dev/null +++ b/packages/tui/src/status/signals.ts @@ -0,0 +1,57 @@ +import { parseStatusSignalTarget } from '#tui/status/validators/signal-target'; +import type { StatusSignalTarget } from '#tui/status/validators/signal-target'; + +const statusSignals = ['SIGINT', 'SIGTERM'] as const; + +export type { StatusSignalTarget }; + +export class StatusSignalCleanup { + #attached = false; + #restored = false; + readonly #restore: () => void; + readonly #target: StatusSignalTarget; + readonly #handleSignal = (): void => { + this.restore(); + }; + + constructor(restore: () => void, target: StatusSignalTarget = process) { + this.#restore = restore; + this.#target = parseStatusSignalTarget(target); + } + + attach(): this { + if (this.#attached) { + return this; + } + + for (const signal of statusSignals) { + this.#target.on(signal, this.#handleSignal); + } + + this.#attached = true; + + return this; + } + + restore(): void { + if (this.#restored) { + return; + } + + this.#restored = true; + this.detach(); + this.#restore(); + } + + detach(): void { + if (!this.#attached) { + return; + } + + for (const signal of statusSignals) { + this.#target.off(signal, this.#handleSignal); + } + + this.#attached = false; + } +} diff --git a/packages/tui/src/status/spinner.ts b/packages/tui/src/status/spinner.ts new file mode 100644 index 00000000..d0734fdc --- /dev/null +++ b/packages/tui/src/status/spinner.ts @@ -0,0 +1,13 @@ +import { resolveSpinnerOptions } from '#tui/status/spinner/options'; +import { runSpinnerLifecycle } from '#tui/status/spinner/lifecycle'; +import type { MaybePromise, StatusOptions } from '#tui/types'; + +export function spin(callback: () => MaybePromise, options?: StatusOptions): Promise; + +export function spin(message: string, callback: () => MaybePromise): Promise; + +export async function spin(callbackOrMessage: (() => MaybePromise) | string, optionsOrCallback: StatusOptions | (() => MaybePromise) = { message: '' }): Promise { + const options = resolveSpinnerOptions(callbackOrMessage, optionsOrCallback); + + return runSpinnerLifecycle(options.message, options.callback); +} diff --git a/packages/tui/src/status/spinner/lifecycle.ts b/packages/tui/src/status/spinner/lifecycle.ts new file mode 100644 index 00000000..fcda53b2 --- /dev/null +++ b/packages/tui/src/status/spinner/lifecycle.ts @@ -0,0 +1,40 @@ +import { promptEnvironment } from '#tui/environment'; +import { eraseRenderedFrame } from '#tui/status/frame'; +import { StatusSignalCleanup } from '#tui/status/signals'; +import { renderSpinnerFrame } from '#tui/status/spinner/render'; +import { hideCursor, showCursor } from '#tui/terminal'; +import type { MaybePromise } from '#tui/types'; + +export const runSpinnerLifecycle = async (message: string, callback: () => MaybePromise): Promise => { + const frame = renderSpinnerFrame(message); + + let rendered = false; + + const cleanup = new StatusSignalCleanup(() => { + try { + if (rendered) { + eraseRenderedFrame(frame); + } + } finally { + showCursor(); + } + }); + + try { + hideCursor(); + cleanup.attach(); + promptEnvironment().output.write(frame); + rendered = true; + } catch (error) { + cleanup.detach(); + showCursor(); + + throw error; + } + + try { + return await callback(); + } finally { + cleanup.restore(); + } +}; diff --git a/packages/tui/src/status/spinner/options.ts b/packages/tui/src/status/spinner/options.ts new file mode 100644 index 00000000..be129f74 --- /dev/null +++ b/packages/tui/src/status/spinner/options.ts @@ -0,0 +1,21 @@ +import { isSpinnerCallback, isSpinnerMessage, parseSpinnerCallback, parseSpinnerOptions } from '#tui/status/spinner/validators/options'; +import type { MaybePromise, StatusOptions } from '#tui/types'; + +export type ResolvedSpinnerOptions = { + callback: () => MaybePromise; + message: string; +}; + +export const resolveSpinnerOptions = (callbackOrMessage: (() => MaybePromise) | string, optionsOrCallback: StatusOptions | (() => MaybePromise)): ResolvedSpinnerOptions => { + if (isSpinnerMessage(callbackOrMessage)) { + return { + callback: parseSpinnerCallback(optionsOrCallback), + message: callbackOrMessage, + }; + } + + return { + callback: parseSpinnerCallback(callbackOrMessage), + message: isSpinnerCallback(optionsOrCallback) ? '' : parseSpinnerOptions(optionsOrCallback).message, + }; +}; diff --git a/packages/tui/src/status/spinner/render.ts b/packages/tui/src/status/spinner/render.ts new file mode 100644 index 00000000..362f613d --- /dev/null +++ b/packages/tui/src/status/spinner/render.ts @@ -0,0 +1,15 @@ +export const SPINNER_FRAMES = ['⠂', '⠒', '⠐', '⠰', '⠠', '⠤', '⠄', '⠆'] as const; + +export const STATIC_SPINNER_FRAME = '⠶'; + +export const spinnerFrame = (count: number): string => { + const index = ((count % SPINNER_FRAMES.length) + SPINNER_FRAMES.length) % SPINNER_FRAMES.length; + + return SPINNER_FRAMES[index] ?? STATIC_SPINNER_FRAME; +}; + +export const renderSpinnerFrame = (message: string, count?: number): string => { + const frame = count === undefined ? STATIC_SPINNER_FRAME : spinnerFrame(count); + + return ` ${frame} ${message}\n`; +}; diff --git a/packages/tui/src/status/spinner/validators/options.ts b/packages/tui/src/status/spinner/validators/options.ts new file mode 100644 index 00000000..2799c173 --- /dev/null +++ b/packages/tui/src/status/spinner/validators/options.ts @@ -0,0 +1,43 @@ +import { z } from 'zod'; +import { functionSchema } from '#tui/validators/function'; +import type { MaybePromise } from '#tui/types'; + +const spinnerCallbackSchema = (): z.ZodType<() => MaybePromise> => functionSchema<() => MaybePromise>(); +const spinnerMessageSchema = z.string(); + +const spinnerOptionsSchema = z + .object({ + message: spinnerMessageSchema.default(''), + }) + .passthrough() + .default({ message: '' }); + +export type ResolvedStatusOptions = z.output; + +export const isSpinnerCallback = (value: unknown): value is () => MaybePromise => { + return spinnerCallbackSchema().safeParse(value).success; +}; + +export const isSpinnerMessage = (value: unknown): value is string => { + return spinnerMessageSchema.safeParse(value).success; +}; + +export const parseSpinnerCallback = (value: unknown): (() => MaybePromise) => { + const parsed = spinnerCallbackSchema().safeParse(value); + + if (!parsed.success) { + throw new Error('A spinner callback is required.'); + } + + return parsed.data; +}; + +export const parseSpinnerOptions = (value: unknown): ResolvedStatusOptions => { + const parsed = spinnerOptionsSchema.safeParse(value); + + if (!parsed.success) { + throw new Error('Spinner options must include a string message.'); + } + + return parsed.data; +}; diff --git a/packages/tui/src/status/stream.ts b/packages/tui/src/status/stream.ts new file mode 100644 index 00000000..a9d14adb --- /dev/null +++ b/packages/tui/src/status/stream.ts @@ -0,0 +1,13 @@ +import { Stream } from '#tui/status/stream/output'; + +export { Stream } from '#tui/status/stream/output'; + +export function stream(): Stream; + +export function stream(source: AsyncIterable | Iterable): Promise; + +export function stream(source?: AsyncIterable | Iterable): Stream | Promise { + const output = new Stream(); + + return source === undefined ? output : output.pipe(source); +} diff --git a/packages/tui/src/status/stream/buffer.ts b/packages/tui/src/status/stream/buffer.ts new file mode 100644 index 00000000..00333915 --- /dev/null +++ b/packages/tui/src/status/stream/buffer.ts @@ -0,0 +1,38 @@ +import { parseStreamFadeSteps } from '#tui/status/stream/validators/fade'; + +export class StreamBuffer { + readonly fading: string[] = []; + readonly #fadeLimit: number; + #message = ''; + + constructor(fadeLimit: number) { + this.#fadeLimit = parseStreamFadeSteps(fadeLimit, 10); + } + + append(message: string): void { + this.fading.push(message); + + while (this.fading.length > this.#fadeLimit) { + this.#message += this.fading.shift() ?? ''; + } + } + + flush(): boolean { + if (this.fading.length === 0) { + return false; + } + + this.#message += this.fading.join(''); + this.fading.length = 0; + + return true; + } + + value(): string { + return `${this.#message}${this.fading.join('')}`; + } + + stableValue(): string { + return this.#message; + } +} diff --git a/packages/tui/src/status/stream/fade.ts b/packages/tui/src/status/stream/fade.ts new file mode 100644 index 00000000..c97ba7dd --- /dev/null +++ b/packages/tui/src/status/stream/fade.ts @@ -0,0 +1,39 @@ +import { backgroundColor, foregroundColor, supportsTrueColor } from '#tui/terminal'; +import { dim, foregroundRgb } from '#tui/theme/styles'; +import { parseStreamFadeColor, parseStreamFadeSteps, parseStreamFadeTrueColor } from '#tui/status/stream/validators/fade'; +import type { TerminalColor } from '#tui/terminal/capabilities'; + +export type StreamFadeOptions = { + background?: TerminalColor; + foreground?: TerminalColor; + steps?: number; + trueColor?: boolean; +}; + +export type StreamFadeStyle = (value: string) => string; + +const interpolate = (foreground: TerminalColor, background: TerminalColor, factor: number): TerminalColor => { + const red = Math.trunc(background[0] + (foreground[0] - background[0]) * factor); + const green = Math.trunc(background[1] + (foreground[1] - background[1]) * factor); + const blue = Math.trunc(background[2] + (foreground[2] - background[2]) * factor); + + return [red, green, blue]; +}; + +export const streamFadeStyles = (options: StreamFadeOptions = {}): StreamFadeStyle[] => { + const trueColor = parseStreamFadeTrueColor(options.trueColor, supportsTrueColor()); + + if (!trueColor) { + return [(value) => value, dim]; + } + + const steps = parseStreamFadeSteps(options.steps, 10); + const foreground = parseStreamFadeColor(options.foreground, foregroundColor()); + const background = parseStreamFadeColor(options.background, backgroundColor()); + + return Array.from({ length: steps }, (_, step) => { + const [red, green, blue] = interpolate(foreground, background, 1 - step / steps); + + return (value: string) => foregroundRgb(value, red, green, blue); + }); +}; diff --git a/packages/tui/src/status/stream/lifecycle.ts b/packages/tui/src/status/stream/lifecycle.ts new file mode 100644 index 00000000..76e1f324 --- /dev/null +++ b/packages/tui/src/status/stream/lifecycle.ts @@ -0,0 +1,42 @@ +import { StatusSignalCleanup } from '#tui/status/signals'; +import { hideCursor, showCursor } from '#tui/terminal'; + +export class StreamLifecycle { + #closed = false; + readonly #flush: () => void; + readonly #signalCleanup: StatusSignalCleanup; + + constructor(flush: () => void) { + this.#flush = flush; + this.#signalCleanup = new StatusSignalCleanup(() => { + this.close(); + }); + + try { + hideCursor(); + this.#signalCleanup.attach(); + } catch (error) { + showCursor(); + + throw error; + } + } + + close(): void { + if (this.#closed) { + return; + } + + try { + this.#flush(); + } finally { + this.#closed = true; + this.#signalCleanup.detach(); + showCursor(); + } + } + + closed(): boolean { + return this.#closed; + } +} diff --git a/packages/tui/src/status/stream/output.ts b/packages/tui/src/status/stream/output.ts new file mode 100644 index 00000000..bf60b126 --- /dev/null +++ b/packages/tui/src/status/stream/output.ts @@ -0,0 +1,78 @@ +import { createStreamOutputContext } from '#tui/status/stream/output/context'; +import { streamClosedError, streamPromptError } from '#tui/status/stream/output/errors'; +import { pipeStreamSource } from '#tui/status/stream/output/pipe'; +import { streamBufferLines, streamBufferValue } from '#tui/status/stream/output/readback'; +import { flushStreamBuffer, renderStreamBuffer, requestStreamBufferRender } from '#tui/status/stream/output/rendering'; +import { parseStreamChunk } from '#tui/status/stream/validators/chunk'; +import type { StreamOutputContext } from '#tui/status/stream/output/context'; + +export class Stream { + readonly #context: StreamOutputContext = createStreamOutputContext(() => { + this.flush(); + }); + + write(content: string): this { + return this.append(content); + } + + append(content: string): this { + this.appendChunk(content, false); + + return this; + } + + close(): void { + this.#context.lifecycle.close(); + } + + closed(): boolean { + return this.#context.lifecycle.closed(); + } + + lines(): string[] { + return streamBufferLines(this.#context.buffer); + } + + async pipe(source: AsyncIterable | Iterable): Promise { + return pipeStreamSource( + source, + (chunk) => { + this.appendChunk(chunk, true); + }, + () => { + this.close(); + }, + ); + } + + prompt(): never { + throw streamPromptError(); + } + + value(): string { + return streamBufferValue(this.#context.buffer); + } + + private appendChunk(content: string, deferred: boolean): void { + if (this.#context.lifecycle.closed()) { + throw streamClosedError(); + } + + this.#context.buffer.append(parseStreamChunk(content)); + this.render(deferred); + } + + private render(deferred: boolean): void { + if (deferred) { + requestStreamBufferRender(this.#context); + + return; + } + + renderStreamBuffer(this.#context); + } + + private flush(): void { + flushStreamBuffer(this.#context); + } +} diff --git a/packages/tui/src/status/stream/output/context.ts b/packages/tui/src/status/stream/output/context.ts new file mode 100644 index 00000000..bcc4d5a5 --- /dev/null +++ b/packages/tui/src/status/stream/output/context.ts @@ -0,0 +1,18 @@ +import { StreamBuffer } from '#tui/status/stream/buffer'; +import { StreamLifecycle } from '#tui/status/stream/lifecycle'; +import { StreamRenderer } from '#tui/status/stream/renderer'; +import { StreamRenderScheduler } from '#tui/status/stream/scheduler'; + +export type StreamOutputContext = { + buffer: StreamBuffer; + lifecycle: StreamLifecycle; + renderer: StreamRenderer; + scheduler: StreamRenderScheduler; +}; + +export const createStreamOutputContext = (flush: () => void): StreamOutputContext => ({ + buffer: new StreamBuffer(10), + lifecycle: new StreamLifecycle(flush), + renderer: new StreamRenderer(), + scheduler: new StreamRenderScheduler(), +}); diff --git a/packages/tui/src/status/stream/output/errors.ts b/packages/tui/src/status/stream/output/errors.ts new file mode 100644 index 00000000..2169c1e2 --- /dev/null +++ b/packages/tui/src/status/stream/output/errors.ts @@ -0,0 +1,3 @@ +export const streamClosedError = (): Error => new Error('Stream is closed.'); + +export const streamPromptError = (): Error => new Error('Stream cannot be prompted'); diff --git a/packages/tui/src/status/stream/output/pipe.ts b/packages/tui/src/status/stream/output/pipe.ts new file mode 100644 index 00000000..17604cf1 --- /dev/null +++ b/packages/tui/src/status/stream/output/pipe.ts @@ -0,0 +1,9 @@ +export const pipeStreamSource = async (source: AsyncIterable | Iterable, write: (chunk: string) => void, close: () => void): Promise => { + try { + for await (const chunk of source) { + write(chunk); + } + } finally { + close(); + } +}; diff --git a/packages/tui/src/status/stream/output/readback.ts b/packages/tui/src/status/stream/output/readback.ts new file mode 100644 index 00000000..577edbea --- /dev/null +++ b/packages/tui/src/status/stream/output/readback.ts @@ -0,0 +1,10 @@ +import { streamLines } from '#tui/status/stream/render'; +import type { StreamBuffer } from '#tui/status/stream/buffer'; + +export const streamBufferValue = (buffer: StreamBuffer): string => { + return buffer.value(); +}; + +export const streamBufferLines = (buffer: StreamBuffer): string[] => { + return streamLines({ value: streamBufferValue(buffer) }); +}; diff --git a/packages/tui/src/status/stream/output/rendering.ts b/packages/tui/src/status/stream/output/rendering.ts new file mode 100644 index 00000000..643c7efb --- /dev/null +++ b/packages/tui/src/status/stream/output/rendering.ts @@ -0,0 +1,32 @@ +import type { StreamBuffer } from '#tui/status/stream/buffer'; +import type { StreamRenderer } from '#tui/status/stream/renderer'; + +type StreamRenderContext = { + buffer: StreamBuffer; + renderer: StreamRenderer; + scheduler: { + flush(render: () => void): void; + request(render: () => void): void; + }; +}; + +export const renderStreamBuffer = ({ buffer, renderer }: StreamRenderContext): void => { + renderer.render({ + fading: buffer.fading, + value: buffer.stableValue(), + }); +}; + +export const requestStreamBufferRender = (context: StreamRenderContext): void => { + context.scheduler.request(() => { + renderStreamBuffer(context); + }); +}; + +export const flushStreamBuffer = (context: StreamRenderContext): void => { + if (context.buffer.flush()) { + context.scheduler.flush(() => { + renderStreamBuffer(context); + }); + } +}; diff --git a/packages/tui/src/status/stream/render.ts b/packages/tui/src/status/stream/render.ts new file mode 100644 index 00000000..e148d080 --- /dev/null +++ b/packages/tui/src/status/stream/render.ts @@ -0,0 +1,29 @@ +import { wrap } from '#tui/strings'; +import type { StreamFadeStyle } from '#tui/status/stream/fade'; +import { parseStreamChunkLines } from '#tui/status/stream/validators/chunk'; +import { parseStreamFrameWidth } from '#tui/status/stream/validators/fade'; + +type StreamFrameOptions = { + fading?: string[]; + fadeStyles?: StreamFadeStyle[]; + value: string; + width?: number; +}; + +const fadedValue = (value: string, fading: string[], styles: StreamFadeStyle[]): string => { + return `${value}${fading.map((chunk, index) => (styles[index] ?? styles.at(-1) ?? ((text: string) => text))(chunk)).join('')}`; +}; + +export const streamLines = ({ value, width }: StreamFrameOptions): string[] => { + const frameWidth = parseStreamFrameWidth(width, 60); + + return parseStreamChunkLines(value).flatMap((line) => wrap(line, frameWidth)); +}; + +export const renderStreamFrame = (options: StreamFrameOptions): string => { + const value = fadedValue(options.value, options.fading ?? [], options.fadeStyles ?? []); + + return `${streamLines({ ...options, value }) + .map((line) => ` ${line}`) + .join('\n')}\n`; +}; diff --git a/packages/tui/src/status/stream/renderer.ts b/packages/tui/src/status/stream/renderer.ts new file mode 100644 index 00000000..4b56a00b --- /dev/null +++ b/packages/tui/src/status/stream/renderer.ts @@ -0,0 +1,39 @@ +import { promptEnvironment } from '#tui/environment'; +import { eraseRenderedFrame } from '#tui/status/frame'; +import { streamFadeStyles } from '#tui/status/stream/fade'; +import { renderStreamFrame } from '#tui/status/stream/render'; +import type { StreamFadeStyle } from '#tui/status/stream/fade'; + +type StreamRenderOptions = { + fading: string[]; + value: string; +}; + +export class StreamRenderer { + readonly #fadeStyles: StreamFadeStyle[]; + #frame = ''; + + constructor(fadeStyles = streamFadeStyles()) { + this.#fadeStyles = fadeStyles; + } + + current(): string { + return this.#frame; + } + + render(options: StreamRenderOptions): void { + if (this.#frame.length > 0) { + eraseRenderedFrame(this.#frame); + this.#frame = ''; + } + + const nextFrame = renderStreamFrame({ + fading: options.fading, + fadeStyles: this.#fadeStyles, + value: options.value, + }); + + promptEnvironment().output.write(nextFrame); + this.#frame = nextFrame; + } +} diff --git a/packages/tui/src/status/stream/scheduler.ts b/packages/tui/src/status/stream/scheduler.ts new file mode 100644 index 00000000..7f59a4af --- /dev/null +++ b/packages/tui/src/status/stream/scheduler.ts @@ -0,0 +1,30 @@ +export class StreamRenderScheduler { + #scheduled = false; + + flush(render: () => void): void { + if (!this.#scheduled) { + render(); + + return; + } + + this.#scheduled = false; + render(); + } + + request(render: () => void): void { + if (this.#scheduled) { + return; + } + + this.#scheduled = true; + queueMicrotask(() => { + if (!this.#scheduled) { + return; + } + + this.#scheduled = false; + render(); + }); + } +} diff --git a/packages/tui/src/status/stream/validators/chunk.ts b/packages/tui/src/status/stream/validators/chunk.ts new file mode 100644 index 00000000..e3144f2d --- /dev/null +++ b/packages/tui/src/status/stream/validators/chunk.ts @@ -0,0 +1,24 @@ +import { z } from 'zod'; + +const streamChunkSchema = z.string(); +const streamChunkLinesSchema = streamChunkSchema.transform((chunk) => chunk.split(/\r?\n/u)); + +export const parseStreamChunk = (chunk: unknown): string => { + const parsed = streamChunkSchema.safeParse(chunk); + + if (!parsed.success) { + throw new TypeError('Stream chunks must be strings.'); + } + + return parsed.data; +}; + +export const parseStreamChunkLines = (chunk: unknown): string[] => { + const parsed = streamChunkLinesSchema.safeParse(chunk); + + if (!parsed.success) { + throw new TypeError('Stream chunks must be strings.'); + } + + return parsed.data; +}; diff --git a/packages/tui/src/status/stream/validators/fade.ts b/packages/tui/src/status/stream/validators/fade.ts new file mode 100644 index 00000000..449d8f0d --- /dev/null +++ b/packages/tui/src/status/stream/validators/fade.ts @@ -0,0 +1,37 @@ +import { z } from 'zod'; +import { parseTerminalColor } from '#tui/terminal/validators/color'; +import type { TerminalColor } from '#tui/terminal/capabilities'; + +const streamFadeStepsSchema = z.number().finite().positive(); +const streamFrameWidthSchema = z.number().finite().positive(); +const streamFadeTrueColorSchema = z.boolean(); + +export const parseStreamFadeSteps = (steps: unknown, defaultValue: number): number => { + const parsed = streamFadeStepsSchema.safeParse(steps); + + if (!parsed.success) { + return defaultValue; + } + + return Math.max(1, Math.floor(parsed.data)); +}; + +export const parseStreamFrameWidth = (width: unknown, defaultValue: number): number => { + const parsed = streamFrameWidthSchema.safeParse(width); + + if (!parsed.success) { + return defaultValue; + } + + return Math.max(1, Math.floor(parsed.data)); +}; + +export const parseStreamFadeColor = (value: unknown, fallback: TerminalColor): TerminalColor => { + return parseTerminalColor(value, fallback); +}; + +export const parseStreamFadeTrueColor = (value: unknown, fallback: boolean): boolean => { + const parsed = streamFadeTrueColorSchema.safeParse(value); + + return parsed.success ? parsed.data : fallback; +}; diff --git a/packages/tui/src/status/task.ts b/packages/tui/src/status/task.ts new file mode 100644 index 00000000..787810c4 --- /dev/null +++ b/packages/tui/src/status/task.ts @@ -0,0 +1,16 @@ +import { resolveTaskDefinition } from '#tui/status/task/definition'; +import { runTaskLifecycle } from '#tui/status/task/lifecycle'; +import { Logger } from '#tui/status/task/logger'; +import type { TaskDefinition } from '#tui/status/task/definition'; +import type { MaybePromise } from '#tui/types'; + +export { Logger }; +export type { TaskDefinition } from '#tui/status/task/definition'; + +export async function task(definition: TaskDefinition): Promise; + +export async function task(label: string, callback: (logger: Logger) => MaybePromise, limit?: number, keepSummary?: boolean, subLabel?: string): Promise; + +export async function task(definitionOrLabel: TaskDefinition | string, callback?: (logger: Logger) => MaybePromise, limit = 10, keepSummary = false, subLabel?: string): Promise { + return runTaskLifecycle(resolveTaskDefinition(definitionOrLabel, callback, limit, keepSummary, subLabel)); +} diff --git a/packages/tui/src/status/task/definition.ts b/packages/tui/src/status/task/definition.ts new file mode 100644 index 00000000..367d1b25 --- /dev/null +++ b/packages/tui/src/status/task/definition.ts @@ -0,0 +1,47 @@ +import { isTaskTitle, parseTaskCallback, parseTaskDefinition } from '#tui/status/task/validators/definition'; +import type { Logger } from '#tui/status/task/logger'; +import type { MaybePromise } from '#tui/types'; + +export type TaskDefinition = { + keepSummary?: boolean; + subLabel?: string; + title: string; + limit?: number; + task: (logger: Logger) => MaybePromise; +}; + +export type ResolvedTaskDefinition = { + keepSummary: boolean; + limit: number; + run: (logger: Logger) => MaybePromise; + subLabel: string; + title: string; +}; + +export const resolveTaskDefinition = ( + definitionOrLabel: TaskDefinition | string, + callback?: (logger: Logger) => MaybePromise, + limit = 10, + keepSummary = false, + subLabel?: string, +): ResolvedTaskDefinition => { + if (isTaskTitle(definitionOrLabel)) { + return { + keepSummary, + limit, + run: parseTaskCallback(callback), + subLabel: subLabel ?? '', + title: definitionOrLabel, + }; + } + + const definition = parseTaskDefinition(definitionOrLabel); + + return { + keepSummary: definition.keepSummary ?? keepSummary, + limit: definition.limit ?? limit, + run: definition.task, + subLabel: definition.subLabel ?? subLabel ?? '', + title: definition.title, + }; +}; diff --git a/packages/tui/src/status/task/frame.ts b/packages/tui/src/status/task/frame.ts new file mode 100644 index 00000000..8f6b3175 --- /dev/null +++ b/packages/tui/src/status/task/frame.ts @@ -0,0 +1,22 @@ +import { renderTaskFrame } from '#tui/status/task/render'; +import type { Logger } from '#tui/status/task/logger'; + +export type LoggerTaskFrameOptions = { + finished?: boolean; + keepSummary?: boolean; +}; + +export const renderLoggerTaskFrame = (logger: Logger, options: LoggerTaskFrameOptions = {}): string => { + const finished = options.finished ?? false; + const keepSummary = options.keepSummary ?? false; + + return renderTaskFrame({ + finished, + keepSummary, + label: logger.labelValue, + limit: logger.limitValue, + lines: logger.lines, + stableMessages: finished && !keepSummary ? [] : logger.stableMessages, + subLabel: logger.subLabelValue, + }); +}; diff --git a/packages/tui/src/status/task/lifecycle.ts b/packages/tui/src/status/task/lifecycle.ts new file mode 100644 index 00000000..a641399c --- /dev/null +++ b/packages/tui/src/status/task/lifecycle.ts @@ -0,0 +1,55 @@ +import { eraseRenderedFrame } from '#tui/status/frame'; +import { StatusSignalCleanup } from '#tui/status/signals'; +import { Logger } from '#tui/status/task/logger'; +import { captureTaskProcessOutput } from '#tui/status/task/process-output'; +import { createTaskLifecycleRenderer } from '#tui/status/task/lifecycle/rendering'; +import { hideCursor, showCursor } from '#tui/terminal'; +import type { ResolvedTaskDefinition } from '#tui/status/task/definition'; +import type { TaskLifecycleRenderer } from '#tui/status/task/lifecycle/rendering'; + +export const runTaskLifecycle = async (definition: ResolvedTaskDefinition): Promise => { + let renderer: TaskLifecycleRenderer | null = null; + + const logger = new Logger(definition.limit, definition.title, definition.subLabel, () => { + renderer?.render(); + }); + + let processOutput: ReturnType; + + try { + hideCursor(); + renderer = createTaskLifecycleRenderer(logger); + renderer.render(); + processOutput = captureTaskProcessOutput(logger); + } catch (error) { + showCursor(); + + throw error; + } + + const cleanup = new StatusSignalCleanup(() => { + try { + processOutput.stop(); + eraseRenderedFrame(renderer?.current() ?? ''); + } finally { + showCursor(); + } + }).attach(); + + try { + const result = await definition.run(logger); + + processOutput.stop(); + renderer.render({ finished: true, keepSummary: definition.keepSummary }); + + return result; + } catch (error) { + processOutput.stop(); + renderer.render(); + + throw error; + } finally { + cleanup.detach(); + showCursor(); + } +}; diff --git a/packages/tui/src/status/task/lifecycle/rendering.ts b/packages/tui/src/status/task/lifecycle/rendering.ts new file mode 100644 index 00000000..8903ed3e --- /dev/null +++ b/packages/tui/src/status/task/lifecycle/rendering.ts @@ -0,0 +1,41 @@ +import { promptEnvironment } from '#tui/environment'; +import { eraseRenderedFrame } from '#tui/status/frame'; +import { renderLoggerTaskFrame } from '#tui/status/task/frame'; +import type { Logger } from '#tui/status/task/logger'; + +type TaskLifecycleRenderOptions = { + finished?: boolean; + keepSummary?: boolean; +}; + +export type TaskLifecycleRenderer = { + current(): string; + render(options?: TaskLifecycleRenderOptions): void; +}; + +export const createTaskLifecycleRenderer = (logger: Logger): TaskLifecycleRenderer => { + const output = promptEnvironment().output; + + let frame = ''; + + return { + current() { + return frame; + }, + render(options = {}) { + const finished = options.finished ?? false; + + if (frame.length > 0) { + eraseRenderedFrame(frame); + } + + const nextFrame = renderLoggerTaskFrame(logger, options); + + if (frame.length === 0 || finished) { + output.write(nextFrame); + } + + frame = nextFrame; + }, + }; +}; diff --git a/packages/tui/src/status/task/logger.ts b/packages/tui/src/status/task/logger.ts new file mode 100644 index 00000000..e8b6c3e8 --- /dev/null +++ b/packages/tui/src/status/task/logger.ts @@ -0,0 +1 @@ +export { Logger } from '#tui/status/task/logger/logger'; diff --git a/packages/tui/src/status/task/logger/events.ts b/packages/tui/src/status/task/logger/events.ts new file mode 100644 index 00000000..ba0545a1 --- /dev/null +++ b/packages/tui/src/status/task/logger/events.ts @@ -0,0 +1,5 @@ +export type TaskLoggerChangeHandler = () => void; + +export const notifyTaskLoggerChanged = (handler?: TaskLoggerChangeHandler): void => { + handler?.(); +}; diff --git a/packages/tui/src/status/task/logger/labels.ts b/packages/tui/src/status/task/logger/labels.ts new file mode 100644 index 00000000..fc7f0060 --- /dev/null +++ b/packages/tui/src/status/task/logger/labels.ts @@ -0,0 +1,19 @@ +export type TaskLoggerLabels = { + label: string; + subLabel: string; +}; + +export const createTaskLoggerLabels = (label: string, subLabel: string): TaskLoggerLabels => ({ + label, + subLabel, +}); + +export const setTaskLoggerLabel = (labels: TaskLoggerLabels, label: string): TaskLoggerLabels => ({ + ...labels, + label, +}); + +export const setTaskLoggerSubLabel = (labels: TaskLoggerLabels, subLabel: string): TaskLoggerLabels => ({ + ...labels, + subLabel, +}); diff --git a/packages/tui/src/status/task/logger/limits.ts b/packages/tui/src/status/task/logger/limits.ts new file mode 100644 index 00000000..be18f469 --- /dev/null +++ b/packages/tui/src/status/task/logger/limits.ts @@ -0,0 +1,15 @@ +import { parseLogLimit } from '#tui/status/validators/limit'; + +export type TaskLoggerLimits = { + line: number; + stable: number; +}; + +export const createTaskLoggerLimits = (limit: number): TaskLoggerLimits => { + const parsed = parseLogLimit(limit, 10); + + return { + line: parsed, + stable: parsed, + }; +}; diff --git a/packages/tui/src/status/task/logger/lines.ts b/packages/tui/src/status/task/logger/lines.ts new file mode 100644 index 00000000..a00baa6d --- /dev/null +++ b/packages/tui/src/status/task/logger/lines.ts @@ -0,0 +1,46 @@ +import { sanitizeTaskLine } from '#tui/status/task/sanitize'; +import { parseTaskLogMessageLines } from '#tui/status/task/logger/validators/message'; +import { parseLogLimit } from '#tui/status/validators/limit'; + +export type PartialTaskLogState = { + startIndex: number | null; + value: string; +}; + +const parsedTaskLogLines = (message: string): string[] => parseTaskLogMessageLines(message).map((line) => sanitizeTaskLine(line)); + +const trimTaskLogLines = (lines: string[], limit: number): number => { + const visibleLimit = parseLogLimit(limit, 10); + const removed = Math.max(0, lines.length - visibleLimit); + + if (removed > 0) { + lines.splice(0, removed); + } + + return removed; +}; + +export const appendTaskLogLines = (lines: string[], message: string, limit: number): void => { + for (const line of parsedTaskLogLines(message)) { + lines.push(line); + } + + trimTaskLogLines(lines, limit); +}; + +export const appendPartialTaskLog = (lines: string[], partial: PartialTaskLogState, chunk: string, limit: number): PartialTaskLogState => { + const value = `${partial.value}${chunk}`; + const startIndex = Math.min(partial.startIndex ?? lines.length, lines.length); + const parsed = parsedTaskLogLines(value); + + lines.splice(startIndex); + lines.push(...parsed); + + const removed = trimTaskLogLines(lines, limit); + const nextStartIndex = parsed.length === 0 || lines.length === 0 ? null : Math.max(0, startIndex - removed); + + return { + startIndex: nextStartIndex, + value, + }; +}; diff --git a/packages/tui/src/status/task/logger/logger.ts b/packages/tui/src/status/task/logger/logger.ts new file mode 100644 index 00000000..7e79518a --- /dev/null +++ b/packages/tui/src/status/task/logger/logger.ts @@ -0,0 +1,97 @@ +import { createTaskLoggerLimits } from '#tui/status/task/logger/limits'; +import { createTaskLoggerLabels } from '#tui/status/task/logger/labels'; +import { notifyTaskLoggerChanged } from '#tui/status/task/logger/events'; +import { syncTaskLoggerLabel, syncTaskLoggerSubLabel } from '#tui/status/task/logger/sync'; +import type { TaskLoggerChangeHandler } from '#tui/status/task/logger/events'; +import type { TaskLoggerLimits } from '#tui/status/task/logger/limits'; +import type { PartialTaskLogState } from '#tui/status/task/logger/lines'; +import type { TaskLoggerLabels } from '#tui/status/task/logger/labels'; +import type { StableTaskMessage } from '#tui/status/task/messages'; + +import { clearTaskLoggerPartial, writeStableTaskLoggerMessage, writeTaskLoggerLine, writeTaskLoggerPartial } from '#tui/status/task/logger/methods'; + +export class Logger { + labelValue: string; + readonly lines: string[] = []; + readonly stableMessages: StableTaskMessage[] = []; + subLabelValue: string; + #partial: PartialTaskLogState = { startIndex: null, value: '' }; + private labels: TaskLoggerLabels; + private readonly limits: TaskLoggerLimits; + + constructor( + limit: number, + label: string, + subLabel = '', + private readonly onChange?: TaskLoggerChangeHandler, + ) { + this.limits = createTaskLoggerLimits(limit); + this.labels = createTaskLoggerLabels(label, subLabel); + this.labelValue = this.labels.label; + this.subLabelValue = this.labels.subLabel; + } + + get limitValue(): number { + return this.limits.line; + } + + line(message: string): void { + writeTaskLoggerLine(this.lines, message, this.limits.line); + this.changed(); + } + + log(message: string): void { + this.line(message); + } + + label(message: string): void { + const next = syncTaskLoggerLabel(this.labels, message); + + this.labels = next.labels; + this.labelValue = next.labelValue; + this.changed(); + } + + subLabel(message: string): void { + const next = syncTaskLoggerSubLabel(this.labels, message); + + this.labels = next.labels; + this.subLabelValue = next.subLabelValue; + this.changed(); + } + + partial(chunk: string): void { + this.#partial = writeTaskLoggerPartial(this.lines, this.#partial, chunk, this.limits.line); + this.changed(); + } + + commitPartial(): void { + this.#partial = clearTaskLoggerPartial(); + this.changed(); + } + + info(message: string): void { + this.line(message); + } + + success(message: string): void { + this.stable('success', message); + } + + warning(message: string): void { + this.stable('warning', message); + } + + error(message: string): void { + this.stable('error', message); + } + + private stable(type: StableTaskMessage['type'], message: string): void { + this.#partial = writeStableTaskLoggerMessage(this.stableMessages, this.lines, type, message, this.limits); + this.changed(); + } + + private changed(): void { + notifyTaskLoggerChanged(this.onChange); + } +} diff --git a/packages/tui/src/status/task/logger/methods.ts b/packages/tui/src/status/task/logger/methods.ts new file mode 100644 index 00000000..992be70a --- /dev/null +++ b/packages/tui/src/status/task/logger/methods.ts @@ -0,0 +1,24 @@ +import { appendPartialTaskLog, appendTaskLogLines } from '#tui/status/task/logger/lines'; +import { appendStableTaskLoggerOutput } from '#tui/status/task/logger/output'; +import type { TaskLoggerLimits } from '#tui/status/task/logger/limits'; +import type { PartialTaskLogState } from '#tui/status/task/logger/lines'; +import type { StableTaskMessage } from '#tui/status/task/messages'; + +export const writeTaskLoggerLine = (lines: string[], message: string, limit: number): void => { + appendTaskLogLines(lines, message.trimEnd(), limit); +}; + +export const writeTaskLoggerPartial = (lines: string[], partial: PartialTaskLogState, chunk: string, limit: number): PartialTaskLogState => { + return appendPartialTaskLog(lines, partial, chunk, limit); +}; + +export const clearTaskLoggerPartial = (): PartialTaskLogState => ({ + startIndex: null, + value: '', +}); + +export const writeStableTaskLoggerMessage = (stableMessages: StableTaskMessage[], lines: string[], type: StableTaskMessage['type'], message: string, limits: TaskLoggerLimits): PartialTaskLogState => { + appendStableTaskLoggerOutput(stableMessages, lines, type, message, limits.stable); + + return clearTaskLoggerPartial(); +}; diff --git a/packages/tui/src/status/task/logger/output.ts b/packages/tui/src/status/task/logger/output.ts new file mode 100644 index 00000000..7cd7b71d --- /dev/null +++ b/packages/tui/src/status/task/logger/output.ts @@ -0,0 +1,11 @@ +import { appendStableTaskMessage } from '#tui/status/task/logger/stable'; +import type { StableTaskMessage } from '#tui/status/task/messages'; + +export const resetTaskLoggerLines = (lines: string[]): void => { + lines.splice(0); +}; + +export const appendStableTaskLoggerOutput = (messages: StableTaskMessage[], lines: string[], type: StableTaskMessage['type'], message: string, limit: number): void => { + appendStableTaskMessage(messages, type, message, limit); + resetTaskLoggerLines(lines); +}; diff --git a/packages/tui/src/status/task/logger/stable.ts b/packages/tui/src/status/task/logger/stable.ts new file mode 100644 index 00000000..2d7c53d2 --- /dev/null +++ b/packages/tui/src/status/task/logger/stable.ts @@ -0,0 +1,12 @@ +import { parseLogLimit } from '#tui/status/validators/limit'; +import type { StableTaskMessage } from '#tui/status/task/messages'; + +export const appendStableTaskMessage = (messages: StableTaskMessage[], type: StableTaskMessage['type'], message: string, limit: number): void => { + const stableLimit = parseLogLimit(limit, 10); + + messages.push({ message, type }); + + while (messages.length > stableLimit) { + messages.shift(); + } +}; diff --git a/packages/tui/src/status/task/logger/sync.ts b/packages/tui/src/status/task/logger/sync.ts new file mode 100644 index 00000000..82596665 --- /dev/null +++ b/packages/tui/src/status/task/logger/sync.ts @@ -0,0 +1,30 @@ +import { setTaskLoggerLabel, setTaskLoggerSubLabel } from '#tui/status/task/logger/labels'; +import type { TaskLoggerLabels } from '#tui/status/task/logger/labels'; + +export type TaskLoggerLabelSync = { + labelValue: string; + labels: TaskLoggerLabels; +}; + +export type TaskLoggerSubLabelSync = { + labels: TaskLoggerLabels; + subLabelValue: string; +}; + +export const syncTaskLoggerLabel = (labels: TaskLoggerLabels, label: string): TaskLoggerLabelSync => { + const next = setTaskLoggerLabel(labels, label); + + return { + labelValue: next.label, + labels: next, + }; +}; + +export const syncTaskLoggerSubLabel = (labels: TaskLoggerLabels, subLabel: string): TaskLoggerSubLabelSync => { + const next = setTaskLoggerSubLabel(labels, subLabel); + + return { + labels: next, + subLabelValue: next.subLabel, + }; +}; diff --git a/packages/tui/src/status/task/logger/validators/message.ts b/packages/tui/src/status/task/logger/validators/message.ts new file mode 100644 index 00000000..45120e72 --- /dev/null +++ b/packages/tui/src/status/task/logger/validators/message.ts @@ -0,0 +1,13 @@ +import { z } from 'zod'; + +const taskLogMessageLinesSchema = z.string().transform((message) => message.split(/\r?\n/u).filter((line) => line.length > 0)); + +export const parseTaskLogMessageLines = (message: unknown): string[] => { + const parsed = taskLogMessageLinesSchema.safeParse(message); + + if (!parsed.success) { + throw new TypeError('Task log messages must be strings.'); + } + + return parsed.data; +}; diff --git a/packages/tui/src/status/task/messages.ts b/packages/tui/src/status/task/messages.ts new file mode 100644 index 00000000..f1b47a15 --- /dev/null +++ b/packages/tui/src/status/task/messages.ts @@ -0,0 +1,4 @@ +export type StableTaskMessage = { + message: string; + type: 'error' | 'success' | 'warning'; +}; diff --git a/packages/tui/src/status/task/process-output.ts b/packages/tui/src/status/task/process-output.ts new file mode 100644 index 00000000..c37efb38 --- /dev/null +++ b/packages/tui/src/status/task/process-output.ts @@ -0,0 +1,34 @@ +import { ProcessOutputBuffer } from '#tui/status/task/process-output/buffer'; +import { parseProcessOutputWrite, parseWritableProcessStream } from '#tui/status/task/process-output/validators/writer'; +import { processOutputWriter } from '#tui/status/task/process-output/writer'; +import type { Logger } from '#tui/status/task/logger'; + +type TaskProcessOutputCapture = { + stop(): void; +}; + +export const captureTaskProcessOutput = (logger: Logger): TaskProcessOutputCapture => { + const stdout = parseWritableProcessStream(process.stdout); + const stderr = parseWritableProcessStream(process.stderr); + const originalStdoutWrite = stdout.write; + const originalStderrWrite = stderr.write; + const buffer = new ProcessOutputBuffer(logger); + + let stopped = false; + + stdout.write = parseProcessOutputWrite(processOutputWriter(buffer)); + stderr.write = parseProcessOutputWrite(processOutputWriter(buffer)); + + return { + stop(): void { + if (stopped) { + return; + } + + stopped = true; + stdout.write = originalStdoutWrite; + stderr.write = originalStderrWrite; + buffer.flush(); + }, + }; +}; diff --git a/packages/tui/src/status/task/process-output/buffer.ts b/packages/tui/src/status/task/process-output/buffer.ts new file mode 100644 index 00000000..9bef4ae7 --- /dev/null +++ b/packages/tui/src/status/task/process-output/buffer.ts @@ -0,0 +1,32 @@ +import type { Logger } from '#tui/status/task/logger'; +import { parseProcessOutputChunkLines } from '#tui/status/task/process-output/validators/chunk'; + +export class ProcessOutputBuffer { + #pending = ''; + readonly #logger: Logger; + + constructor(logger: Logger) { + this.#logger = logger; + } + + write(chunk: string): void { + const lines = parseProcessOutputChunkLines(`${this.#pending}${chunk}`); + + this.#pending = lines.pop() ?? ''; + + for (const line of lines) { + if (line.length > 0) { + this.#logger.line(line); + } + } + } + + flush(): void { + if (this.#pending.length === 0) { + return; + } + + this.#logger.line(this.#pending); + this.#pending = ''; + } +} diff --git a/packages/tui/src/status/task/process-output/validators/chunk.ts b/packages/tui/src/status/task/process-output/validators/chunk.ts new file mode 100644 index 00000000..78cb7f7a --- /dev/null +++ b/packages/tui/src/status/task/process-output/validators/chunk.ts @@ -0,0 +1,13 @@ +import { z } from 'zod'; + +const processOutputChunkLinesSchema = z.string().transform((chunk) => chunk.split(/\r?\n/u)); + +export const parseProcessOutputChunkLines = (chunk: unknown): string[] => { + const parsed = processOutputChunkLinesSchema.safeParse(chunk); + + if (!parsed.success) { + throw new TypeError('Process output chunks must be strings.'); + } + + return parsed.data; +}; diff --git a/packages/tui/src/status/task/process-output/validators/writer.ts b/packages/tui/src/status/task/process-output/validators/writer.ts new file mode 100644 index 00000000..41caf619 --- /dev/null +++ b/packages/tui/src/status/task/process-output/validators/writer.ts @@ -0,0 +1,6 @@ +export { parseProcessOutputCallback, parseProcessOutputEncoding } from '#tui/status/task/process-output/validators/writer/callback'; +export type { ProcessOutputCallback } from '#tui/status/task/process-output/validators/writer/callback'; +export { parseProcessOutputChunk, resolveProcessOutputWrite } from '#tui/status/task/process-output/validators/writer/chunk'; +export type { ResolvedProcessOutputWrite } from '#tui/status/task/process-output/validators/writer/chunk'; +export { parseProcessOutputWrite, parseWritableProcessStream } from '#tui/status/task/process-output/validators/writer/stream'; +export type { WritableProcessStream } from '#tui/status/task/process-output/validators/writer/stream'; diff --git a/packages/tui/src/status/task/process-output/validators/writer/callback.ts b/packages/tui/src/status/task/process-output/validators/writer/callback.ts new file mode 100644 index 00000000..70868109 --- /dev/null +++ b/packages/tui/src/status/task/process-output/validators/writer/callback.ts @@ -0,0 +1,26 @@ +import { z } from 'zod'; +import { functionSchema } from '#tui/validators/function'; + +export type ProcessOutputCallback = (error?: Error | null) => void; + +const processOutputEncodingSchema = z.string().refine((value): value is BufferEncoding => Buffer.isEncoding(value)); + +const processOutputCallbackSchema = functionSchema(); + +export const parseProcessOutputEncoding = (value: unknown): BufferEncoding | undefined => { + const parsed = processOutputEncodingSchema.safeParse(value); + + return parsed.success ? parsed.data : undefined; +}; + +export const parseProcessOutputCallback = (encodingOrCallback: unknown, callback?: ProcessOutputCallback): ProcessOutputCallback | undefined => { + const parsedEncodingCallback = processOutputCallbackSchema.safeParse(encodingOrCallback); + + if (parsedEncodingCallback.success) { + return parsedEncodingCallback.data; + } + + const parsedCallback = processOutputCallbackSchema.safeParse(callback); + + return parsedCallback.success ? parsedCallback.data : undefined; +}; diff --git a/packages/tui/src/status/task/process-output/validators/writer/chunk.ts b/packages/tui/src/status/task/process-output/validators/writer/chunk.ts new file mode 100644 index 00000000..5881ec3f --- /dev/null +++ b/packages/tui/src/status/task/process-output/validators/writer/chunk.ts @@ -0,0 +1,25 @@ +import { z } from 'zod'; +import { parseProcessOutputCallback, parseProcessOutputEncoding } from '#tui/status/task/process-output/validators/writer/callback'; +import type { ProcessOutputCallback } from '#tui/status/task/process-output/validators/writer/callback'; + +export type ResolvedProcessOutputWrite = { + callback?: ProcessOutputCallback; + content: string; +}; + +const processOutputStringSchema = z.string(); + +export const parseProcessOutputChunk = (chunk: string | Uint8Array, encoding?: BufferEncoding): string => { + const parsed = processOutputStringSchema.safeParse(chunk); + + return parsed.success ? parsed.data : Buffer.from(chunk).toString(encoding); +}; + +export const resolveProcessOutputWrite = (chunk: string | Uint8Array, encodingOrCallback?: BufferEncoding | ProcessOutputCallback, callback?: ProcessOutputCallback): ResolvedProcessOutputWrite => { + const encoding = parseProcessOutputEncoding(encodingOrCallback); + + return { + callback: parseProcessOutputCallback(encodingOrCallback, callback), + content: parseProcessOutputChunk(chunk, encoding), + }; +}; diff --git a/packages/tui/src/status/task/process-output/validators/writer/stream.ts b/packages/tui/src/status/task/process-output/validators/writer/stream.ts new file mode 100644 index 00000000..9ddef67d --- /dev/null +++ b/packages/tui/src/status/task/process-output/validators/writer/stream.ts @@ -0,0 +1,34 @@ +import { z } from 'zod'; +import { functionSchema } from '#tui/validators/function'; + +export type WritableProcessStream = { + write: NodeJS.WriteStream['write']; +}; + +const processOutputWriteSchema = functionSchema(); + +const writableProcessStreamSchema = z + .object({ + write: processOutputWriteSchema, + }) + .passthrough() as unknown as z.ZodType; + +export const parseProcessOutputWrite = (value: unknown): NodeJS.WriteStream['write'] => { + const parsed = processOutputWriteSchema.safeParse(value); + + if (!parsed.success) { + throw new TypeError('Process output writers must be functions.'); + } + + return value as NodeJS.WriteStream['write']; +}; + +export const parseWritableProcessStream = (value: unknown): WritableProcessStream => { + const parsed = writableProcessStreamSchema.safeParse(value); + + if (!parsed.success) { + throw new TypeError('Process output streams must include a write function.'); + } + + return value as WritableProcessStream; +}; diff --git a/packages/tui/src/status/task/process-output/writer.ts b/packages/tui/src/status/task/process-output/writer.ts new file mode 100644 index 00000000..af13ecd8 --- /dev/null +++ b/packages/tui/src/status/task/process-output/writer.ts @@ -0,0 +1,18 @@ +import { resolveProcessOutputWrite } from '#tui/status/task/process-output/validators/writer'; +import type { ProcessOutputBuffer } from '#tui/status/task/process-output/buffer'; + +export const processOutputWriter = (buffer: ProcessOutputBuffer) => { + return (chunk: string | Uint8Array, encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void), callback?: (error?: Error | null) => void): boolean => { + const resolved = resolveProcessOutputWrite(chunk, encodingOrCallback, callback); + + buffer.write(resolved.content); + + if (resolved.callback) { + queueMicrotask(() => { + resolved.callback?.(); + }); + } + + return true; + }; +}; diff --git a/packages/tui/src/status/task/render.ts b/packages/tui/src/status/task/render.ts new file mode 100644 index 00000000..9cbc33c4 --- /dev/null +++ b/packages/tui/src/status/task/render.ts @@ -0,0 +1,64 @@ +import { renderSpinnerFrame } from '#tui/status/spinner/render'; +import { parseLogLimit } from '#tui/status/validators/limit'; +import type { StableTaskMessage } from '#tui/status/task/messages'; + +const TASK_DIVIDER_WIDTH = 60; + +type TaskFrameOptions = { + frameCount?: number; + finished?: boolean; + keepSummary?: boolean; + label: string; + limit: number; + lines: string[]; + stableMessages: StableTaskMessage[]; + subLabel?: string; +}; + +const stableMessageSymbol = (type: StableTaskMessage['type']): string => { + if (type === 'success') { + return '✔'; + } + + if (type === 'error') { + return '✘'; + } + + return '⚠'; +}; + +const renderStableMessage = (message: StableTaskMessage): string => { + return ` ${stableMessageSymbol(message.type)} ${message.message}`; +}; + +export const renderTaskFrame = ({ finished = false, frameCount, keepSummary = false, label, limit, lines, stableMessages, subLabel }: TaskFrameOptions): string => { + const visibleLimit = parseLogLimit(limit, 10); + + if (finished && keepSummary && stableMessages.length > 0) { + return [` • ${label}`, ...stableMessages.map(renderStableMessage), ''].join('\n'); + } + + const output = [renderSpinnerFrame(label, frameCount).trimEnd()]; + + if (subLabel) { + output.push(` ${subLabel}`); + } + + output.push(...stableMessages.map(renderStableMessage)); + + if (stableMessages.length > 0 || lines.length > 0) { + output.push(` ${'─'.repeat(TASK_DIVIDER_WIDTH)}`); + } else { + output.push(''); + } + + const visibleLines = lines.slice(-visibleLimit); + + output.push(...visibleLines.map((line) => ` ${line}`)); + + for (let remaining = visibleLimit - visibleLines.length; remaining > 0; remaining -= 1) { + output.push(''); + } + + return `${output.join('\n')}\n`; +}; diff --git a/packages/tui/src/status/task/sanitize.ts b/packages/tui/src/status/task/sanitize.ts new file mode 100644 index 00000000..109a30dd --- /dev/null +++ b/packages/tui/src/status/task/sanitize.ts @@ -0,0 +1,6 @@ +const cursorReset = `${String.fromCharCode(27)}[1G`; +const eraseLine = `${String.fromCharCode(27)}[2K`; + +export const sanitizeTaskLine = (line: string): string => { + return line.replaceAll(cursorReset, '').replaceAll(eraseLine, '').replaceAll('\r', ''); +}; diff --git a/packages/tui/src/status/task/validators/definition.ts b/packages/tui/src/status/task/validators/definition.ts new file mode 100644 index 00000000..c3ff78e6 --- /dev/null +++ b/packages/tui/src/status/task/validators/definition.ts @@ -0,0 +1,50 @@ +import { z } from 'zod'; +import { functionSchema } from '#tui/validators/function'; +import type { TaskDefinition } from '#tui/status/task/definition'; +import type { Logger } from '#tui/status/task/logger'; +import type { MaybePromise } from '#tui/types'; + +const taskCallbackSchema = (): z.ZodType<(logger: Logger) => MaybePromise> => functionSchema<(logger: Logger) => MaybePromise>(); + +const taskDefinitionSchema = (): z.ZodType> => + z + .object({ + keepSummary: z.boolean().optional(), + limit: z.number().optional(), + subLabel: z.string().optional(), + task: taskCallbackSchema(), + title: z.string(), + }) + .passthrough() as z.ZodType>; + +const taskTitleSchema = z.string(); + +export const isTaskTitle = (value: unknown): value is string => { + return taskTitleSchema.safeParse(value).success; +}; + +export const parseTaskCallback = (value: unknown): ((logger: Logger) => MaybePromise) => { + const parsed = taskCallbackSchema().safeParse(value); + + if (!parsed.success) { + throw new Error('A task callback is required.'); + } + + return parsed.data; +}; + +export const parseTaskDefinition = (value: unknown): TaskDefinition => { + const parsed = taskDefinitionSchema().safeParse(value); + + if (parsed.success) { + return parsed.data; + } + + const callback = z.object({ task: taskCallbackSchema() }).passthrough().safeParse(value); + + if (!callback.success) { + throw new Error('A task callback is required.'); + } + + throw new Error('A task title is required.'); +}; diff --git a/packages/tui/src/status/validators/limit.ts b/packages/tui/src/status/validators/limit.ts new file mode 100644 index 00000000..3a48fefa --- /dev/null +++ b/packages/tui/src/status/validators/limit.ts @@ -0,0 +1,15 @@ +import { z } from 'zod'; + +const limitSchema = z.number().finite(); + +export const parseLogLimit = (limit: unknown, defaultValue: number): number => { + const parsed = limitSchema.safeParse(limit); + + if (!parsed.success) { + return defaultValue; + } + + const value = Math.trunc(parsed.data); + + return value <= 0 ? defaultValue : value; +}; diff --git a/packages/tui/src/status/validators/progress.ts b/packages/tui/src/status/validators/progress.ts new file mode 100644 index 00000000..875e9c82 --- /dev/null +++ b/packages/tui/src/status/validators/progress.ts @@ -0,0 +1,40 @@ +import { z } from 'zod'; +import { iterableSchema } from '#tui/validators/iterable'; + +const progressNumberSchema = z.number().finite(); +const progressTotalInputSchema = z.union([z.number(), z.literal(Number.POSITIVE_INFINITY), z.literal(Number.NEGATIVE_INFINITY)]); + +type ProgressValuesInput = { kind: 'steps'; steps: Iterable } | { kind: 'total'; total: number }; + +export const parseProgressTotal = (total: unknown): number => { + const parsed = progressNumberSchema.safeParse(total); + const value = parsed.success ? Math.trunc(parsed.data) : 0; + + if (value <= 0) { + throw new Error('Progress bar must have at least one item.'); + } + + return value; +}; + +export const parseProgressStep = (step: unknown): number => { + const parsed = progressNumberSchema.safeParse(step); + + return parsed.success ? Math.trunc(parsed.data) : 0; +}; + +export const parseProgressValuesInput = (steps: Iterable | number): ProgressValuesInput => { + const total = progressTotalInputSchema.safeParse(steps); + + if (total.success) { + return { kind: 'total', total: parseProgressTotal(total.data) }; + } + + const parsedSteps = iterableSchema().safeParse(steps); + + if (!parsedSteps.success) { + throw new Error('Progress steps must be an iterable or a number.'); + } + + return { kind: 'steps', steps: parsedSteps.data }; +}; diff --git a/packages/tui/src/status/validators/signal-target.ts b/packages/tui/src/status/validators/signal-target.ts new file mode 100644 index 00000000..781229e9 --- /dev/null +++ b/packages/tui/src/status/validators/signal-target.ts @@ -0,0 +1,24 @@ +import { z } from 'zod'; +import { functionSchema } from '#tui/validators/function'; + +export type StatusSignalTarget = { + off(signal: string, listener: () => void): unknown; + on(signal: string, listener: () => void): unknown; +}; + +const statusSignalTargetSchema = z + .object({ + off: functionSchema(), + on: functionSchema(), + }) + .passthrough() as unknown as z.ZodType; + +export const parseStatusSignalTarget = (target: unknown): StatusSignalTarget => { + const parsed = statusSignalTargetSchema.safeParse(target); + + if (!parsed.success) { + throw new TypeError('Status signal targets must include on and off functions.'); + } + + return target as StatusSignalTarget; +}; diff --git a/packages/tui/src/string-utils/ansi.ts b/packages/tui/src/string-utils/ansi.ts new file mode 100644 index 00000000..f29f0209 --- /dev/null +++ b/packages/tui/src/string-utils/ansi.ts @@ -0,0 +1,7 @@ +import stripAnsi from 'strip-ansi'; + +export { ansiCloseSequence, ESC } from '#tui/string-utils/ansi/codes'; +export { parseAnsiSegments } from '#tui/string-utils/ansi/segments'; +export type { AnsiSegment } from '#tui/string-utils/ansi/segments'; + +export const parseAnsiText = (value: string): string => stripAnsi(value); diff --git a/packages/tui/src/string-utils/ansi/codes.ts b/packages/tui/src/string-utils/ansi/codes.ts new file mode 100644 index 00000000..8416b9e7 --- /dev/null +++ b/packages/tui/src/string-utils/ansi/codes.ts @@ -0,0 +1,42 @@ +export const ESC = '\u001B'; + +const ANSI_SEQUENCE = new RegExp(`^${ESC}\\[[0-9;]*m$`, 'u'); +const ANSI_RESET_CODES = new Set(['0', '22', '23', '24', '27', '29', '39', '49']); + +export const ansiCode = (sequence: string): string => sequence.slice(2, -1); + +export const ansiCodeParts = (code: string): string[] => code.split(';').filter((part) => part.length > 0); + +export const isAnsiStyleSequence = (sequence: string): boolean => ANSI_SEQUENCE.test(sequence); + +export const isAnsiResetCode = (code: string): boolean => ansiCodeParts(code).some((part) => ANSI_RESET_CODES.has(part)); + +export const ansiCloseSequence = (codes: string): string => { + const code = ansiCode(codes); + + if (!isAnsiStyleSequence(codes)) { + return `${ESC}[0m`; + } + + if (code === '1' || code === '2') { + return `${ESC}[22m`; + } + + if (code === '3') { + return `${ESC}[23m`; + } + + if (code === '4') { + return `${ESC}[24m`; + } + + if (code === '7') { + return `${ESC}[27m`; + } + + if (code === '9') { + return `${ESC}[29m`; + } + + return `${ESC}[0m`; +}; diff --git a/packages/tui/src/string-utils/ansi/segments.ts b/packages/tui/src/string-utils/ansi/segments.ts new file mode 100644 index 00000000..ab7ef3e6 --- /dev/null +++ b/packages/tui/src/string-utils/ansi/segments.ts @@ -0,0 +1,123 @@ +import { ansiCode, ansiCodeParts, ESC, isAnsiResetCode, isAnsiStyleSequence } from '#tui/string-utils/ansi/codes'; + +export type AnsiSegment = { + codes: string; + text: string; +}; + +export const parseAnsiSegments = (value: string): AnsiSegment[] => { + const segments: AnsiSegment[] = []; + + let currentCodes: string[] = []; + let currentText = ''; + let index = 0; + + while (index < value.length) { + if (value[index] === ESC && value[index + 1] === '[') { + if (currentText !== '') { + segments.push({ text: currentText, codes: activeAnsiCodes(currentCodes) }); + currentText = ''; + } + + const sequence = readAnsiSequence(value, index); + + index += sequence.length; + + if (isAnsiStyleSequence(sequence)) { + const code = ansiCode(sequence); + + currentCodes = applyAnsiStyleSequence(currentCodes, code, sequence); + } + + continue; + } + + currentText += value[index]; + index += 1; + } + + if (currentText !== '') { + segments.push({ text: currentText, codes: activeAnsiCodes(currentCodes) }); + } + + return segments; +}; + +const activeAnsiCodes = (codes: string[]): string => codes.join(''); +const hasAnsiStyleCode = (code: string): boolean => ansiCodeParts(code).some((part) => !isAnsiResetCode(part)); + +const applyAnsiStyleSequence = (codes: string[], code: string, sequence: string): string[] => { + const next = isAnsiResetCode(code) ? resetAnsiCodes(codes, code) : codes; + + return hasAnsiStyleCode(code) ? [...next, sequence] : next; +}; + +const resetAnsiCodes = (codes: string[], resetCode: string): string[] => { + if (ansiCodeParts(resetCode).includes('0')) { + return []; + } + + return codes.filter((sequence) => ansiCodeParts(resetCode).every((part) => !ansiCodeMatchesReset(ansiCode(sequence), part))); +}; + +const ansiCodeMatchesReset = (code: string, resetCode: string): boolean => { + const parts = ansiCodeParts(code); + + if (resetCode === '22') { + return parts.some((part) => part === '1' || part === '2'); + } + + if (resetCode === '23') { + return parts.includes('3'); + } + + if (resetCode === '24') { + return parts.includes('4'); + } + + if (resetCode === '27') { + return parts.includes('7'); + } + + if (resetCode === '29') { + return parts.includes('9'); + } + + if (resetCode === '39') { + return foregroundAnsiCode(code); + } + + if (resetCode === '49') { + return backgroundAnsiCode(code); + } + + return false; +}; + +const foregroundAnsiCode = (code: string): boolean => { + const values = ansiCodeParts(code).map((part) => Number(part)); + + return values.includes(38) || values.some((value) => (value >= 30 && value <= 37) || (value >= 90 && value <= 97)); +}; + +const backgroundAnsiCode = (code: string): boolean => { + const values = ansiCodeParts(code).map((part) => Number(part)); + + return values.includes(48) || values.some((value) => (value >= 40 && value <= 47) || (value >= 100 && value <= 107)); +}; + +const readAnsiSequence = (value: string, start: number): string => { + let sequence = ''; + let index = start; + + while (index < value.length) { + sequence += value[index]; + index += 1; + + if (isAnsiStyleSequence(sequence)) { + break; + } + } + + return sequence; +}; diff --git a/packages/tui/src/string-utils/truncate.ts b/packages/tui/src/string-utils/truncate.ts new file mode 100644 index 00000000..82b4c475 --- /dev/null +++ b/packages/tui/src/string-utils/truncate.ts @@ -0,0 +1,66 @@ +import { ansiCloseSequence, parseAnsiSegments } from '#tui/string-utils/ansi'; +import { parseStringWidth } from '#tui/string-utils/validators/width'; +import { visibleWidth } from '#tui/string-utils/width'; + +export const truncate = (value: string, width: number, marker = '...'): string => { + const parsedWidth = parseStringWidth(width); + + if (parsedWidth === undefined) { + return ''; + } + + const markerWidth = visibleWidth(marker); + + if (visibleWidth(value) <= parsedWidth) { + return value; + } + + if (parsedWidth <= markerWidth) { + let clippedMarker = ''; + let clippedWidth = 0; + + for (const char of marker) { + const charWidth = visibleWidth(char); + + if (clippedWidth + charWidth > parsedWidth) { + break; + } + + clippedMarker += char; + clippedWidth += charWidth; + } + + return clippedMarker; + } + + let result = ''; + let activeCodes = ''; + let resultWidth = 0; + + for (const segment of parseAnsiSegments(value)) { + if (segment.codes !== activeCodes) { + if (activeCodes !== '') { + result += ansiCloseSequence(activeCodes); + } + + if (segment.codes !== '') { + result += segment.codes; + } + + activeCodes = segment.codes; + } + + for (const char of segment.text) { + const charWidth = visibleWidth(char); + + if (resultWidth + charWidth + markerWidth > parsedWidth) { + return `${result}${activeCodes === '' ? '' : ansiCloseSequence(activeCodes)}${marker}`; + } + + result += char; + resultWidth += charWidth; + } + } + + return result; +}; diff --git a/packages/tui/src/string-utils/validators/width.ts b/packages/tui/src/string-utils/validators/width.ts new file mode 100644 index 00000000..a4412847 --- /dev/null +++ b/packages/tui/src/string-utils/validators/width.ts @@ -0,0 +1,9 @@ +import { z } from 'zod'; + +const stringWidthSchema = z.number().finite().positive(); + +export const parseStringWidth = (width: unknown): number | undefined => { + const parsed = stringWidthSchema.safeParse(width); + + return parsed.success ? Math.floor(parsed.data) : undefined; +}; diff --git a/packages/tui/src/string-utils/width.ts b/packages/tui/src/string-utils/width.ts new file mode 100644 index 00000000..e5506299 --- /dev/null +++ b/packages/tui/src/string-utils/width.ts @@ -0,0 +1,4 @@ +import stringWidth from 'string-width'; +import stripAnsi from 'strip-ansi'; + +export const visibleWidth = (value: string): number => stringWidth(stripAnsi(value)); diff --git a/packages/tui/src/string-utils/wrap.ts b/packages/tui/src/string-utils/wrap.ts new file mode 100644 index 00000000..ceaef25f --- /dev/null +++ b/packages/tui/src/string-utils/wrap.ts @@ -0,0 +1,7 @@ +import stripAnsi from 'strip-ansi'; +import { restoreAnsiWrappedLines } from '#tui/string-utils/wrap/ansi'; +import { plainWrap } from '#tui/string-utils/wrap/plain'; + +export const wrap = (value: string, width: number): string[] => { + return restoreAnsiWrappedLines(value, plainWrap(stripAnsi(value), width)); +}; diff --git a/packages/tui/src/string-utils/wrap/ansi.ts b/packages/tui/src/string-utils/wrap/ansi.ts new file mode 100644 index 00000000..1283703a --- /dev/null +++ b/packages/tui/src/string-utils/wrap/ansi.ts @@ -0,0 +1,57 @@ +import { ansiCloseSequence, parseAnsiSegments } from '#tui/string-utils/ansi'; +import type { AnsiSegment } from '#tui/string-utils/ansi'; + +const codedCharacters = (value: string): AnsiSegment[] => parseAnsiSegments(value).flatMap((segment) => [...segment.text].map((text) => ({ text, codes: segment.codes }))); + +export const restoreAnsiWrappedLines = (value: string, plainLines: string[]): string[] => { + const characters = codedCharacters(value); + const result: string[] = []; + + let characterIndex = 0; + + for (const plainLine of plainLines) { + let line = ''; + let activeCodes = ''; + + for (const plainCharacter of [...plainLine]) { + while (characterIndex < characters.length && characters[characterIndex]?.text !== plainCharacter) { + if (characters[characterIndex]?.text === ' ') { + characterIndex += 1; + continue; + } + + break; + } + + const character = characters[characterIndex]; + + if (character === undefined) { + line += plainCharacter; + continue; + } + + if (character.codes !== activeCodes) { + if (activeCodes !== '') { + line += '\u001B[0m'; + } + + if (character.codes !== '') { + line += character.codes; + } + + activeCodes = character.codes; + } + + line += plainCharacter; + characterIndex += 1; + } + + if (activeCodes !== '') { + line += ansiCloseSequence(activeCodes); + } + + result.push(line); + } + + return result; +}; diff --git a/packages/tui/src/string-utils/wrap/plain.ts b/packages/tui/src/string-utils/wrap/plain.ts new file mode 100644 index 00000000..6fbea989 --- /dev/null +++ b/packages/tui/src/string-utils/wrap/plain.ts @@ -0,0 +1,77 @@ +import { parseStringWidth } from '#tui/string-utils/validators/width'; +import { visibleWidth } from '#tui/string-utils/width'; + +const wrappedWideWord = (word: string, width: number): string[] => { + const chunks: string[] = []; + + let chunk = ''; + let chunkWidth = 0; + + for (const char of word) { + const charWidth = visibleWidth(char); + + if (chunkWidth + charWidth > width) { + if (chunk.length > 0) { + chunks.push(chunk); + } + + chunk = ''; + chunkWidth = 0; + } + + chunk += char; + chunkWidth += charWidth; + } + + return [...chunks, chunk]; +}; + +export const plainWrap = (value: string, width: number): string[] => { + const parsedWidth = parseStringWidth(width); + + if (parsedWidth === undefined) { + return [value]; + } + + const lines: string[] = []; + + for (const originalLine of value.split('\n')) { + let line = ''; + let lineWidth = 0; + + for (const word of originalLine.split(/(\s+)/u)) { + const wordWidth = visibleWidth(word); + + if (lineWidth + wordWidth <= parsedWidth) { + line += word; + lineWidth += wordWidth; + continue; + } + + if (line.length > 0) { + lines.push(line.trimEnd()); + line = ''; + lineWidth = 0; + } + + if (wordWidth > parsedWidth) { + const chunks = wrappedWideWord(word, parsedWidth); + + for (const chunk of chunks.slice(0, -1)) { + lines.push(chunk); + } + + line = chunks.at(-1) ?? ''; + lineWidth = visibleWidth(line); + continue; + } + + line = word.trimStart(); + lineWidth = visibleWidth(line); + } + + lines.push(line.trimEnd()); + } + + return lines; +}; diff --git a/packages/tui/src/strings.ts b/packages/tui/src/strings.ts new file mode 100644 index 00000000..5cd4aba0 --- /dev/null +++ b/packages/tui/src/strings.ts @@ -0,0 +1,5 @@ +export { parseAnsiSegments, parseAnsiText } from '#tui/string-utils/ansi'; +export type { AnsiSegment } from '#tui/string-utils/ansi'; +export { truncate } from '#tui/string-utils/truncate'; +export { visibleWidth } from '#tui/string-utils/width'; +export { wrap } from '#tui/string-utils/wrap'; diff --git a/packages/tui/src/terminal.ts b/packages/tui/src/terminal.ts new file mode 100644 index 00000000..518bbcd2 --- /dev/null +++ b/packages/tui/src/terminal.ts @@ -0,0 +1,61 @@ +import { promptEnvironment } from '#tui/environment'; +import { defaultBackgroundColor, defaultForegroundColor, parseTerminalColor, terminalSupportsTrueColor } from '#tui/terminal/capabilities'; +import type { TerminalColor } from '#tui/terminal/capabilities'; +import { parseTerminalDimension, parseTerminalLineCount } from '#tui/terminal/validators/size'; +import { parseTerminalTitle } from '#tui/terminal/validators/title'; + +export type TerminalSize = { + columns: number; + rows: number; +}; + +export const terminalSize = (): TerminalSize => ({ + columns: parseTerminalDimension(process.stdout.columns, 80), + rows: parseTerminalDimension(process.stdout.rows, 24), +}); + +export const clearTerminal = (): void => { + promptEnvironment().output.write('\u001B[H\u001B[J'); +}; + +export const setTerminalTitle = (title: string): void => { + promptEnvironment().output.write(`\u001B]0;${parseTerminalTitle(title)}\u0007`); +}; + +export const eraseLine = (): void => { + promptEnvironment().output.write('\u001B[2K'); +}; + +export const erasePreviousLines = (count: number): void => { + const lines = parseTerminalLineCount(count); + + if (lines === 0) { + return; + } + + promptEnvironment().output.write('\u001B[1A\u001B[2K'.repeat(lines)); +}; + +export const cursorToStart = (): void => { + promptEnvironment().output.write('\r'); +}; + +export const hideCursor = (): void => { + promptEnvironment().output.write('\u001B[?25l'); +}; + +export const showCursor = (): void => { + promptEnvironment().output.write('\u001B[?25h'); +}; + +export const supportsTrueColor = (value?: string): boolean => { + return terminalSupportsTrueColor(value); +}; + +export const foregroundColor = (value?: unknown): TerminalColor => { + return parseTerminalColor(value, defaultForegroundColor()); +}; + +export const backgroundColor = (value?: unknown): TerminalColor => { + return parseTerminalColor(value, defaultBackgroundColor()); +}; diff --git a/packages/tui/src/terminal/capabilities.ts b/packages/tui/src/terminal/capabilities.ts new file mode 100644 index 00000000..959a2691 --- /dev/null +++ b/packages/tui/src/terminal/capabilities.ts @@ -0,0 +1,14 @@ +import { parseTerminalColor } from '#tui/terminal/validators/color'; + +export type TerminalColor = readonly [number, number, number]; + +const TRUE_COLOR_TERMS = new Set(['truecolor', '24bit']); + +export const defaultForegroundColor = (): TerminalColor => [204, 204, 204]; + +export const defaultBackgroundColor = (): TerminalColor => [0, 0, 0]; + +export const terminalSupportsTrueColor = (value = process.env.COLORTERM): boolean => { + return TRUE_COLOR_TERMS.has((value ?? '').trim().toLowerCase()); +}; +export { parseTerminalColor }; diff --git a/packages/tui/src/terminal/validators/color.ts b/packages/tui/src/terminal/validators/color.ts new file mode 100644 index 00000000..9434d344 --- /dev/null +++ b/packages/tui/src/terminal/validators/color.ts @@ -0,0 +1,10 @@ +import { z } from 'zod'; +import type { TerminalColor } from '#tui/terminal/capabilities'; + +const terminalColorSchema = z.tuple([z.number().int().min(0).max(255), z.number().int().min(0).max(255), z.number().int().min(0).max(255)]); + +export const parseTerminalColor = (value: unknown, fallback: TerminalColor): TerminalColor => { + const parsed = terminalColorSchema.safeParse(value); + + return parsed.success ? parsed.data : fallback; +}; diff --git a/packages/tui/src/terminal/validators/size.ts b/packages/tui/src/terminal/validators/size.ts new file mode 100644 index 00000000..ecbdd6cc --- /dev/null +++ b/packages/tui/src/terminal/validators/size.ts @@ -0,0 +1,24 @@ +import { z } from 'zod'; + +const terminalDimensionSchema = z.number().finite().positive(); +const terminalLineCountSchema = z.number().finite().nonnegative(); + +export const parseTerminalDimension = (value: unknown, defaultValue: number): number => { + const parsed = terminalDimensionSchema.safeParse(value); + + if (!parsed.success) { + return defaultValue; + } + + return Math.trunc(parsed.data); +}; + +export const parseTerminalLineCount = (value: unknown): number => { + const parsed = terminalLineCountSchema.safeParse(value); + + if (!parsed.success) { + return 0; + } + + return Math.floor(parsed.data); +}; diff --git a/packages/tui/src/terminal/validators/title.ts b/packages/tui/src/terminal/validators/title.ts new file mode 100644 index 00000000..a0b2b08c --- /dev/null +++ b/packages/tui/src/terminal/validators/title.ts @@ -0,0 +1,19 @@ +import { z } from 'zod'; + +const isTerminalTitleCharacter = (character: string): boolean => { + const code = character.codePointAt(0); + + return code === undefined || !((code >= 0 && code <= 31) || code === 127 || code === 155); +}; + +const terminalTitleSchema = z.string().transform((title) => Array.from(title).filter(isTerminalTitleCharacter).join('')); + +export const parseTerminalTitle = (title: unknown): string => { + const parsed = terminalTitleSchema.safeParse(title); + + if (!parsed.success) { + throw new TypeError('Terminal titles must be strings.'); + } + + return parsed.data; +}; diff --git a/packages/tui/src/theme.ts b/packages/tui/src/theme.ts new file mode 100644 index 00000000..c397190c --- /dev/null +++ b/packages/tui/src/theme.ts @@ -0,0 +1,5 @@ +export { renderChoices } from '#tui/theme/choices'; +export { renderError } from '#tui/theme/error'; +export { renderQuestion } from '#tui/theme/question'; +export { symbols } from '#tui/theme/symbols'; +export { renderTable } from '#tui/theme/table'; diff --git a/packages/tui/src/theme/box.ts b/packages/tui/src/theme/box.ts new file mode 100644 index 00000000..1f236fde --- /dev/null +++ b/packages/tui/src/theme/box.ts @@ -0,0 +1,35 @@ +import { truncate, visibleWidth } from '#tui/strings'; +import { terminalSize } from '#tui/terminal'; +import { parseBoxWidth } from '#tui/theme/validators/box'; + +const DEFAULT_BOX_WIDTH = 60; +const TERMINAL_PADDING = 6; + +type BoxOptions = { + body: string; + borderStyle?: (value: string) => string; + info?: string; + title?: string; + width?: number; +}; + +const padVisible = (value: string, width: number): string => `${value}${' '.repeat(Math.max(0, width - visibleWidth(value)))}`; + +const defaultBoxWidth = (): number => Math.min(DEFAULT_BOX_WIDTH, Math.max(0, terminalSize().columns - TERMINAL_PADDING)); + +export const renderBox = ({ body, borderStyle = (value) => value, info = '', title = '', width }: BoxOptions): string => { + const bodyLines = body.split('\n'); + const requestedWidth = parseBoxWidth(width, defaultBoxWidth()); + const contentWidth = Math.max(requestedWidth, visibleWidth(title), ...bodyLines.map(visibleWidth)); + const titleWidth = visibleWidth(title); + const titleLabel = titleWidth > 0 ? ` ${title} ` : ''; + const topBorder = '─'.repeat(contentWidth - titleWidth + (titleWidth > 0 ? 0 : 2)); + const renderedInfo = info ? truncate(info, contentWidth - 1) : ''; + const bottomBorder = '─'.repeat(renderedInfo ? contentWidth - visibleWidth(renderedInfo) : contentWidth + 2); + + return [ + `${borderStyle(' ┌')}${titleLabel}${borderStyle(`${topBorder}┐`)}`, + ...bodyLines.map((line) => `${borderStyle(' │')} ${padVisible(line, contentWidth)} ${borderStyle('│')}`), + borderStyle(` └${bottomBorder}${renderedInfo ? ` ${renderedInfo} ` : ''}┘`), + ].join('\n'); +}; diff --git a/packages/tui/src/theme/choice-disabled.ts b/packages/tui/src/theme/choice-disabled.ts new file mode 100644 index 00000000..b0a4ecc9 --- /dev/null +++ b/packages/tui/src/theme/choice-disabled.ts @@ -0,0 +1,9 @@ +import { parseDisabledReason } from '#tui/theme/validators/disabled'; + +export const choiceDisabledSuffix = (disabled: boolean | string | undefined): string => { + if (!disabled) { + return ''; + } + + return ` (${parseDisabledReason(disabled) ?? 'disabled'})`; +}; diff --git a/packages/tui/src/theme/choices.ts b/packages/tui/src/theme/choices.ts new file mode 100644 index 00000000..4855e95e --- /dev/null +++ b/packages/tui/src/theme/choices.ts @@ -0,0 +1,13 @@ +import { choiceDisabledSuffix } from '#tui/theme/choice-disabled'; +import type { Choice } from '#tui/types'; + +export const renderChoices = (choices: Array>): string => { + return choices + .map((choice, index) => { + const disabled = choiceDisabledSuffix(choice.disabled); + const hint = choice.hint ? ` ${choice.hint}` : ''; + + return ` ${index + 1}. ${choice.label}${hint}${disabled}`; + }) + .join('\n'); +}; diff --git a/packages/tui/src/theme/error.ts b/packages/tui/src/theme/error.ts new file mode 100644 index 00000000..10b1830e --- /dev/null +++ b/packages/tui/src/theme/error.ts @@ -0,0 +1,4 @@ +import { red } from '#tui/theme/styles'; +import { symbols } from '#tui/theme/symbols'; + +export const renderError = (message: string): string => `${red(`${symbols.error} ${message}`)}\n`; diff --git a/packages/tui/src/theme/question.ts b/packages/tui/src/theme/question.ts new file mode 100644 index 00000000..e3b96482 --- /dev/null +++ b/packages/tui/src/theme/question.ts @@ -0,0 +1,7 @@ +import { symbols } from '#tui/theme/symbols'; + +export const renderQuestion = (message: string, hint?: string): string => { + const suffix = hint ? ` ${hint}` : ''; + + return `${symbols.question} ${message}${suffix} `; +}; diff --git a/packages/tui/src/theme/styles.ts b/packages/tui/src/theme/styles.ts new file mode 100644 index 00000000..676490b3 --- /dev/null +++ b/packages/tui/src/theme/styles.ts @@ -0,0 +1,37 @@ +const ansi = (open: number, close: number, value: string): string => { + if (value.length === 0) { + return ''; + } + + return `\u001B[${open}m${value}\u001B[${close}m`; +}; + +export const dim = (value: string): string => ansi(2, 22, value); + +export const cyan = (value: string): string => ansi(36, 39, value); + +export const green = (value: string): string => ansi(32, 39, value); + +export const inverse = (value: string): string => ansi(7, 27, value); + +export const black = (value: string): string => ansi(30, 39, value); + +export const red = (value: string): string => ansi(31, 39, value); + +export const strikethrough = (value: string): string => ansi(9, 29, value); + +export const white = (value: string): string => ansi(37, 39, value); + +export const yellow = (value: string): string => ansi(33, 39, value); + +export const backgroundCyan = (value: string): string => ansi(46, 49, value); + +export const backgroundRed = (value: string): string => ansi(41, 49, value); + +export const foregroundRgb = (value: string, red: number, green: number, blue: number): string => { + if (value.length === 0) { + return ''; + } + + return `\u001B[38;2;${red};${green};${blue}m${value}\u001B[0m`; +}; diff --git a/packages/tui/src/theme/symbols.ts b/packages/tui/src/theme/symbols.ts new file mode 100644 index 00000000..c51e56af --- /dev/null +++ b/packages/tui/src/theme/symbols.ts @@ -0,0 +1,11 @@ +export const symbols = { + question: '?', + answer: '>', + error: '!', + info: 'i', + selected: '●', + unselected: '○', + pointer: '›', + success: '✓', + warning: '!', +} as const; diff --git a/packages/tui/src/theme/table.ts b/packages/tui/src/theme/table.ts new file mode 100644 index 00000000..0239ee68 --- /dev/null +++ b/packages/tui/src/theme/table.ts @@ -0,0 +1,30 @@ +import { visibleWidth } from '#tui/strings'; +import { isRenderableTableColumnCount } from '#tui/theme/validators/table'; + +const padVisible = (value: string, width: number): string => `${value}${' '.repeat(Math.max(0, width - visibleWidth(value)))}`; + +const tableColumnCount = (headers: string[], rows: string[][]): number => { + return Math.max(headers.length, ...rows.map((row) => row.length)); +}; + +export const renderTable = (headers: string[], rows: string[][]): string => { + const columnCount = tableColumnCount(headers, rows); + + if (!isRenderableTableColumnCount(columnCount)) { + return ''; + } + + const widths = Array.from({ length: columnCount }, (_, index) => { + return Math.max(visibleWidth(headers[index] ?? ''), ...rows.map((row) => visibleWidth(row[index] ?? ''))); + }); + + const renderRow = (columns: string[]): string => { + return `| ${widths.map((width, index) => padVisible(columns[index] ?? '', width)).join(' | ')} |`; + }; + + if (headers.length === 0) { + return rows.map(renderRow).join('\n'); + } + + return [renderRow(headers), renderRow(widths.map((width) => '-'.repeat(width))), ...rows.map(renderRow)].join('\n'); +}; diff --git a/packages/tui/src/theme/validators/box.ts b/packages/tui/src/theme/validators/box.ts new file mode 100644 index 00000000..08c5511e --- /dev/null +++ b/packages/tui/src/theme/validators/box.ts @@ -0,0 +1,13 @@ +import { z } from 'zod'; + +const boxWidthSchema = z.number().finite().nonnegative(); + +export const parseBoxWidth = (width: unknown, defaultValue: number): number => { + const parsed = boxWidthSchema.safeParse(width); + + if (!parsed.success) { + return defaultValue; + } + + return Math.trunc(parsed.data); +}; diff --git a/packages/tui/src/theme/validators/disabled.ts b/packages/tui/src/theme/validators/disabled.ts new file mode 100644 index 00000000..395df3ca --- /dev/null +++ b/packages/tui/src/theme/validators/disabled.ts @@ -0,0 +1,9 @@ +import { z } from 'zod'; + +const disabledReasonSchema = z.string(); + +export const parseDisabledReason = (value: unknown): string | undefined => { + const parsed = disabledReasonSchema.safeParse(value); + + return parsed.success ? parsed.data : undefined; +}; diff --git a/packages/tui/src/theme/validators/table.ts b/packages/tui/src/theme/validators/table.ts new file mode 100644 index 00000000..4fb721b9 --- /dev/null +++ b/packages/tui/src/theme/validators/table.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; + +const renderableTableColumnCountSchema = z.number().finite().positive(); + +export const isRenderableTableColumnCount = (count: number): boolean => { + return renderableTableColumnCountSchema.safeParse(count).success; +}; diff --git a/packages/tui/src/typed-value.ts b/packages/tui/src/typed-value.ts new file mode 100644 index 00000000..68eb83ba --- /dev/null +++ b/packages/tui/src/typed-value.ts @@ -0,0 +1,4 @@ +export { applyTypedKey } from '#tui/typed-value/edit'; +export { readTypedValue } from '#tui/typed-value/read'; +export type { TypedValueReadResult } from '#tui/typed-value/read'; +export type { TypedValueOptions, TypedValueState } from '#tui/typed-value/types'; diff --git a/packages/tui/src/typed-value/characters.ts b/packages/tui/src/typed-value/characters.ts new file mode 100644 index 00000000..a146198c --- /dev/null +++ b/packages/tui/src/typed-value/characters.ts @@ -0,0 +1,19 @@ +export const characters = (value: string): string[] => [...value]; + +export const fromCharacters = (value: string[]): string => value.join(''); + +export const characterLength = (value: string): number => characters(value).length; + +export const stringIndexToCharacterIndex = (value: string, index: number): number => characterLength(value.slice(0, index)); + +export const isPrintable = (key: string): boolean => { + if (key.length === 0) { + return false; + } + + return [...key].every((character) => { + const code = character.codePointAt(0) ?? 0; + + return code >= 32 && code !== 127; + }); +}; diff --git a/packages/tui/src/typed-value/cursor.ts b/packages/tui/src/typed-value/cursor.ts new file mode 100644 index 00000000..97534e28 --- /dev/null +++ b/packages/tui/src/typed-value/cursor.ts @@ -0,0 +1,22 @@ +import { characters, fromCharacters } from '#tui/typed-value/characters'; +import { dim, inverse } from '#tui/theme/styles'; +import { parseTypedValueCursor } from '#tui/typed-value/validators/cursor'; + +export const valueWithCursor = (value: string, cursor: number): string => { + const chars = characters(value); + const position = parseTypedValueCursor(cursor, chars.length); + const before = fromCharacters(chars.slice(0, position)); + const current = chars[position]; + + if (current === '\n') { + return `${before}${inverse(' ')}${fromCharacters(chars.slice(position))}`; + } + + const after = fromCharacters(chars.slice(position + 1)); + + return `${before}${inverse(current ?? ' ')}${after}`; +}; + +export const placeholderWithCursor = (placeholder = ''): string => { + return dim(valueWithCursor(placeholder, 0)); +}; diff --git a/packages/tui/src/typed-value/delete.ts b/packages/tui/src/typed-value/delete.ts new file mode 100644 index 00000000..580e5395 --- /dev/null +++ b/packages/tui/src/typed-value/delete.ts @@ -0,0 +1,36 @@ +import { fromCharacters, stringIndexToCharacterIndex } from '#tui/typed-value/characters'; +import { moveToLineBoundary } from '#tui/typed-value/lines'; + +export const deleteNextCharacter = (value: string[], cursor: number): number => { + value.splice(cursor, 1); + + return cursor; +}; + +export const deleteToLineStart = (value: string[], cursor: number, allowNewLine: boolean): number => { + const start = allowNewLine ? moveToLineBoundary(value, cursor, 'start') : 0; + + value.splice(start, cursor - start); + + return start; +}; + +export const deletePreviousCharacter = (value: string[], cursor: number): number => { + if (cursor === 0) { + return cursor; + } + + value.splice(cursor - 1, 1); + + return cursor - 1; +}; + +export const deletePreviousWord = (value: string[], cursor: number): number => { + const before = fromCharacters(value.slice(0, cursor)); + const match = before.match(/(?:[\p{L}\p{M}\p{N}]+|[^\p{L}\p{M}\p{N}\s]+)\s*$/u); + const start = match?.index === undefined ? 0 : stringIndexToCharacterIndex(before, match.index); + + value.splice(start, cursor - start); + + return start; +}; diff --git a/packages/tui/src/typed-value/edit.ts b/packages/tui/src/typed-value/edit.ts new file mode 100644 index 00000000..568f1814 --- /dev/null +++ b/packages/tui/src/typed-value/edit.ts @@ -0,0 +1,60 @@ +import { Key } from '#tui/key'; +import { characterLength, characters } from '#tui/typed-value/characters'; +import { deleteTypedValueKey } from '#tui/typed-value/edit/deletion'; +import { insertPrintableKey } from '#tui/typed-value/edit/insertion'; +import { moveTypedValueCursor } from '#tui/typed-value/edit/navigation'; +import { typedKeyResult } from '#tui/typed-value/edit/result'; +import { parseTypedValueCursor } from '#tui/typed-value/validators/cursor'; +import type { AppliedTypedKey, TypedValueState } from '#tui/typed-value/types'; + +export const applyTypedKey = (state: TypedValueState, key: string, allowNewLine = false, wrapWidth?: number): AppliedTypedKey => { + const value = characters(state.value); + + let cursor = parseTypedValueCursor(state.cursor, value.length); + + if (key === Key.ctrlC) { + return typedKeyResult(value, cursor, { cancelled: true }); + } + + if (key === Key.ctrlD && allowNewLine) { + return typedKeyResult(value, cursor, { submitted: true }); + } + + if (key === Key.enter) { + if (!allowNewLine) { + return typedKeyResult(value, cursor, { submitted: true }); + } + + value.splice(cursor, 0, '\n'); + cursor += 1; + + return typedKeyResult(value, cursor); + } + + const moved = moveTypedValueCursor(value, cursor, key, allowNewLine, wrapWidth); + + if (moved !== undefined) { + return typedKeyResult(value, moved); + } + + const deleted = deleteTypedValueKey(value, cursor, key, allowNewLine); + + if (deleted !== undefined) { + return typedKeyResult(value, deleted); + } + + const inserted = insertPrintableKey(value, cursor, key); + + if (inserted !== undefined) { + cursor = inserted; + } + + return typedKeyResult(value, cursor); +}; + +export const initialTypedValueState = (value = ''): TypedValueState => { + return { + cursor: characterLength(value), + value, + }; +}; diff --git a/packages/tui/src/typed-value/edit/deletion.ts b/packages/tui/src/typed-value/edit/deletion.ts new file mode 100644 index 00000000..a61492f1 --- /dev/null +++ b/packages/tui/src/typed-value/edit/deletion.ts @@ -0,0 +1,22 @@ +import { Key } from '#tui/key'; +import { deleteNextCharacter, deletePreviousCharacter, deletePreviousWord, deleteToLineStart } from '#tui/typed-value/delete'; + +export const deleteTypedValueKey = (value: string[], cursor: number, key: string, allowNewLine: boolean): number | undefined => { + if (key === Key.delete) { + return deleteNextCharacter(value, cursor); + } + + if (key === Key.ctrlU) { + return deleteToLineStart(value, cursor, allowNewLine); + } + + if (key === Key.backspace || key === Key.ctrlH) { + return deletePreviousCharacter(value, cursor); + } + + if (key === Key.optionBackspace) { + return deletePreviousWord(value, cursor); + } + + return undefined; +}; diff --git a/packages/tui/src/typed-value/edit/insertion.ts b/packages/tui/src/typed-value/edit/insertion.ts new file mode 100644 index 00000000..77df2631 --- /dev/null +++ b/packages/tui/src/typed-value/edit/insertion.ts @@ -0,0 +1,13 @@ +import { characters, isPrintable } from '#tui/typed-value/characters'; + +export const insertPrintableKey = (value: string[], cursor: number, key: string): number | undefined => { + if (!isPrintable(key)) { + return undefined; + } + + const inserted = characters(key); + + value.splice(cursor, 0, ...inserted); + + return cursor + inserted.length; +}; diff --git a/packages/tui/src/typed-value/edit/navigation.ts b/packages/tui/src/typed-value/edit/navigation.ts new file mode 100644 index 00000000..732f12ef --- /dev/null +++ b/packages/tui/src/typed-value/edit/navigation.ts @@ -0,0 +1,30 @@ +import { Key, oneOf } from '#tui/key'; +import { moveLine, moveToLineBoundary } from '#tui/typed-value/lines'; + +export const moveTypedValueCursor = (value: string[], cursor: number, key: string, allowNewLine: boolean, wrapWidth?: number): number | undefined => { + if (key === Key.left || key === Key.leftArrow || key === Key.ctrlB) { + return Math.max(0, cursor - 1); + } + + if (key === Key.right || key === Key.rightArrow || key === Key.ctrlF) { + return Math.min(value.length, cursor + 1); + } + + if (allowNewLine && (key === Key.up || key === Key.upArrow || key === Key.ctrlP)) { + return moveLine(value, cursor, -1, wrapWidth); + } + + if (allowNewLine && (key === Key.down || key === Key.downArrow || key === Key.ctrlN)) { + return moveLine(value, cursor, 1, wrapWidth); + } + + if (oneOf([Key.home, Key.ctrlA], key)) { + return allowNewLine ? moveToLineBoundary(value, cursor, 'start', wrapWidth) : 0; + } + + if (oneOf([Key.end, Key.ctrlE], key)) { + return allowNewLine ? moveToLineBoundary(value, cursor, 'end', wrapWidth) : value.length; + } + + return undefined; +}; diff --git a/packages/tui/src/typed-value/edit/result.ts b/packages/tui/src/typed-value/edit/result.ts new file mode 100644 index 00000000..3e4b06b0 --- /dev/null +++ b/packages/tui/src/typed-value/edit/result.ts @@ -0,0 +1,14 @@ +import { fromCharacters } from '#tui/typed-value/characters'; +import type { AppliedTypedKey } from '#tui/typed-value/types'; + +type TypedKeyResultOptions = { + cancelled?: boolean; + submitted?: boolean; +}; + +export const typedKeyResult = (value: string[], cursor: number, options: TypedKeyResultOptions = {}): AppliedTypedKey => ({ + cancelled: options.cancelled ?? false, + cursor, + submitted: options.submitted ?? false, + value: fromCharacters(value), +}); diff --git a/packages/tui/src/typed-value/line-ranges.ts b/packages/tui/src/typed-value/line-ranges.ts new file mode 100644 index 00000000..ad90ab47 --- /dev/null +++ b/packages/tui/src/typed-value/line-ranges.ts @@ -0,0 +1,64 @@ +import { visibleWidth } from '#tui/strings'; +import { parseTypedValueWrapWidth } from '#tui/typed-value/validators/width'; + +export type LineRange = { + end: number; + start: number; +}; + +export const lineRanges = (value: string[], width?: number): LineRange[] => { + const ranges: LineRange[] = []; + const wrapWidth = parseTypedValueWrapWidth(width); + + let start = 0; + + for (const [index, character] of value.entries()) { + if (character === '\n') { + appendRanges(ranges, wrappedRanges(value, start, index, wrapWidth)); + start = index + 1; + } + } + + appendRanges(ranges, wrappedRanges(value, start, value.length, wrapWidth)); + + return ranges; +}; + +export const currentLine = (ranges: LineRange[], cursor: number): number => { + const index = ranges.findIndex((range) => cursor <= range.end); + + return index === -1 ? ranges.length - 1 : index; +}; + +const appendRanges = (ranges: LineRange[], nextRanges: LineRange[]): void => { + for (const range of nextRanges) { + ranges.push(range); + } +}; + +const wrappedRanges = (value: string[], start: number, end: number, width?: number): LineRange[] => { + if (width === undefined || width <= 0 || start === end) { + return [{ end, start }]; + } + + const ranges: LineRange[] = []; + + let rangeStart = start; + let rangeWidth = 0; + + for (let index = start; index < end; index += 1) { + const characterWidth = visibleWidth(value[index] ?? ''); + + if (rangeWidth > 0 && rangeWidth + characterWidth > width) { + ranges.push({ end: index, start: rangeStart }); + rangeStart = index; + rangeWidth = 0; + } + + rangeWidth += characterWidth; + } + + ranges.push({ end, start: rangeStart }); + + return ranges; +}; diff --git a/packages/tui/src/typed-value/lines.ts b/packages/tui/src/typed-value/lines.ts new file mode 100644 index 00000000..742c96a6 --- /dev/null +++ b/packages/tui/src/typed-value/lines.ts @@ -0,0 +1,42 @@ +import { currentLine, lineRanges } from '#tui/typed-value/line-ranges'; +import { visibleLineWindow } from '#tui/typed-value/lines/window'; + +export { visibleLineWindow } from '#tui/typed-value/lines/window'; +export { visibleTextWindow } from '#tui/typed-value/lines/text-window'; +export type { VisibleLineWindow, VisibleTextWindow } from '#tui/typed-value/lines/types'; + +export const visibleLines = (value: string, cursor: number, rows: number | undefined): string => { + return visibleLineWindow(value, cursor, rows).lines.join('\n'); +}; + +export const moveLine = (value: string[], cursor: number, direction: 1 | -1, width?: number): number => { + const ranges = lineRanges(value, width); + const index = currentLine(ranges, cursor); + const range = ranges[index]; + + if (!range) { + return cursor; + } + + const target = ranges[index + direction]; + + if (!target) { + return direction === -1 ? 0 : value.length; + } + + const column = Math.min(cursor - range.start, range.end - range.start); + const targetColumn = Math.min(column, target.end - target.start); + + return target.start + targetColumn; +}; + +export const moveToLineBoundary = (value: string[], cursor: number, boundary: 'start' | 'end', width?: number): number => { + const ranges = lineRanges(value, width); + const range = ranges[currentLine(ranges, cursor)]; + + if (!range) { + return cursor; + } + + return boundary === 'start' ? range.start : range.end; +}; diff --git a/packages/tui/src/typed-value/lines/text-window.ts b/packages/tui/src/typed-value/lines/text-window.ts new file mode 100644 index 00000000..94a7be53 --- /dev/null +++ b/packages/tui/src/typed-value/lines/text-window.ts @@ -0,0 +1,27 @@ +import { lineRanges } from '#tui/typed-value/line-ranges'; +import { visibleLineWindow } from '#tui/typed-value/lines/window'; +import { parseTypedValueCursor } from '#tui/typed-value/validators/cursor'; +import type { VisibleTextWindow } from '#tui/typed-value/lines/types'; + +export const visibleTextWindow = (value: string, cursor: number, rows: number | undefined, width?: number): VisibleTextWindow => { + const valueCharacters = [...value]; + const visibleCursor = parseTypedValueCursor(cursor, valueCharacters.length); + const ranges = lineRanges(valueCharacters, width); + const window = visibleLineWindow(value, visibleCursor, rows, width); + const visibleRanges = ranges.slice(window.start, window.start + window.lines.length); + + const currentRangeIndex = Math.max( + 0, + visibleRanges.findIndex((range) => visibleCursor <= range.end), + ); + + const currentRange = visibleRanges[currentRangeIndex] ?? visibleRanges.at(-1); + const cursorInLine = currentRange === undefined ? 0 : Math.max(0, Math.min(visibleCursor, currentRange.end) - currentRange.start); + const previousWidth = window.lines.slice(0, currentRangeIndex).reduce((width, line) => width + [...line].length + 1, 0); + + return { + ...window, + cursor: previousWidth + cursorInLine, + text: window.lines.join('\n'), + }; +}; diff --git a/packages/tui/src/typed-value/lines/types.ts b/packages/tui/src/typed-value/lines/types.ts new file mode 100644 index 00000000..fafbc3a9 --- /dev/null +++ b/packages/tui/src/typed-value/lines/types.ts @@ -0,0 +1,10 @@ +export type VisibleLineWindow = { + lines: string[]; + start: number; + total: number; +}; + +export type VisibleTextWindow = VisibleLineWindow & { + cursor: number; + text: string; +}; diff --git a/packages/tui/src/typed-value/lines/window.ts b/packages/tui/src/typed-value/lines/window.ts new file mode 100644 index 00000000..2c1726ad --- /dev/null +++ b/packages/tui/src/typed-value/lines/window.ts @@ -0,0 +1,30 @@ +import { fromCharacters } from '#tui/typed-value/characters'; +import { currentLine, lineRanges } from '#tui/typed-value/line-ranges'; +import { parseTypedValueCursor } from '#tui/typed-value/validators/cursor'; +import { parseTypedValueRows } from '#tui/typed-value/validators/rows'; +import type { VisibleLineWindow } from '#tui/typed-value/lines/types'; + +export const visibleLineWindow = (value: string, cursor: number, rows: number | undefined, width?: number): VisibleLineWindow => { + const valueCharacters = [...value]; + const ranges = lineRanges(valueCharacters, width); + const visibleCursor = parseTypedValueCursor(cursor, valueCharacters.length); + const visibleRows = parseTypedValueRows(rows); + + if (visibleRows === undefined) { + return { + lines: ranges.map((range) => fromCharacters(valueCharacters.slice(range.start, range.end))), + start: 0, + total: ranges.length, + }; + } + + const line = currentLine(ranges, visibleCursor); + const start = Math.max(0, line - visibleRows + 1); + const end = start + visibleRows; + + return { + lines: ranges.slice(start, end).map((range) => fromCharacters(valueCharacters.slice(range.start, range.end))), + start, + total: ranges.length, + }; +}; diff --git a/packages/tui/src/typed-value/read.ts b/packages/tui/src/typed-value/read.ts new file mode 100644 index 00000000..363f1c75 --- /dev/null +++ b/packages/tui/src/typed-value/read.ts @@ -0,0 +1,59 @@ +import { promptEnvironment } from '#tui/environment'; +import { cancelTypedValueRead } from '#tui/typed-value/read/cancel'; +import { readTypedValueFallback } from '#tui/typed-value/read/fallback'; +import { eraseRenderedFrame } from '#tui/status/frame'; +import { applyTypedKey, initialTypedValueState } from '#tui/typed-value/edit'; +import { renderTypedValue } from '#tui/typed-value/render'; +import { TEXTAREA_CONTENT_WIDTH } from '#tui/typed-value/textarea'; +import type { TypedValueOptions, TypedValueState } from '#tui/typed-value/types'; + +export type TypedValueReadResult = { + cancelled: boolean; + frame?: string; + value: string; +}; + +export const readTypedValue = async (message: string, options: TypedValueOptions = {}): Promise => { + const environment = promptEnvironment(); + + if (!environment.input.readKey) { + return readTypedValueFallback(environment, message, options); + } + + let state: TypedValueState = initialTypedValueState(options.default ?? ''); + let frame = renderTypedValue(message, state, options); + + while (true) { + const key = await environment.input.readKey(); + + if (key === null) { + return { + cancelled: false, + frame, + value: state.value, + }; + } + + const next = applyTypedKey(state, key, options.allowNewLine, options.allowNewLine ? TEXTAREA_CONTENT_WIDTH : undefined); + + if (next.cancelled) { + return cancelTypedValueRead({ frame, message, options, value: state.value }); + } + + state = { + cursor: next.cursor, + value: next.value, + }; + + if (next.submitted) { + return { + cancelled: false, + frame, + value: state.value, + }; + } + + eraseRenderedFrame(frame); + frame = renderTypedValue(message, state, options); + } +}; diff --git a/packages/tui/src/typed-value/read/cancel.ts b/packages/tui/src/typed-value/read/cancel.ts new file mode 100644 index 00000000..067d9cfe --- /dev/null +++ b/packages/tui/src/typed-value/read/cancel.ts @@ -0,0 +1,28 @@ +import { cancelPrompt } from '#tui/prompt'; +import { eraseRenderedFrame } from '#tui/status/frame'; +import { renderCancelledTypedValue } from '#tui/typed-value/render'; +import { renderCancelledTextareaFrame } from '#tui/typed-value/textarea-frame'; +import type { TypedValueReadResult } from '#tui/typed-value/read'; +import type { TypedValueOptions } from '#tui/typed-value/types'; + +type CancelTypedValueReadOptions = { + frame: string; + message: string; + options: TypedValueOptions; + value: string; +}; + +export const cancelTypedValueRead = async ({ frame, message, options, value }: CancelTypedValueReadOptions): Promise => { + eraseRenderedFrame(frame); + + if (!options.allowNewLine) { + renderCancelledTypedValue(message, value, options); + } else { + renderCancelledTextareaFrame(message, value, options); + } + + return { + cancelled: true, + value: await cancelPrompt(value), + }; +}; diff --git a/packages/tui/src/typed-value/read/fallback.ts b/packages/tui/src/typed-value/read/fallback.ts new file mode 100644 index 00000000..b07679c6 --- /dev/null +++ b/packages/tui/src/typed-value/read/fallback.ts @@ -0,0 +1,18 @@ +import { PromptValidationError } from '#tui/prompt'; +import { renderQuestion } from '#tui/theme'; +import type { PromptEnvironment } from '#tui/contracts/environment'; +import type { TypedValueReadResult } from '#tui/typed-value/read'; +import type { TypedValueOptions } from '#tui/typed-value/types'; + +export const readTypedValueFallback = async (environment: PromptEnvironment, message: string, options: TypedValueOptions): Promise => { + if (!environment.input.readLine) { + throw new PromptValidationError('The configured prompt input cannot read input.'); + } + + const answer = await environment.input.readLine(renderQuestion(message, options.hint)); + + return { + cancelled: false, + value: answer === '' && options.default !== undefined ? options.default : answer, + }; +}; diff --git a/packages/tui/src/typed-value/render.ts b/packages/tui/src/typed-value/render.ts new file mode 100644 index 00000000..ee1b9c68 --- /dev/null +++ b/packages/tui/src/typed-value/render.ts @@ -0,0 +1,42 @@ +import { promptEnvironment } from '#tui/environment'; +import { renderBox } from '#tui/theme/box'; +import { cyan, dim, red, strikethrough } from '#tui/theme/styles'; +import { valueWithCursor } from '#tui/typed-value/cursor'; +import { visibleTextWindow } from '#tui/typed-value/lines'; +import { renderTextareaFrame } from '#tui/typed-value/textarea-frame'; +import type { TypedValueOptions, TypedValueState } from '#tui/typed-value/types'; + +export const renderTypedValue = (message: string, state: TypedValueState, options: TypedValueOptions): string => { + if (options.allowNewLine) { + const frame = `${renderTextareaFrame(message, state, options)}\n`; + + promptEnvironment().output.write(frame); + + if (options.hint) { + promptEnvironment().output.write(`${dim(options.hint)}\n`); + } + + return options.hint ? `${frame}${dim(options.hint)}\n` : frame; + } + + const visible = visibleTextWindow(state.value, state.cursor, options.rows); + const displayValue = state.value.length > 0 ? valueWithCursor(visible.text, visible.cursor) : dim(options.placeholder ?? ''); + + const frame = `${renderBox({ body: displayValue, borderStyle: cyan, info: options.hint, title: cyan(message) })}\n`; + + promptEnvironment().output.write(frame); + + return frame; +}; + +export const renderSubmittedTypedValue = (message: string, value: string): void => { + promptEnvironment().output.write(`${renderBox({ body: value, title: dim(message) })}\n`); +}; + +export const renderCancelledTypedValue = (message: string, value: string, options: TypedValueOptions): void => { + const environment = promptEnvironment(); + const displayValue = value.length > 0 ? value : (options.placeholder ?? ''); + + environment.output.write(`${renderBox({ body: strikethrough(dim(displayValue)), borderStyle: red, title: message })}\n`); + environment.error.write(`${red(' ⚠ Cancelled.')}\n`); +}; diff --git a/packages/tui/src/typed-value/textarea-frame.ts b/packages/tui/src/typed-value/textarea-frame.ts new file mode 100644 index 00000000..9b4f063e --- /dev/null +++ b/packages/tui/src/typed-value/textarea-frame.ts @@ -0,0 +1,61 @@ +import { renderScrollbarRows } from '#tui/concerns/scrollbar'; +import { promptEnvironment } from '#tui/environment'; +import { renderBox } from '#tui/theme/box'; +import { cyan, dim, red, strikethrough } from '#tui/theme/styles'; +import { valueWithCursor } from '#tui/typed-value/cursor'; +import { visibleTextWindow } from '#tui/typed-value/lines'; +import { TEXTAREA_CONTENT_WIDTH } from '#tui/typed-value/textarea'; +import { parseTypedValueRows } from '#tui/typed-value/validators/rows'; +import type { TypedValueOptions, TypedValueState } from '#tui/typed-value/types'; + +export const renderTextareaFrame = (message: string, state: TypedValueState, options: TypedValueOptions): string => { + return renderBox({ body: textareaBody(state, options), borderStyle: cyan, info: 'Ctrl+D to submit', title: cyan(message) }); +}; + +export const renderSubmittedTextareaFrame = (message: string, value: string): void => { + promptEnvironment().output.write(`${renderBox({ body: value, title: dim(message) })}\n`); +}; + +export const renderCancelledTextareaFrame = (message: string, value: string, options: TypedValueOptions): void => { + const environment = promptEnvironment(); + const body = textareaCancelledBody(value, options); + + environment.output.write(`${renderBox({ body, borderStyle: red, title: message })}\n`); + environment.error.write(`${red(' ⚠ Cancelled.')}\n`); +}; + +const textareaBody = (state: TypedValueState, options: TypedValueOptions): string => { + const rows = parseTypedValueRows(options.rows); + + if (state.value.length === 0) { + return placeholderBody(options, rows); + } + + const window = visibleTextWindow(state.value, state.cursor, rows, TEXTAREA_CONTENT_WIDTH); + + if (rows === undefined) { + return valueWithCursor(window.text, window.cursor); + } + + const visibleLines = valueWithCursor(window.text, window.cursor).split('\n'); + const padded = [...visibleLines.slice(0, rows), ...Array.from({ length: Math.max(0, rows - visibleLines.length) }, () => '')]; + + return renderScrollbarRows(padded, window.start, rows, window.total).join('\n'); +}; + +const placeholderBody = (options: TypedValueOptions, rows: number | undefined): string => { + const lines = [dim(options.placeholder ?? '')]; + + if (rows === undefined) { + return lines.join('\n'); + } + + return [...lines, ...Array.from({ length: Math.max(0, rows - lines.length) }, () => '')].join('\n'); +}; + +const textareaCancelledBody = (value: string, options: TypedValueOptions): string => { + const text = value.length > 0 ? value : (options.placeholder ?? ''); + const lines = text.split(/\r?\n/u); + + return lines.map((line) => strikethrough(dim(line))).join('\n'); +}; diff --git a/packages/tui/src/typed-value/textarea.ts b/packages/tui/src/typed-value/textarea.ts new file mode 100644 index 00000000..7097da98 --- /dev/null +++ b/packages/tui/src/typed-value/textarea.ts @@ -0,0 +1 @@ +export const TEXTAREA_CONTENT_WIDTH = 60; diff --git a/packages/tui/src/typed-value/types.ts b/packages/tui/src/typed-value/types.ts new file mode 100644 index 00000000..7c0e2343 --- /dev/null +++ b/packages/tui/src/typed-value/types.ts @@ -0,0 +1,17 @@ +export type TypedValueState = { + cursor: number; + value: string; +}; + +export type TypedValueOptions = { + allowNewLine?: boolean; + default?: string; + hint?: string; + placeholder?: string; + rows?: number; +}; + +export type AppliedTypedKey = TypedValueState & { + cancelled: boolean; + submitted: boolean; +}; diff --git a/packages/tui/src/typed-value/validators/cursor.ts b/packages/tui/src/typed-value/validators/cursor.ts new file mode 100644 index 00000000..0f3564bb --- /dev/null +++ b/packages/tui/src/typed-value/validators/cursor.ts @@ -0,0 +1,16 @@ +import { z } from 'zod'; + +const typedValueCursorSchema = z.number().finite().nonnegative(); +const typedValueLengthSchema = z.number().finite().nonnegative(); + +export const parseTypedValueCursor = (cursor: unknown, length: unknown): number => { + const parsedLength = typedValueLengthSchema.safeParse(length); + const max = parsedLength.success ? Math.floor(parsedLength.data) : 0; + const parsedCursor = typedValueCursorSchema.safeParse(cursor); + + if (!parsedCursor.success) { + return 0; + } + + return Math.min(Math.floor(parsedCursor.data), max); +}; diff --git a/packages/tui/src/typed-value/validators/rows.ts b/packages/tui/src/typed-value/validators/rows.ts new file mode 100644 index 00000000..b0633398 --- /dev/null +++ b/packages/tui/src/typed-value/validators/rows.ts @@ -0,0 +1,13 @@ +import { z } from 'zod'; + +const typedValueRowsSchema = z.number().finite().positive(); + +export const parseTypedValueRows = (rows: unknown): number | undefined => { + const parsed = typedValueRowsSchema.safeParse(rows); + + if (!parsed.success) { + return undefined; + } + + return Math.floor(parsed.data); +}; diff --git a/packages/tui/src/typed-value/validators/width.ts b/packages/tui/src/typed-value/validators/width.ts new file mode 100644 index 00000000..1eab81e6 --- /dev/null +++ b/packages/tui/src/typed-value/validators/width.ts @@ -0,0 +1,13 @@ +import { z } from 'zod'; + +const typedValueWrapWidthSchema = z.number().finite().positive(); + +export const parseTypedValueWrapWidth = (width: unknown): number | undefined => { + const parsed = typedValueWrapWidthSchema.safeParse(width); + + if (!parsed.success) { + return undefined; + } + + return Math.floor(parsed.data); +}; diff --git a/packages/tui/src/types.ts b/packages/tui/src/types.ts new file mode 100644 index 00000000..ce40ef0b --- /dev/null +++ b/packages/tui/src/types.ts @@ -0,0 +1,16 @@ +export type { MaybePromise, PromptValue, ValidationResult, Validator } from '#tui/contracts/base'; +export type { PromptEnvironment, PromptInput, PromptOutput } from '#tui/contracts/environment'; +export type { BasePromptOptions } from '#tui/contracts/options'; +export type { Choice, ChoiceInput, ChoiceOptions, PromptInfo } from '#tui/contracts/choices'; +export type { + ConfirmPromptOptions, + MultiSearchPromptOptions, + MultiSelectPromptOptions, + NumberPromptOptions, + SearchPromptOptions, + SelectPromptOptions, + TextareaPromptOptions, + TextPromptOptions, +} from '#tui/contracts/prompts'; +export type { DataTableObjectRow, DataTablePromptOptions, DataTableRow, TableCell, TableOptions } from '#tui/contracts/output'; +export type { StatusOptions } from '#tui/contracts/status'; diff --git a/packages/tui/src/validators/default.ts b/packages/tui/src/validators/default.ts new file mode 100644 index 00000000..1761e090 --- /dev/null +++ b/packages/tui/src/validators/default.ts @@ -0,0 +1,19 @@ +import { z } from 'zod'; + +const definedPromptDefaultSchema = z.unknown().refine((value) => value !== undefined); +const promptDefaultSchema = z.object({ default: definedPromptDefaultSchema }).passthrough(); +const promptDefaultValueSchema = (): z.ZodType => z.unknown() as z.ZodType; + +export const hasPromptDefault = (value: unknown): boolean => { + return promptDefaultSchema.safeParse(value).success; +}; + +export const parsePromptDefault = (value: unknown): T => { + const parsed = promptDefaultValueSchema().safeParse(value); + + if (!parsed.success) { + throw new TypeError('Prompt defaults must resolve to a typed value.'); + } + + return parsed.data; +}; diff --git a/packages/tui/src/validators/function.ts b/packages/tui/src/validators/function.ts new file mode 100644 index 00000000..2d980d01 --- /dev/null +++ b/packages/tui/src/validators/function.ts @@ -0,0 +1,5 @@ +import { z } from 'zod'; + +const callableSchema = z.function(); + +export const functionSchema = (): z.ZodType => z.custom((value) => callableSchema.safeParse(value).success); diff --git a/packages/tui/src/validators/iterable.ts b/packages/tui/src/validators/iterable.ts new file mode 100644 index 00000000..99270705 --- /dev/null +++ b/packages/tui/src/validators/iterable.ts @@ -0,0 +1,22 @@ +import { z } from 'zod'; +import { functionSchema } from '#tui/validators/function'; + +const iteratorMethodSchema = functionSchema<() => Iterator>(); +const asyncIteratorMethodSchema = functionSchema<() => AsyncIterator>(); +const stringValueSchema = z.string(); + +const hasIteratorMethod = (value: unknown): boolean => { + const iterator = (value as Partial> | null | undefined)?.[Symbol.iterator]; + + return iteratorMethodSchema.safeParse(iterator).success; +}; + +const hasAsyncIteratorMethod = (value: unknown): boolean => { + const iterator = (value as Partial> | null | undefined)?.[Symbol.asyncIterator]; + + return asyncIteratorMethodSchema.safeParse(iterator).success; +}; + +export const iterableSchema = (): z.ZodType> => z.custom>((value) => !stringValueSchema.safeParse(value).success && hasIteratorMethod(value)); + +export const asyncIterableSchema = (): z.ZodType> => z.custom>((value) => hasAsyncIteratorMethod(value)); diff --git a/packages/tui/src/validators/required.ts b/packages/tui/src/validators/required.ts new file mode 100644 index 00000000..547d2a15 --- /dev/null +++ b/packages/tui/src/validators/required.ts @@ -0,0 +1,27 @@ +import { z } from 'zod'; + +const requiredOptionSchema = z.union([z.boolean(), z.string()]).optional(); +const requiredMessageSchema = z.string().min(1); +const invalidRequiredValueSchema = z.union([z.literal(''), z.literal(false), z.null(), z.undefined(), z.array(z.unknown()).length(0)]); + +const isInvalidRequiredValue = (value: unknown): boolean => { + return invalidRequiredValueSchema.safeParse(value).success; +}; + +export const requiredMessage = (value: unknown, required?: boolean | string): string | undefined => { + const requiredOption = requiredOptionSchema.safeParse(required); + + if (!requiredOption.success) { + return undefined; + } + + const parsed = requiredOption.data; + + if (parsed === false || parsed === undefined || !isInvalidRequiredValue(value)) { + return undefined; + } + + const message = requiredMessageSchema.safeParse(parsed); + + return message.success ? message.data : 'Required.'; +}; diff --git a/packages/tui/src/validators/result.ts b/packages/tui/src/validators/result.ts new file mode 100644 index 00000000..5fdd9ac9 --- /dev/null +++ b/packages/tui/src/validators/result.ts @@ -0,0 +1,14 @@ +import { z } from 'zod'; +import type { ValidationResult } from '#tui/types'; + +const validationResultSchema = z.union([z.string(), z.null(), z.undefined()]); + +export const parseValidationResult = (result: unknown): ValidationResult => { + const parsed = validationResultSchema.safeParse(result); + + if (!parsed.success) { + throw new Error('The validator must return a string or null.'); + } + + return parsed.data; +}; diff --git a/packages/tui/tests/choices.test.ts b/packages/tui/tests/choices.test.ts new file mode 100644 index 00000000..f20c5b4c --- /dev/null +++ b/packages/tui/tests/choices.test.ts @@ -0,0 +1,1099 @@ +import { describe, expect, it } from 'vitest'; +import { confirm, createMemoryOutput, createScriptedInput, Key, multiselect, parseAnsiText, PromptValidationError, select, withPromptEnvironment } from '#tui/index'; +import type { SelectPromptOptions } from '#tui/index'; + +describe('choice prompts', () => { + it('confirms with direct y and n keys', async () => { + const output = createMemoryOutput(); + + const accepted = await withPromptEnvironment( + { + input: createScriptedInput(['y', Key.enter]), + output, + error: output, + interactive: true, + }, + () => confirm('Continue?', false), + ); + + const declined = await withPromptEnvironment( + { + input: createScriptedInput(['n', Key.enter]), + output, + error: output, + interactive: true, + }, + () => confirm('Continue?'), + ); + + expect(accepted).toBe(true); + expect(declined).toBe(false); + }); + + it('confirms with uppercase direct y and n keys', async () => { + const output = createMemoryOutput(); + + const accepted = await withPromptEnvironment( + { + input: createScriptedInput(['Y', Key.enter]), + output, + error: output, + interactive: true, + }, + () => confirm('Continue?', false), + ); + + const declined = await withPromptEnvironment( + { + input: createScriptedInput(['N', Key.enter]), + output, + error: output, + interactive: true, + }, + () => confirm('Continue?'), + ); + + expect(accepted).toBe(true); + expect(declined).toBe(false); + }); + + it('toggles confirm values with navigation keys', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.left, 'l', Key.enter]), + output, + error: output, + interactive: true, + }, + () => confirm('Continue?'), + ); + + expect(result).toBe(true); + expect(output.text()).toContain('\u001B[36m ┌\u001B[39m \u001B[36mContinue?\u001B[39m '); + expect(output.text()).toContain('\u001B[32m●\u001B[39m Yes'); + expect(output.text()).toContain('\u001B[32m●\u001B[39m No'); + expect(output.text()).toContain('┌ \u001B[2mContinue?\u001B[22m '); + }); + + it('renders custom confirm labels in active and submitted frames', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.enter]), + output, + error: output, + interactive: true, + }, + () => confirm('¿Listo?', true, 'Sí, por favor', 'No, gracias'), + ); + + expect(result).toBe(true); + expect(output.text()).toContain('Sí, por favor'); + expect(output.text()).toContain('No, gracias'); + expect(output.text()).toContain('┌ \u001B[2m¿Listo?\u001B[22m '); + }); + + it('renders cancelled confirm frames with the current value', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.ctrlC]), + output, + error: output, + interactive: true, + }, + () => confirm('Continue?'), + ); + + expect(result).toBe(false); + expect(output.text()).toContain('Cancelled.'); + expect(output.text()).toContain('\u001B[31m ┌\u001B[39m Continue? '); + expect(parseAnsiText(output.text())).toContain('○ Yes / ● No'); + }); + + it('reads line-mode confirm answers when raw keys are unavailable', async () => { + const output = createMemoryOutput(); + const questions: string[] = []; + + const accepted = await withPromptEnvironment( + { + input: { + async readLine(question) { + questions.push(question); + + return ' absolutely '; + }, + }, + output, + error: output, + interactive: true, + }, + () => confirm('Continue?', false, 'Absolutely', 'Nope'), + ); + + const declined = await withPromptEnvironment( + { + input: { + async readLine(question) { + questions.push(question); + + return 'no'; + }, + }, + output, + error: output, + interactive: true, + }, + () => confirm('Continue?'), + ); + + expect(accepted).toBe(true); + expect(declined).toBe(false); + expect(questions).toEqual(['? Continue? [y/N] ', '? Continue? [Y/n] ']); + }); + + it('returns and validates confirm defaults in non-interactive mode', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([]), + output, + error: output, + interactive: false, + }, + () => confirm('Continue?', false), + ); + + await expect( + withPromptEnvironment( + { + input: createScriptedInput([]), + output, + error: output, + interactive: false, + }, + () => confirm('Continue?', false, 'Yes', 'No', true), + ), + ).rejects.toThrow(PromptValidationError); + + expect(result).toBe(false); + expect(output.text()).toBe(''); + }); + + it('treats false confirm answers as invalid when required', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['n', Key.enter, 'y', Key.enter]), + output, + error: output, + interactive: true, + }, + () => confirm('Continue?', true, 'Yes', 'No', true), + ); + + expect(result).toBe(true); + expect(output.text()).toContain('Required.'); + expect(parseAnsiText(output.text())).not.toContain('│ No '); + expect(parseAnsiText(output.text())).toContain('│ Yes '); + }); + + it('transforms confirm answers before returning', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['y', Key.enter]), + output, + error: output, + interactive: true, + }, + () => confirm('Continue?', true, 'Yes', 'No', false, undefined, '', (value) => !value), + ); + + expect(result).toBe(false); + }); + + it('transforms non-interactive confirm defaults before validation and return', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + output, + error: output, + interactive: false, + }, + () => + confirm({ + message: 'Continue?', + default: true, + transform: (value) => !value, + validate: (value) => (value === false ? null : 'Unexpected value.'), + }), + ); + + expect(result).toBe(false); + }); + + it('selects with arrow keys and enter', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.enter]), + output, + error: output, + interactive: true, + }, + () => select({ message: 'Pick one', options: ['first', 'second'] }), + ); + + expect(result).toBe('second'); + expect(output.text()).toContain('Pick one'); + expect(output.text()).toContain('┌ \u001B[2mPick one\u001B[22m '); + expect(output.text()).toContain('│ second'); + }); + + it('rejects optional single select prompts', async () => { + const options = { + message: 'Pick one', + options: ['first', 'second'], + required: false, + } as unknown as SelectPromptOptions; + + await expect(select(options)).rejects.toThrow('Argument [required] must be true or a string.'); + }); + + it('returns keys from keyed select options', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.enter]), + output, + error: output, + interactive: true, + }, + () => select({ message: 'Pick one', options: { first: 'First', second: 'Second' } }), + ); + + expect(result).toBe('second'); + }); + + it('returns numeric keys from numeric keyed select options', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.enter]), + output, + error: output, + interactive: true, + }, + () => select({ message: 'Pick one', options: { 1: 'First', 2: 'Second' } }), + ); + + expect(result).toBe(2); + }); + + it('normalizes keyed select options through the validator layer', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.enter]), + output, + error: output, + interactive: true, + }, + () => select({ message: 'Pick one', options: { 1: 'First', second: 'Second' } }), + ); + + expect(result).toBe(1); + expect(output.text()).toContain('First'); + }); + + it('starts select prompts on the default value', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.enter]), + output, + error: output, + interactive: true, + }, + () => select({ message: 'Pick one', options: ['first', 'second', 'third'], default: 'third' }), + ); + + expect(result).toBe('third'); + expect(output.text()).toContain('\u001B[36m ┌\u001B[39m \u001B[36mPick one\u001B[39m '); + expect(output.text()).toContain('\u001B[36m›\u001B[39m \u001B[36m●\u001B[39m third'); + expect(output.text()).toContain('│ third'); + }); + + it('returns the default value for non-interactive select prompts', async () => { + const result = await withPromptEnvironment( + { + output: createMemoryOutput(), + error: createMemoryOutput(), + interactive: false, + }, + () => select({ message: 'Pick one', options: ['first', 'second'], default: 'second' }), + ); + + expect(result).toBe('second'); + }); + + it('transforms non-interactive select defaults before validation and return', async () => { + const result = await withPromptEnvironment( + { + output: createMemoryOutput(), + error: createMemoryOutput(), + interactive: false, + }, + () => + select({ + message: 'Pick one', + options: ['first', 'second'], + default: 'second', + transform: (value) => value.toUpperCase(), + validate: (value) => (value === 'SECOND' ? null : 'Unexpected value.'), + }), + ); + + expect(result).toBe('SECOND'); + }); + + it('rejects non-interactive select prompts without defaults', async () => { + await expect( + withPromptEnvironment( + { + output: createMemoryOutput(), + error: createMemoryOutput(), + interactive: false, + }, + () => select({ message: 'Pick one', options: ['first', 'second'] }), + ), + ).rejects.toThrow('Required.'); + }); + + it('renders a scrolling select window around the highlighted choice', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.down, Key.enter]), + output, + error: output, + interactive: true, + }, + () => select({ message: 'Pick one', options: ['first', 'second', 'third', 'fourth'], scroll: 3 }), + ); + + const rendered = parseAnsiText(output.text()); + + expect(result).toBe('third'); + expect(rendered).toContain('│ ○ second'); + expect(rendered).toContain('│ › ● third'); + expect(rendered).toContain('│ ○ fourth'); + expect(rendered).toContain('┃'); + expect(rendered).toContain('│'); + }); + + it('supports alternate select navigation keys', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.tab, 'l', Key.right, 'h', Key.enter]), + output, + error: output, + interactive: true, + }, + () => select({ message: 'Pick one', options: ['first', 'second', 'third'] }), + ); + + expect(result).toBe('third'); + }); + + it('supports home and end select navigation keys', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.end[0], Key.home[0], Key.enter]), + output, + error: output, + interactive: true, + }, + () => select({ message: 'Pick one', options: ['first', 'second', 'third'] }), + ); + + expect(result).toBe('first'); + }); + + it('supports control-line select navigation keys', async () => { + const output = createMemoryOutput(); + + const endResult = await withPromptEnvironment( + { + input: createScriptedInput([Key.ctrlE, Key.enter]), + output, + error: output, + interactive: true, + }, + () => select({ message: 'Pick one', options: ['first', 'second', 'third'] }), + ); + + const homeResult = await withPromptEnvironment( + { + input: createScriptedInput([Key.ctrlE, Key.ctrlA, Key.enter]), + output, + error: output, + interactive: true, + }, + () => select({ message: 'Pick one', options: ['first', 'second', 'third'] }), + ); + + expect(endResult).toBe('third'); + expect(homeResult).toBe('first'); + }); + + it('supports page select navigation keys', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.pageDown, Key.pageUp, Key.pageDown, Key.enter]), + output, + error: output, + interactive: true, + }, + () => select({ message: 'Pick one', options: ['first', 'second', 'third', 'fourth'], scroll: 2 }), + ); + + expect(result).toBe('third'); + }); + + it('normalizes fractional scroll sizes for select page navigation', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.pageDown, Key.enter]), + output, + error: output, + interactive: true, + }, + () => select({ message: 'Pick one', options: ['first', 'second', 'third', 'fourth'], scroll: 2.9 }), + ); + + const rendered = parseAnsiText(output.text()); + + expect(result).toBe('third'); + expect(rendered).toContain('│ › ● third'); + expect(rendered).toContain('│ ○ fourth'); + }); + + it('renders select info for the highlighted option', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.enter]), + output, + error: output, + interactive: true, + }, + () => select({ message: 'Pick one', options: ['first', 'second'], info: (value) => `About ${value ?? 'none'}` }), + ); + + expect(output.text()).toContain('About first'); + expect(output.text()).toContain('About second'); + }); + + it('supports label-first select helpers with info', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.enter]), + output, + error: output, + interactive: true, + }, + () => select('Pick one', ['first', 'second'], undefined, 5, undefined, '', true, undefined, (value) => `About ${value ?? 'none'}`), + ); + + expect(result).toBe('second'); + expect(output.text()).toContain('About first'); + expect(output.text()).toContain('About second'); + }); + + it('transforms selected values before validation and return', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.enter]), + output, + error: output, + interactive: true, + }, + () => + select({ + message: 'Pick one', + options: ['first', 'second'], + transform: (value) => value.toUpperCase(), + validate: (value) => (value === 'FIRST' ? null : 'Unexpected value.'), + }), + ); + + expect(result).toBe('FIRST'); + }); + + it('renders select submitted frames only after validation passes', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.enter, Key.down, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + select({ + message: 'Pick one', + options: ['first', 'second'], + validate: (value) => (value === 'second' ? null : 'Choose second.'), + }), + ); + + const rendered = parseAnsiText(output.text()); + + expect(result).toBe('second'); + expect(rendered).toContain('Choose second.'); + expect(rendered).not.toMatch(/^ │ first\s*│$/m); + expect(rendered).toMatch(/^ │ second\s*│$/m); + }); + + it('trims line-mode select answers before matching choices', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: { + async readLine(): Promise { + return ' 2 '; + }, + }, + output, + error: output, + interactive: true, + }, + () => select({ message: 'Pick one', options: ['first', 'second'] }), + ); + + expect(result).toBe('second'); + }); + + it('returns select defaults for empty line-mode answers', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: { + async readLine(): Promise { + return ''; + }, + }, + output, + error: output, + interactive: true, + }, + () => select({ message: 'Pick one', options: ['first', 'second'], default: 'second' }), + ); + + expect(result).toBe('second'); + }); + + it('returns select defaults when key input is exhausted', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([]), + output, + error: output, + interactive: true, + }, + () => select({ message: 'Pick one', options: ['first', 'second'], default: 'second' }), + ); + + expect(result).toBe('second'); + }); + + it('rejects invalid line-mode select answers before accepting defaults', async () => { + const output = createMemoryOutput(); + const answers = ['invalid', '']; + + const result = await withPromptEnvironment( + { + input: { + async readLine(): Promise { + return answers.shift() ?? ''; + }, + }, + output, + error: output, + interactive: true, + }, + () => select({ message: 'Pick one', options: ['first', 'second'], default: 'second' }), + ); + + expect(result).toBe('second'); + expect(output.text()).toContain('Please select a valid option.'); + }); + + it('rejects partial numeric line-mode select answers and retries', async () => { + const output = createMemoryOutput(); + const answers = ['1abc', '2']; + + const result = await withPromptEnvironment( + { + input: { + async readLine(): Promise { + return answers.shift() ?? ''; + }, + }, + output, + error: output, + interactive: true, + }, + () => select({ message: 'Pick one', options: ['first', 'second'] }), + ); + + expect(result).toBe('second'); + expect(output.text()).toContain('Please select a valid option.'); + }); + + it('cancels select prompts with the current highlighted option', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.ctrlC]), + output, + error: output, + interactive: true, + }, + () => select({ message: 'Pick one', options: ['first', 'second'] }), + ); + + expect(result).toBe('second'); + expect(output.text()).toContain('Cancelled.'); + expect(output.text()).toContain('\u001B[31m ┌\u001B[39m Pick one '); + expect(output.text()).toContain('\u001B[2m› ● \u001B[9msecond\u001B[29m'); + expect(parseAnsiText(output.text())).toContain('│ › ● second'); + }); + + it('toggles multiselect choices with the space bar', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.space, Key.down, Key.space, Key.enter]), + output, + error: output, + interactive: true, + }, + () => multiselect({ message: 'Pick many', options: ['first', 'second'] }), + ); + + expect(result).toEqual(['first', 'second']); + expect(output.text()).toContain('\u001B[36m ┌\u001B[39m \u001B[36mPick many\u001B[39m '); + expect(output.text()).toContain('\u001B[36m› ◼\u001B[39m first'); + expect(output.text()).toContain('┌ \u001B[2mPick many\u001B[22m '); + expect(output.text()).toContain('│ first'); + expect(output.text()).toContain('│ second'); + }); + + it('renders multiselect options as a checklist', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment( + { + input: createScriptedInput([Key.space, Key.enter]), + output, + error: output, + interactive: true, + }, + () => multiselect({ message: 'Pick many', options: ['first', 'second'] }), + ); + + expect(output.text()).toContain('\u001B[36m›\u001B[39m ◻ first'); + expect(output.text()).toContain(' \u001B[2m◻\u001B[22m \u001B[2msecond\u001B[22m'); + expect(output.text()).toContain('\u001B[36m› ◼\u001B[39m first'); + }); + + it('returns keys from keyed multiselect options', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.space, Key.down, Key.space, Key.enter]), + output, + error: output, + interactive: true, + }, + () => multiselect({ message: 'Pick many', options: { first: 'First', second: 'Second', third: 'Third' } }), + ); + + expect(result).toEqual(['second', 'third']); + }); + + it('starts multiselect prompts with default selected values', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.enter]), + output, + error: output, + interactive: true, + }, + () => multiselect({ message: 'Pick many', options: ['first', 'second'], default: ['second'] }), + ); + + expect(result).toEqual(['second']); + expect(output.text()).toContain('\u001B[36m ┌\u001B[39m \u001B[36mPick many\u001B[39m '); + expect(output.text()).toContain('│ second'); + }); + + it('returns an empty array for non-interactive multiselect prompts without defaults', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + output, + error: output, + interactive: false, + }, + () => multiselect({ message: 'Pick many', options: ['first', 'second'] }), + ); + + expect(result).toEqual([]); + }); + + it('transforms non-interactive multiselect defaults before validation and return', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + output, + error: output, + interactive: false, + }, + () => + multiselect({ + message: 'Pick many', + options: ['first', 'second'], + default: ['first'], + transform: (value) => [...value, 'second'], + validate: (value) => (value.length === 2 ? null : 'Unexpected value.'), + }), + ); + + expect(result).toEqual(['first', 'second']); + }); + + it('rejects invalid comma-separated line-mode multiselect answers and retries', async () => { + const output = createMemoryOutput(); + const answers = ['1,missing', '1,2']; + + const result = await withPromptEnvironment( + { + input: { + async readLine(): Promise { + return answers.shift() ?? ''; + }, + }, + output, + error: output, + interactive: true, + }, + () => multiselect({ message: 'Pick many', options: ['first', 'second'] }), + ); + + expect(result).toEqual(['first', 'second']); + expect(output.text()).toContain('Please select valid options.'); + }); + + it('rejects required non-interactive multiselect prompts without defaults', async () => { + const output = createMemoryOutput(); + + await expect( + withPromptEnvironment( + { + output, + error: output, + interactive: false, + }, + () => multiselect({ message: 'Pick many', options: ['first', 'second'], required: true }), + ), + ).rejects.toThrow(PromptValidationError); + }); + + it('requires multiselect choices when configured', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.enter, Key.space, Key.enter]), + output, + error: output, + interactive: true, + }, + () => multiselect({ message: 'Pick many', options: ['first', 'second'], required: true }), + ); + + expect(result).toEqual(['first']); + expect(output.text()).toContain('Required.'); + }); + + it('renders multiselect submitted frames only after validation passes', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.enter, Key.space, Key.enter]), + output, + error: output, + interactive: true, + }, + () => multiselect({ message: 'Pick many', options: ['first', 'second'], required: true }), + ); + + const rendered = parseAnsiText(output.text()); + + expect(result).toEqual(['first']); + expect(rendered).toContain('Required.'); + expect(rendered).not.toMatch(/^ │ None\s*│$/m); + expect(rendered).toMatch(/^ │ first\s*│$/m); + }); + + it('toggles all multiselect choices with ctrl-a', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.ctrlA, Key.enter]), + output, + error: output, + interactive: true, + }, + () => multiselect({ message: 'Pick many', options: ['first', 'second', 'third'] }), + ); + + expect(result).toEqual(['first', 'second', 'third']); + expect(output.text()).toContain('│ first'); + expect(output.text()).toContain('│ second'); + expect(output.text()).toContain('│ third'); + }); + + it('toggles only enabled multiselect choices with ctrl-a', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.ctrlA, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + multiselect({ + message: 'Pick many', + options: [ + { label: 'first', value: 'first' }, + { label: 'second', value: 'second', disabled: true }, + { label: 'third', value: 'third' }, + ], + }), + ); + + expect(result).toEqual(['first', 'third']); + expect(output.text()).toContain('│ first'); + expect(output.text()).toContain('│ third'); + expect(output.text()).not.toContain('│ second'); + }); + + it('renders multiselect info for the highlighted option', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.enter]), + output, + error: output, + interactive: true, + }, + () => multiselect({ message: 'Pick many', options: ['first', 'second'], info: (value) => `About ${value ?? 'none'}` }), + ); + + expect(output.text()).toContain('About first'); + expect(output.text()).toContain('About second'); + }); + + it('supports label-first multiselect helpers with info', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.space, Key.enter]), + output, + error: output, + interactive: true, + }, + () => multiselect('Pick many', ['first', 'second'], [], 5, false, undefined, 'Use the space bar to select options.', undefined, (value) => `About ${value ?? 'none'}`), + ); + + expect(result).toEqual(['second']); + expect(output.text()).toContain('About first'); + expect(output.text()).toContain('About second'); + }); + + it('renders multiselect selected counts for scrollable option lists', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment( + { + input: createScriptedInput([Key.space, Key.enter]), + output, + error: output, + interactive: true, + }, + () => multiselect({ message: 'Pick many', options: ['first', 'second', 'third'], scroll: 2 }), + ); + + expect(output.text()).toContain('0 selected'); + expect(output.text()).toContain('1 selected'); + expect(output.text()).toContain('┃'); + expect(output.text()).toContain('│'); + }); + + it('supports page multiselect navigation keys', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.pageDown, Key.space, Key.pageUp, Key.space, Key.enter]), + output, + error: output, + interactive: true, + }, + () => multiselect({ message: 'Pick many', options: ['first', 'second', 'third', 'fourth'], scroll: 2 }), + ); + + expect(result).toEqual(['third', 'first']); + }); + + it('supports control-key multiselect navigation', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.ctrlN, Key.ctrlN, Key.ctrlP, Key.space, Key.enter]), + output, + error: output, + interactive: true, + }, + () => multiselect({ message: 'Pick many', options: ['first', 'second', 'third'] }), + ); + + expect(result).toEqual(['second']); + }); + + it('ignores end-of-line control navigation for multiselect prompts', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.ctrlE, Key.space, Key.enter]), + output, + error: output, + interactive: true, + }, + () => multiselect({ message: 'Pick many', options: ['first', 'second', 'third'] }), + ); + + expect(result).toEqual(['first']); + }); + + it('combines multiselect info with selected counts', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment( + { + input: createScriptedInput([Key.enter]), + output, + error: output, + interactive: true, + }, + () => multiselect({ message: 'Pick many', options: ['first', 'second', 'third'], scroll: 2, info: (value) => `About ${value ?? 'none'}` }), + ); + + expect(output.text()).toContain('About first · 0 selected'); + }); + + it('transforms multiselect values before validation and return', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.space, Key.down, Key.space, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + multiselect({ + message: 'Pick many', + options: ['first', 'second'], + transform: (value) => value.toReversed(), + validate: (value) => (value[0] === 'second' ? null : 'Unexpected order.'), + }), + ); + + expect(result).toEqual(['second', 'first']); + }); + + it('cancels multiselect prompts with the current marked options', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.space, Key.down, Key.ctrlC]), + output, + error: output, + interactive: true, + }, + () => multiselect({ message: 'Pick many', options: ['first', 'second'] }), + ); + + expect(result).toEqual(['first']); + expect(output.text()).toContain('Cancelled.'); + expect(output.text()).toContain('\u001B[31m ┌\u001B[39m Pick many '); + expect(output.text()).toContain('\u001B[2m ◼ \u001B[9mfirst\u001B[29m'); + expect(output.text()).toContain('\u001B[2m› ◻ \u001B[9msecond\u001B[29m'); + expect(parseAnsiText(output.text())).toContain('│ › ◻ second'); + }); +}); diff --git a/packages/tui/tests/environment.test.ts b/packages/tui/tests/environment.test.ts new file mode 100644 index 00000000..060aac5b --- /dev/null +++ b/packages/tui/tests/environment.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, it } from 'vitest'; +import { EventEmitter } from 'node:events'; +import { configurePrompts, createMemoryOutput, createScriptedInput, promptEnvironment, withPromptEnvironment } from '#tui/environment'; +import { readRawKey } from '#tui/environment/raw-key'; +import { Key } from '#tui/key'; + +class FakeRawInput extends EventEmitter { + isRaw = false; + isTTY = true; + paused = 0; + rawModes: boolean[] = []; + resumed = 0; + + pause(): void { + this.paused += 1; + } + + resume(): void { + this.resumed += 1; + } + + setRawMode(mode: boolean): void { + this.rawModes.push(mode); + this.isRaw = mode; + } +} + +describe('prompt environment', () => { + it('restores the previous environment when a scoped callback fails', async () => { + const output = createMemoryOutput(); + const error = createMemoryOutput(); + const previous = promptEnvironment(); + + configurePrompts({ output, error, interactive: false }); + + const scopedOutput = createMemoryOutput(); + + await expect( + withPromptEnvironment({ output: scopedOutput, interactive: true }, async () => { + expect(promptEnvironment().output).toBe(scopedOutput); + expect(promptEnvironment().error).toBe(error); + expect(promptEnvironment().interactive).toBe(true); + + throw new Error('failed'); + }), + ).rejects.toThrow('failed'); + + expect(promptEnvironment().output).toBe(output); + expect(promptEnvironment().error).toBe(error); + expect(promptEnvironment().interactive).toBe(false); + + configurePrompts(previous); + }); + + it('merges partial prompt configuration without replacing unspecified concerns', () => { + const previous = promptEnvironment(); + const output = createMemoryOutput(); + const error = createMemoryOutput(); + + configurePrompts({ output, error, interactive: false }); + + const nextOutput = createMemoryOutput(); + + configurePrompts({ output: nextOutput }); + + expect(promptEnvironment().output).toBe(nextOutput); + expect(promptEnvironment().error).toBe(error); + expect(promptEnvironment().interactive).toBe(false); + + configurePrompts(previous); + }); + + it('captures and clears memory output', () => { + const output = createMemoryOutput(); + + output.write('first'); + output.write(' second'); + + expect(output.text()).toBe('first second'); + + output.clear(); + + expect(output.text()).toBe(''); + }); + + it('drains scripted input independently for key and line reads', async () => { + const input = createScriptedInput(['a', 'line']); + + await expect(input.readKey?.()).resolves.toBe('a'); + + await expect(input.readLine?.('Question')).resolves.toBe('line'); + + await expect(input.readKey?.()).resolves.toBeNull(); + + await expect(input.readLine?.('Question')).resolves.toBe(''); + }); + + it('restores raw input mode after reading a key', async () => { + const input = new FakeRawInput(); + const key = readRawKey(input); + + expect(input.rawModes).toEqual([true]); + expect(input.resumed).toBe(1); + + input.emit('data', Buffer.from('x')); + + await expect(key).resolves.toBe('x'); + + expect(input.rawModes).toEqual([true, false]); + expect(input.paused).toBe(1); + expect(input.listenerCount('data')).toBe(0); + expect(input.listenerCount('end')).toBe(0); + expect(input.listenerCount('error')).toBe(0); + }); + + it('normalizes raw terminal key aliases', async () => { + const input = new FakeRawInput(); + const enter = readRawKey(input); + + input.emit('data', Buffer.from('\r')); + + await expect(enter).resolves.toBe(Key.enter); + + const backspaceInput = new FakeRawInput(); + const backspace = readRawKey(backspaceInput); + + backspaceInput.emit('data', Buffer.from('\u0008')); + + await expect(backspace).resolves.toBe(Key.backspace); + }); + + it('buffers split raw terminal escape sequences', async () => { + const input = new FakeRawInput(); + const key = readRawKey(input); + + input.emit('data', Buffer.from('\u001B')); + input.emit('data', Buffer.from('[')); + input.emit('data', Buffer.from('A')); + + await expect(key).resolves.toBe(Key.up); + + expect(input.rawModes).toEqual([true, false]); + }); + + it('returns buffered partial escape sequences when input ends', async () => { + const input = new FakeRawInput(); + const key = readRawKey(input); + + input.emit('data', Buffer.from('\u001B')); + input.emit('end'); + + await expect(key).resolves.toBe(Key.escape); + }); + + it('restores raw input mode when key input ends', async () => { + const input = new FakeRawInput(); + const key = readRawKey(input); + + input.emit('end'); + + await expect(key).resolves.toBeNull(); + + expect(input.rawModes).toEqual([true, false]); + expect(input.paused).toBe(1); + }); + + it('restores raw input mode when key input errors', async () => { + const input = new FakeRawInput(); + const key = readRawKey(input); + + input.emit('error', new Error('read failed')); + + await expect(key).rejects.toThrow('read failed'); + + expect(input.rawModes).toEqual([true, false]); + expect(input.paused).toBe(1); + }); +}); diff --git a/packages/tui/tests/form.test.ts b/packages/tui/tests/form.test.ts new file mode 100644 index 00000000..a81db692 --- /dev/null +++ b/packages/tui/tests/form.test.ts @@ -0,0 +1,1021 @@ +import { describe, expect, it } from 'vitest'; +import { confirm, createMemoryOutput, createScriptedInput, form, Key, outro, Stream, text, withPromptEnvironment } from '#tui/index'; + +describe('form builder', () => { + it('runs chained steps and returns positional responses', async () => { + const output = createMemoryOutput(); + + const responses = await withPromptEnvironment( + { + input: createScriptedInput(['A', 'd', 'a', Key.enter, Key.enter, Key.enter]), + output, + error: output, + interactive: true, + }, + () => form().text('Name').select('Runtime', ['TS', 'JS']).confirm('Active').submit(), + ); + + expect(responses[0]).toBe('Ada'); + expect(responses[1]).toBe('TS'); + expect(responses[2]).toBe(true); + }); + + it('keys named responses and passes prior responses and names to custom steps', async () => { + const output = createMemoryOutput(); + + const responses = await withPromptEnvironment( + { + input: createScriptedInput(['A', 'd', 'a', Key.enter]), + output, + error: output, + interactive: true, + }, + () => + form() + .text('Name', '', '', false, undefined, '', 'name') + .add((values, previous, name) => `${name ?? 'missing'}:${previous ?? 'new'}:Hello ${values.name}`, 'greeting') + .submit(), + ); + + expect(responses.name).toBe('Ada'); + expect(responses.greeting).toBe('greeting:new:Hello Ada'); + }); + + it('stores null for skipped conditional steps', async () => { + const responses = await withPromptEnvironment( + { + input: createScriptedInput(['n']), + output: createMemoryOutput(), + error: createMemoryOutput(), + interactive: true, + }, + () => + form() + .confirm('Include details?', false, 'Yes', 'No', false, undefined, '', 'include') + .addIf( + (values) => values.include === true, + () => 'details', + 'details', + ) + .submit(), + ); + + expect(responses.include).toBe(false); + expect(responses.details).toBeNull(); + }); + + it('awaits asynchronous conditional steps', async () => { + const responses = await withPromptEnvironment( + { + input: createScriptedInput(['y', Key.enter]), + output: createMemoryOutput(), + error: createMemoryOutput(), + interactive: true, + }, + () => + form() + .confirm('Include details?', true, 'Yes', 'No', false, undefined, '', 'include') + .addIf( + async (values) => values.include === true, + () => 'details', + 'details', + ) + .submit(), + ); + + expect(responses.include).toBe(true); + expect(responses.details).toBe('details'); + }); + + it('reverts to the previous form step and reuses the prior response', async () => { + const output = createMemoryOutput(); + + const responses = await withPromptEnvironment( + { + input: createScriptedInput(['A', Key.enter, 'B', Key.ctrlU, Key.backspace, 'C', Key.enter, 'D', Key.enter]), + output, + error: output, + interactive: true, + }, + () => form().text('First').text('Second').submit(), + ); + + expect(responses[0]).toBe('C'); + expect(responses[1]).toBe('D'); + }); + + it('reuses numeric previous responses when reverting prompt builder steps', async () => { + const output = createMemoryOutput(); + + const responses = await withPromptEnvironment( + { + input: createScriptedInput(['4', Key.enter, Key.ctrlU, '5', Key.enter, 'D', Key.enter]), + output, + error: output, + interactive: true, + }, + () => form().number('Count').text('Done').submit(), + ); + + expect(responses[0]).toBe(45); + expect(responses[1]).toBe('D'); + }); + + it('applies number transforms from form steps', async () => { + const output = createMemoryOutput(); + + const responses = await withPromptEnvironment( + { + input: createScriptedInput(['4', Key.enter]), + output, + error: output, + interactive: true, + }, + () => + form() + .number('Count', '', '', false, undefined, '', undefined, undefined, undefined, 'count', (value) => Number(value) * 2) + .submit(), + ); + + expect(responses.count).toBe(8); + }); + + it('preserves empty number defaults in form steps', async () => { + const output = createMemoryOutput(); + + const responses = await withPromptEnvironment( + { + input: createScriptedInput([Key.enter, Key.enter]), + output, + error: output, + interactive: true, + }, + () => form().number('Count', '', undefined, false, undefined, '', undefined, undefined, undefined, 'labelCount').number({ message: 'Size' }, 'objectCount').submit(), + ); + + expect(responses.labelCount).toBe(''); + expect(responses.objectCount).toBe(''); + }); + + it('runs object-option basic prompt form steps', async () => { + const output = createMemoryOutput(); + + const responses = await withPromptEnvironment( + { + input: createScriptedInput([Key.enter, Key.enter, Key.enter, Key.ctrlD]), + output, + error: output, + interactive: true, + }, + () => + form() + .text({ message: 'Name', default: 'Ada', transform: (value) => value.toUpperCase() }, 'name') + .number({ message: 'Count', default: 2, transform: (value) => Number(value) * 2 }, 'count') + .password({ message: 'Secret', default: 'shh', transform: (value) => `${value}!` }, 'secret') + .textarea({ message: 'Body', default: 'Line', rows: 3, transform: (value) => `${value}\nDone` }, 'body') + .submit(), + ); + + expect(responses.name).toBe('ADA'); + expect(responses.count).toBe(4); + expect(responses.secret).toBe('shh!'); + expect(responses.body).toBe('Line\nDone'); + }); + + it('runs object-option choice prompt form steps', async () => { + const output = createMemoryOutput(); + + const responses = await withPromptEnvironment( + { + input: createScriptedInput([Key.enter, Key.enter, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + form() + .confirm({ message: 'Continue?', default: false, transform: (value) => !value }, 'confirmed') + .select({ message: 'Color', options: { red: 'Red', green: 'Green' }, default: 'green', transform: (value) => value.toUpperCase() }, 'color') + .multiselect({ message: 'Colors', options: { red: 'Red', blue: 'Blue' }, default: ['red'], transform: (value) => [...value, 'blue'] }, 'colors') + .submit(), + ); + + expect(responses.confirmed).toBe(true); + expect(responses.color).toBe('GREEN'); + expect(responses.colors).toEqual(['red', 'blue']); + }); + + it('stores unnamed object-option confirm form steps positionally', async () => { + const output = createMemoryOutput(); + + const responses = await withPromptEnvironment( + { + input: createScriptedInput([Key.enter]), + output, + error: output, + interactive: true, + }, + () => form().confirm({ message: 'Continue?' }).submit(), + ); + + expect(responses[0]).toBe(true); + }); + + it('reuses array previous responses when reverting prompt builder steps', async () => { + const output = createMemoryOutput(); + + const responses = await withPromptEnvironment( + { + input: createScriptedInput([Key.space, Key.enter, Key.ctrlU, Key.enter, 'D', Key.enter]), + output, + error: output, + interactive: true, + }, + () => form().multiselect('Flags', ['alpha', 'beta']).text('Done').submit(), + ); + + expect(responses[0]).toEqual(['alpha']); + expect(responses[1]).toBe('D'); + }); + + it('reuses boolean previous responses when reverting confirm steps', async () => { + const output = createMemoryOutput(); + + const responses = await withPromptEnvironment( + { + input: createScriptedInput(['n', Key.enter, Key.ctrlU, Key.enter, 'D', Key.enter]), + output, + error: output, + interactive: true, + }, + () => form().confirm('Active?').text('Done').submit(), + ); + + expect(responses[0]).toBe(false); + expect(responses[1]).toBe('D'); + }); + + it('reverts past ignored side-effect steps', async () => { + const output = createMemoryOutput(); + + const responses = await withPromptEnvironment( + { + input: createScriptedInput(['A', Key.enter, 'B', Key.ctrlU, Key.backspace, 'C', Key.enter, 'D', Key.enter]), + output, + error: output, + interactive: true, + }, + () => form().text('First').note('Skipping back over this', null, 'note').text('Second').submit(), + ); + + expect(responses[0]).toBe('C'); + expect(responses.note).toBeNull(); + expect(responses[2]).toBe('D'); + }); + + it('will not skip over the first step when reverting', async () => { + const output = createMemoryOutput(); + + const responses = await withPromptEnvironment( + { + input: createScriptedInput([Key.ctrlU, Key.enter]), + output, + error: output, + interactive: true, + }, + () => form().info('This should not be skipped').confirm('Are you sure?').submit(), + ); + + expect(responses[0]).toBeNull(); + expect(responses[1]).toBe(true); + }); + + it('stops custom step execution at the moment of reverting', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment( + { + input: createScriptedInput(['2', '7', Key.enter, Key.down, Key.ctrlU, Key.enter, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + form() + .text('Age') + .add(async () => { + const confirmed = await confirm('Are you sure?'); + + if (!confirmed) { + outro('This should not appear!'); + } + }) + .submit(), + ); + + expect(output.text()).not.toContain('This should not appear!'); + }); + + it('re-evaluates conditional steps after reverting prior responses', async () => { + const output = createMemoryOutput(); + + const responses = await withPromptEnvironment( + { + input: createScriptedInput(['A', 'd', 'a', Key.enter, Key.down, Key.enter, Key.ctrlU, Key.up, Key.enter, '1', Key.enter, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + form() + .text('Name') + .select('Runtime', ['TS', 'JS']) + .addIf( + (values) => values[1] === 'TS', + () => text('Version'), + ) + .confirm('Are you sure?') + .submit(), + ); + + expect(responses[0]).toBe('Ada'); + expect(responses[1]).toBe('TS'); + expect(responses[2]).toBe('1'); + expect(responses[3]).toBe(true); + }); + + it('re-evaluates asynchronous conditional steps after reverting prior responses', async () => { + const output = createMemoryOutput(); + + const responses = await withPromptEnvironment( + { + input: createScriptedInput(['A', 'd', 'a', Key.enter, Key.down, Key.enter, Key.ctrlU, Key.up, Key.enter, '1', Key.enter, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + form() + .text('Name') + .select('Runtime', ['TS', 'JS']) + .addIf( + async (values) => values[1] === 'TS', + () => text('Version'), + ) + .confirm('Are you sure?') + .submit(), + ); + + expect(responses[0]).toBe('Ada'); + expect(responses[1]).toBe('TS'); + expect(responses[2]).toBe('1'); + expect(responses[3]).toBe(true); + }); + + it('uses declared defaults when named choice steps follow skipped conditional responses', async () => { + const output = createMemoryOutput(); + + const responses = await withPromptEnvironment( + { + input: createScriptedInput([Key.enter]), + output, + error: output, + interactive: true, + }, + () => + form() + .addIf(false, () => 'skipped', 'choice') + .select('Choice', { a: 'A', b: 'B' }, 'b', 5, undefined, '', true, 'choice') + .addIf(false, () => 'skipped', 'searched') + .search({ message: 'Search', options: { red: 'Red', blue: 'Blue' }, default: 'blue' }, 'searched') + .submit(), + ); + + expect(responses.choice).toBe('b'); + expect(responses.searched).toBe('blue'); + }); + + it('does not revert the first form step', async () => { + const output = createMemoryOutput(); + + const responses = await withPromptEnvironment( + { + input: createScriptedInput([Key.ctrlU, 'A', Key.enter]), + output, + error: output, + interactive: true, + }, + () => form().text('First').submit(), + ); + + expect(responses[0]).toBe('A'); + expect(output.text()).toContain('This cannot be reverted.'); + }); + + it('does not allow reverting a normal prompt after form submit', async () => { + const output = createMemoryOutput(); + + const confirmed = await withPromptEnvironment( + { + input: createScriptedInput(['A', Key.enter, Key.ctrlU, Key.enter]), + output, + error: output, + interactive: true, + }, + async () => { + await form().text('Name').submit(); + + return confirm('Ready?'); + }, + ); + + expect(confirmed).toBe(true); + expect(output.text()).toContain('This cannot be reverted.'); + }); + + it('runs suggest, search, and multisearch form steps', async () => { + const output = createMemoryOutput(); + + const responses = await withPromptEnvironment( + { + input: createScriptedInput(['b', Key.tab, Key.enter, 'g', Key.down, Key.enter, 'r', Key.down, Key.space, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + form() + .suggest('Suggested color', ['Red', 'Green', 'Blue'], '', '', 5, false, undefined, '', 'suggested') + .search( + { + message: 'Searched color', + options: (value) => { + const options = { green: 'Green', blue: 'Blue', red: 'Red' }; + + return Object.fromEntries(Object.entries(options).filter(([, label]) => label.toLowerCase().includes(value.toLowerCase()))); + }, + }, + 'searched', + ) + .multisearch( + { + message: 'Many colors', + options: (value) => { + const options = { red: 'Red', green: 'Green', blue: 'Blue' }; + + return Object.fromEntries(Object.entries(options).filter(([, label]) => label.toLowerCase().includes(value.toLowerCase()))); + }, + }, + 'many', + ) + .submit(), + ); + + expect(responses.suggested).toBe('Blue'); + expect(responses.searched).toBe('green'); + expect(responses.many).toEqual(['red']); + }); + + it('passes suggest placeholders and defaults from form steps', async () => { + const output = createMemoryOutput(); + + const responses = await withPromptEnvironment( + { + input: createScriptedInput([Key.enter]), + output, + error: output, + interactive: true, + }, + () => form().suggest('Suggested color', ['Red', 'Green', 'Blue'], 'Type a color', 'Blue', 5, false, undefined, '', 'suggested').submit(), + ); + + expect(responses.suggested).toBe('Blue'); + }); + + it('passes autocomplete placeholders from form steps', async () => { + const output = createMemoryOutput(); + + const responses = await withPromptEnvironment( + { + input: createScriptedInput([Key.enter]), + output, + error: output, + interactive: true, + }, + () => form().autocomplete('Auto color', ['Red', 'Green', 'Blue'], 'Type a color', '', false, undefined, '', 'auto').submit(), + ); + + expect(responses.auto).toBe(''); + expect(output.text()).toContain('Type a color'); + }); + + it('passes suggest and autocomplete info from form steps', async () => { + const output = createMemoryOutput(); + + const responses = await withPromptEnvironment( + { + input: createScriptedInput(['b', Key.down, Key.enter, 'g', Key.tab, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + form() + .suggest('Suggested color', ['Red', 'Green', 'Blue'], '', '', 5, false, undefined, '', 'suggested', undefined, (value) => `About suggested ${value ?? 'none'}`) + .autocomplete('Auto color', ['Red', 'Green', 'Blue'], '', '', false, undefined, '', 'auto', undefined, (value) => `About auto ${value ?? 'none'}`) + .submit(), + ); + + expect(responses.suggested).toBe('Blue'); + expect(responses.auto).toBe('Green'); + expect(output.text()).toContain('About suggested Blue'); + expect(output.text()).toContain('About auto Green'); + }); + + it('runs object-option suggest and autocomplete form steps', async () => { + const output = createMemoryOutput(); + + const responses = await withPromptEnvironment( + { + input: createScriptedInput([Key.enter, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + form() + .suggest({ message: 'Suggested color', options: ['Red', 'Green', 'Blue'], default: 'Blue' }, 'suggested') + .autocomplete({ message: 'Auto color', options: ['Red', 'Green', 'Blue'], default: 'Green' }, 'auto') + .submit(), + ); + + expect(responses.suggested).toBe('Blue'); + expect(responses.auto).toBe('Green'); + }); + + it('runs label-first search and multisearch form steps', async () => { + const output = createMemoryOutput(); + + const responses = await withPromptEnvironment( + { + input: createScriptedInput(['g', Key.down, Key.enter, 'r', Key.down, Key.space, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + form() + .search( + 'Searched color', + (value) => { + const options = { green: 'Green', blue: 'Blue', red: 'Red' }; + + return Object.fromEntries(Object.entries(options).filter(([, label]) => label.toLowerCase().includes(value.toLowerCase()))); + }, + '', + 5, + undefined, + '', + true, + 'searched', + ) + .multisearch( + 'Many colors', + (value) => { + const options = { red: 'Red', green: 'Green', blue: 'Blue' }; + + return Object.fromEntries(Object.entries(options).filter(([, label]) => label.toLowerCase().includes(value.toLowerCase()))); + }, + '', + 5, + false, + undefined, + 'Use the space bar to select options.', + 'many', + ) + .submit(), + ); + + expect(responses.searched).toBe('green'); + expect(responses.many).toEqual(['red']); + }); + + it('passes label-first choice info through form steps', async () => { + const output = createMemoryOutput(); + + const colorOptions = (value: string): Record => { + const options = { red: 'Red', green: 'Green', blue: 'Blue' }; + + return Object.fromEntries(Object.entries(options).filter(([, label]) => label.toLowerCase().includes(value.toLowerCase()))); + }; + + const responses = await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.enter, Key.down, Key.space, Key.enter, 'g', Key.down, Key.enter, 'r', Key.down, Key.space, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + form() + .select('Choice', ['first', 'second'], undefined, 5, undefined, '', true, 'choice', undefined, (value) => `About choice ${value ?? 'none'}`) + .multiselect('Choices', ['first', 'second'], [], 5, false, undefined, 'Use the space bar to select options.', 'choices', undefined, (value) => `About choices ${value ?? 'none'}`) + .search('Searched color', colorOptions, '', 5, undefined, '', true, 'searched', undefined, (value) => `About search ${value ?? 'none'}`) + .multisearch('Many colors', colorOptions, '', 5, false, undefined, 'Use the space bar to select options.', 'many', undefined, (value) => `About multisearch ${value ?? 'none'}`) + .submit(), + ); + + expect(responses.choice).toBe('second'); + expect(responses.choices).toEqual(['second']); + expect(responses.searched).toBe('green'); + expect(responses.many).toEqual(['red']); + expect(output.text()).toContain('About choice second'); + expect(output.text()).toContain('About choices second'); + expect(output.text()).toContain('About search green'); + expect(output.text()).toContain('About multisearch red'); + }); + + it('reuses previous search responses when reverting object-option form steps', async () => { + const output = createMemoryOutput(); + + const responses = await withPromptEnvironment( + { + input: createScriptedInput(['g', Key.down, Key.enter, Key.ctrlU]), + output, + error: output, + interactive: true, + }, + () => + form() + .search( + { + message: 'Searched color', + options: (value) => { + const options = { green: 'Green', blue: 'Blue', red: 'Red' }; + + return Object.fromEntries(Object.entries(options).filter(([, label]) => label.toLowerCase().includes(value.toLowerCase()))); + }, + }, + 'searched', + ) + .confirm('Done?') + .submit(), + ); + + expect(responses.searched).toBe('green'); + }); + + it('reuses previous multisearch responses when reverting object-option form steps', async () => { + const output = createMemoryOutput(); + + const responses = await withPromptEnvironment( + { + input: createScriptedInput(['r', Key.down, Key.space, Key.enter, Key.ctrlU]), + output, + error: output, + interactive: true, + }, + () => + form() + .multisearch( + { + message: 'Many colors', + options: (value) => { + const options = { red: 'Red', green: 'Green', blue: 'Blue' }; + + return Object.fromEntries(Object.entries(options).filter(([, label]) => label.toLowerCase().includes(value.toLowerCase()))); + }, + }, + 'many', + ) + .confirm('Done?') + .submit(), + ); + + expect(responses.many).toEqual(['red']); + }); + + it('runs password and textarea form steps', async () => { + const output = createMemoryOutput(); + + const responses = await withPromptEnvironment( + { + input: createScriptedInput(['s', 'e', 'c', 'r', 'e', 't', Key.enter, 'L', '1', Key.enter, 'L', '2', Key.ctrlD]), + output, + error: output, + interactive: true, + }, + () => form().password('Secret', '', false, undefined, '', 'secret').textarea('Body', '', '', false, undefined, '', 3, 'body').submit(), + ); + + expect(responses.secret).toBe('secret'); + expect(responses.body).toBe('L1\nL2'); + }); + + it('reuses password previous responses when reverting label-first form steps', async () => { + const output = createMemoryOutput(); + + const responses = await withPromptEnvironment( + { + input: createScriptedInput(['s', 'e', 'c', 'r', 'e', 't', Key.enter, Key.ctrlU, Key.enter, 'D', Key.enter]), + output, + error: output, + interactive: true, + }, + () => form().password('Secret').text('Done').submit(), + ); + + expect(responses[0]).toBe('secret'); + expect(responses[1]).toBe('D'); + }); + + it('runs task, pause, and stream form steps', async () => { + const output = createMemoryOutput(); + + const responses = await withPromptEnvironment( + { + input: createScriptedInput([Key.enter]), + output, + error: output, + interactive: true, + }, + () => + form() + .task( + 'Build', + (logger) => { + logger.log('done'); + + return 1; + }, + 10, + false, + '', + 'task', + ) + .pause('Continue') + .stream(['line one\n'], 'streamed') + .submit(), + ); + + expect(responses.task).toBe(1); + expect(responses[1]).toBe(true); + expect(responses.streamed).toBeNull(); + expect(output.text()).toContain('Build'); + expect(output.text()).toContain('line one'); + }); + + it('runs spin and progress form steps', async () => { + const output = createMemoryOutput(); + + const responses = await withPromptEnvironment( + { + output, + error: output, + }, + () => + form() + .spin(async () => 'spun', undefined, 'spin') + .progress('Files', [1, 2], (step) => Number(step) * 2, '', 'progress') + .submit(), + ); + + expect(responses.spin).toBe('spun'); + expect(responses.progress).toEqual([2, 4]); + expect(output.text()).toContain(' ⠶ '); + expect(output.text()).not.toContain('Loading'); + expect(output.text()).not.toContain('Done:'); + expect(output.text()).toContain('Files'); + expect(output.text()).toContain('2 / 2'); + }); + + it('runs root-aligned status overloads from form steps', async () => { + const output = createMemoryOutput(); + + const responses = await withPromptEnvironment( + { + output, + error: output, + }, + () => + form() + .spin('Loading', async () => 'spun', 'spin') + .task( + { + keepSummary: true, + task: (logger) => { + logger.info('done'); + + return 2; + }, + title: 'Build', + }, + 'task', + ) + .submit(), + ); + + expect(responses.spin).toBe('spun'); + expect(responses.task).toBe(2); + expect(output.text()).toContain('Loading'); + expect(output.text()).toContain('Build'); + expect(output.text()).toContain('done'); + }); + + it('creates manual progress bars from form steps', async () => { + const output = createMemoryOutput(); + + const responses = await withPromptEnvironment( + { + output, + error: output, + }, + () => form().progress(2, 'Manual', 'bar').submit(), + ); + + expect(responses.bar).toBeNull(); + expect(output.text()).toContain('Manual'); + expect(output.text()).toContain('0 / 2'); + }); + + it('creates manual streams from form steps', async () => { + const output = createMemoryOutput(); + + const responses = await withPromptEnvironment( + { + output, + error: output, + }, + async () => { + const values = await form().stream().submit(); + + const outputStream = values[0] as Stream; + + outputStream.write('hello'); + outputStream.close(); + + return values; + }, + ); + + expect(responses[0]).toBeInstanceOf(Stream); + expect((responses[0] as Stream).value()).toBe('hello'); + expect(output.text()).toContain('hello'); + expect(output.text()).toContain('\u001B[?25h'); + }); + + it('stores null for display-only output helper responses', async () => { + const output = createMemoryOutput(); + + const responses = await withPromptEnvironment( + { + output, + error: output, + }, + () => + form() + .note('Saved', null, 'note') + .table(['Name'], [['Ollin']], 'table') + .dataTable(['Alias'], [['Display']], 'dataTable') + .grid(['A', 'B'], 80, 'grid') + .title('Prompt', 'title') + .clear('clear') + .submit(), + ); + + expect(responses.note).toBeNull(); + expect(responses.table).toBeNull(); + expect(responses.dataTable).toBeNull(); + expect(responses.grid).toBeNull(); + expect(responses.title).toBeNull(); + expect(responses.clear).toBeNull(); + expect(output.text()).toContain('Ollin'); + expect(output.text()).toContain('Display'); + expect(output.text()).toContain('A'); + expect(output.text()).toContain('B'); + expect(output.text()).toContain('\u001B]0;Prompt\u0007'); + expect(output.text()).toContain('\u001B[H\u001B[J'); + }); + + it('stores null for named object-option table output form steps', async () => { + const output = createMemoryOutput(); + + const responses = await withPromptEnvironment( + { + output, + error: output, + }, + () => + form() + .table({ headers: ['Name'], rows: [['Ada']] }, 'tableOptions') + .dataTable({ headers: ['Alias'], rows: [['Display']] }, 'dataTableOptions') + .submit(), + ); + + expect(responses.tableOptions).toBeNull(); + expect(responses.dataTableOptions).toBeNull(); + expect(responses[0]).toBeUndefined(); + expect(output.text()).toContain('Ada'); + expect(output.text()).toContain('Display'); + }); + + it('runs label-first data table form steps', async () => { + const output = createMemoryOutput(); + + const responses = await withPromptEnvironment( + { + input: createScriptedInput(['/', 'slow', Key.enter, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + form() + .datatable( + ['Name', 'Tag'], + [ + { Name: 'Alpha', Tag: 'fast', value: 'alpha' }, + { Name: 'Beta', Tag: 'slow', value: 'beta' }, + ], + 10, + 'Pick project', + '', + false, + undefined, + (value) => String(value).toUpperCase(), + (query, row) => !Array.isArray(row) && row.Tag === query, + 'project', + ) + .submit(), + ); + + expect(responses.project).toBe('BETA'); + expect(output.text()).toContain('Pick project slow'); + expect(output.text()).toContain('Beta'); + }); + + it('reuses previous data table responses when reverting object-option form steps', async () => { + const output = createMemoryOutput(); + + const responses = await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.enter, Key.ctrlU, Key.enter, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + form() + .datatable( + { + message: 'Pick project', + rows: [ + { Name: 'Alpha', value: 'alpha' }, + { Name: 'Beta', value: 'beta' }, + ], + }, + 'project', + ) + .confirm('Done?') + .submit(), + ); + + expect(responses.project).toBe('beta'); + expect(responses[1]).toBe(true); + }); + + it('reuses previous data table responses when reverting label-first form steps', async () => { + const output = createMemoryOutput(); + + const responses = await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.enter, Key.ctrlU, Key.enter, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + form() + .datatable( + ['Name'], + [ + { Name: 'Alpha', value: 'alpha' }, + { Name: 'Beta', value: 'beta' }, + ], + 10, + 'Pick project', + '', + false, + undefined, + undefined, + undefined, + 'project', + ) + .confirm('Done?') + .submit(), + ); + + expect(responses.project).toBe('beta'); + expect(responses[1]).toBe(true); + }); +}); diff --git a/packages/tui/tests/key.test.ts b/packages/tui/tests/key.test.ts new file mode 100644 index 00000000..c8fbebd4 --- /dev/null +++ b/packages/tui/tests/key.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; +import { Key, keyFromEvent, oneOf } from '#tui/index'; + +describe('keyboard mapping', () => { + it('maps named keys', () => { + expect(keyFromEvent({ name: 'enter' })).toBe(Key.enter); + }); + + it('maps terminal key name aliases', () => { + expect(keyFromEvent({ name: 'return' })).toBe(Key.enter); + expect(keyFromEvent({ name: 'pageup' })).toBe(Key.pageUp); + expect(keyFromEvent({ name: 'pagedown' })).toBe(Key.pageDown); + }); + + it('maps space and newline sequences', () => { + expect(keyFromEvent({ sequence: ' ' })).toBe(Key.space); + expect(keyFromEvent({ sequence: '\n' })).toBe(Key.enter); + }); + + it('maps control-modified key names', () => { + expect(keyFromEvent({ ctrl: true, name: 'c' })).toBe(Key.ctrlC); + expect(keyFromEvent({ ctrl: true, name: 'n' })).toBe(Key.ctrlN); + expect(keyFromEvent({ ctrl: true, name: 'U' })).toBe(Key.ctrlU); + }); + + it('maps option backspace events', () => { + expect(keyFromEvent({ meta: true, name: 'backspace' })).toBe(Key.optionBackspace); + }); + + it('maps shifted tab events', () => { + expect(keyFromEvent({ name: 'tab', shift: true })).toBe(Key.shiftTab); + }); + + it('matches nested key variants', () => { + expect(oneOf([Key.home, Key.end], Key.home[2])).toBe(Key.home[2]); + expect(oneOf([Key.home, Key.end], Key.end[3])).toBe(Key.end[3]); + expect(oneOf([Key.home, Key.end], Key.enter)).toBeUndefined(); + }); + + it('matches scalar and grouped keys through the validator layer', () => { + expect(oneOf([Key.enter, Key.home], Key.enter)).toBe(Key.enter); + expect(oneOf([Key.enter, Key.home], Key.home[0])).toBe(Key.home[0]); + }); +}); diff --git a/packages/tui/tests/notify.test.ts b/packages/tui/tests/notify.test.ts new file mode 100644 index 00000000..77580184 --- /dev/null +++ b/packages/tui/tests/notify.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, expectTypeOf, it } from 'vitest'; +import { createMemoryOutput, notificationCommand, notificationCommands, notify, notifyForPlatform, withPromptEnvironment } from '#tui/index'; +import type { NotificationCommand } from '#tui/index'; + +describe('notify helper', () => { + it('builds macOS notification commands', () => { + const command = notificationCommand('darwin', 'Deploy', 'Done', 'Preview', 'Glass'); + + expect(command?.bin).toBe('osascript'); + expect(command?.args[0]).toBe('-e'); + expect(command?.args[1]).toContain('display notification "Done"'); + expect(command?.args[1]).toContain('with title "Deploy"'); + expect(command?.args[1]).toContain('subtitle "Preview"'); + expect(command?.args[1]).toContain('sound name "Glass"'); + }); + + it('builds Linux notification commands', () => { + expect(notificationCommand('linux', 'Deploy', 'Done')).toEqual({ + args: ['Deploy', 'Done'], + bin: 'notify-send', + }); + + expect(notificationCommand('linux', 'Deploy')).toEqual({ + args: ['Deploy'], + bin: 'notify-send', + }); + + expect(notificationCommand('linux', 'Deploy', 'Done', '', '', '/tmp/icon.png')).toEqual({ + args: ['--icon', '/tmp/icon.png', 'Deploy', 'Done'], + bin: 'notify-send', + }); + }); + + it('builds Linux fallback notification commands in availability order', () => { + expect(notificationCommands('linux', { title: 'Deploy', body: 'Done' })).toEqual([ + { + args: ['Deploy', 'Done'], + bin: 'notify-send', + }, + { + args: ['--passivepopup', 'Deploy: Done', '5', '--title', 'Deploy'], + bin: 'kdialog', + }, + ]); + }); + + it('falls back to note output on unsupported platforms', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + expect(notifyForPlatform('freebsd', 'Deploy', 'Done')).toBe(false); + }); + + expect(output.text()).toContain('Deploy: Done'); + }); + + it('does not expose a public notification return value', () => { + expectTypeOf(notify).returns.toBeVoid(); + }); + + it('executes the first available Linux notifier and reports the process result', () => { + const executed: NotificationCommand[] = []; + + const result = notifyForPlatform('linux', 'Deploy', 'Done', '', '', '', { + commandExists: (bin) => bin === 'kdialog', + execute: (command) => { + executed.push(command); + + return true; + }, + }); + + expect(result).toBe(true); + expect(executed).toEqual([ + { + args: ['--passivepopup', 'Deploy: Done', '5', '--title', 'Deploy'], + bin: 'kdialog', + }, + ]); + }); + + it('returns false when notification command execution fails', () => { + const result = notifyForPlatform('darwin', 'Deploy', 'Done', '', '', '', { + execute: () => false, + }); + + expect(result).toBe(false); + }); +}); diff --git a/packages/tui/tests/number.test.ts b/packages/tui/tests/number.test.ts new file mode 100644 index 00000000..07006d32 --- /dev/null +++ b/packages/tui/tests/number.test.ts @@ -0,0 +1,366 @@ +import { describe, expect, it } from 'vitest'; +import { createMemoryOutput, createScriptedInput, Key, number, parseAnsiText, PromptValidationError, withPromptEnvironment } from '#tui/index'; +import { parseNumberInput } from '#tui/prompts/number/validators/value'; + +describe('number prompt', () => { + it('rejects partial decimal input and accepts a retry', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['1', '2', 'a', Key.enter, '4', '2', Key.enter]), + output, + error: output, + interactive: true, + }, + () => number({ message: 'Amount' }), + ); + + expect(result).toBe(42); + expect(output.text()).toContain('Must be a number'); + }); + + it('casts decimal numeric values to integers', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['1', '.', '5', Key.enter]), + output, + error: output, + interactive: true, + }, + () => number({ message: 'Count', integer: true }), + ); + + expect(result).toBe(1); + }); + + it('returns an empty string for optional empty input', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.enter]), + output, + error: output, + interactive: true, + }, + () => number({ message: 'Count' }), + ); + + expect(result).toBe(''); + }); + + it('renders placeholders before number input', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment( + { + input: createScriptedInput([Key.enter]), + output, + error: output, + interactive: true, + }, + () => number('Count', '0'), + ); + + expect(output.text()).toContain('\u001B[36m ┌\u001B[39m \u001B[36mCount\u001B[39m '); + expect(parseAnsiText(output.text())).toContain('0 ▲▼'); + expect(output.text()).toContain('┌ \u001B[2mCount\u001B[22m '); + }); + + it('renders typed number values while reading raw key input', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['4', '2', Key.enter]), + output, + error: output, + interactive: true, + }, + () => number({ message: 'Count' }), + ); + + expect(result).toBe(42); + expect(parseAnsiText(output.text())).toContain('4 ▲▼'); + expect(parseAnsiText(output.text())).toContain('42 ▲▼'); + expect(output.text()).toContain('┌ \u001B[2mCount\u001B[22m '); + }); + + it('requires number input when configured', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.enter, '4', Key.enter]), + output, + error: output, + interactive: true, + }, + () => number({ message: 'Count', required: true }), + ); + + expect(result).toBe(4); + expect(output.text()).toContain('Required.'); + }); + + it('returns decimal input as an integer value', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['1', '.', '9', Key.enter]), + output, + error: output, + interactive: true, + }, + () => number({ message: 'Count' }), + ); + + expect(result).toBe(1); + }); + + it('applies transforms from positional number prompts', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['4', Key.enter]), + output, + error: output, + interactive: true, + }, + () => number('Count', '', '', false, undefined, '', undefined, undefined, undefined, (value) => Number(value) * 2), + ); + + expect(result).toBe(8); + }); + + it('parses numeric values as integers', () => { + expect(parseNumberInput('1.9')).toEqual({ value: 1 }); + expect(parseNumberInput('1.9', { integer: true })).toEqual({ value: 1 }); + expect(parseNumberInput('1', { integer: true })).toEqual({ value: 1 }); + }); + + it('validates typed values against min and max bounds', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['1', Key.enter, '5', Key.enter, '3', Key.enter]), + output, + error: output, + interactive: true, + }, + () => number({ message: 'Count', min: 2, max: 4 }), + ); + + expect(result).toBe(3); + expect(output.text()).toContain('Must be at least 2'); + expect(output.text()).toContain('Must be less than 4'); + expect(output.text().split('┌ \u001B[2mCount\u001B[22m').length - 1).toBe(1); + }); + + it('increments and decrements with arrow keys', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.up, Key.up, Key.down, Key.enter]), + output, + error: output, + interactive: true, + }, + () => number({ message: 'Count', step: 2 }), + ); + + expect(result).toBe(1); + expect(parseAnsiText(output.text())).toContain('1 ▲▼'); + }); + + it('uses the max value when decrementing from an empty number input', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.enter]), + output, + error: output, + interactive: true, + }, + () => number({ message: 'Count', max: 9 }), + ); + + expect(result).toBe(9); + expect(parseAnsiText(output.text())).toContain('9 ▲▼'); + expect(output.text()).toContain('\u001B[2m▲\u001B[22m▼'); + }); + + it('does not increment or decrement with control navigation keys', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.ctrlP, Key.ctrlP, Key.ctrlN, Key.enter]), + output, + error: output, + interactive: true, + }, + () => number({ message: 'Count', step: 2 }), + ); + + expect(result).toBe(''); + expect(parseAnsiText(output.text())).not.toContain('1 ▲▼'); + }); + + it('increments and decrements decimal values with whole steps', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.up, Key.down, Key.down, Key.enter]), + output, + error: output, + interactive: true, + }, + () => number({ message: 'Amount', default: 1.5, step: 0.25 }), + ); + + expect(result).toBe(0); + expect(parseAnsiText(output.text())).toContain('2 ▲▼'); + expect(parseAnsiText(output.text())).toContain('0 ▲▼'); + }); + + it('falls back to a whole step for invalid step sizes', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.up, Key.enter]), + output, + error: output, + interactive: true, + }, + () => number({ message: 'Amount', default: 1.5, step: 0 }), + ); + + expect(result).toBe(2); + }); + + it('renders default number values before input', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.enter]), + output, + error: output, + interactive: true, + }, + () => number({ message: 'Count', default: 7 }), + ); + + expect(result).toBe(7); + expect(parseAnsiText(output.text())).toContain('7 ▲▼'); + expect(output.text()).toContain('┌ \u001B[2mCount\u001B[22m '); + }); + + it('returns an empty string for non-interactive number prompts without defaults', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + output, + error: output, + interactive: false, + }, + () => number({ message: 'Count' }), + ); + + expect(result).toBe(''); + }); + + it('rejects required non-interactive number prompts without defaults', async () => { + const output = createMemoryOutput(); + + await expect( + withPromptEnvironment( + { + output, + error: output, + interactive: false, + }, + () => number({ message: 'Count', required: true }), + ), + ).rejects.toThrow(PromptValidationError); + }); + + it('transforms non-interactive number defaults before validation and return', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + output, + error: output, + interactive: false, + }, + () => + number({ + message: 'Count', + default: 2, + transform: (value) => Number(value) * 2, + validate: (value) => (value === 4 ? null : 'Unexpected value.'), + }), + ); + + expect(result).toBe(4); + }); + + it('renders cancelled number frames with the current value', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['4', Key.ctrlC]), + output, + error: output, + interactive: true, + }, + () => number({ message: 'Count' }), + ); + + expect(result).toBe(4); + expect(output.text()).toContain('Cancelled.'); + expect(output.text()).toContain('\u001B[31m ┌\u001B[39m Count '); + expect(parseAnsiText(output.text())).toContain('4'); + }); + + it('clamps arrow key changes to min and max', async () => { + const output = createMemoryOutput(); + + const increased = await withPromptEnvironment( + { + input: createScriptedInput([Key.up, Key.up, Key.enter]), + output, + error: output, + interactive: true, + }, + () => number({ message: 'Count', max: 3, min: 2, step: 2 }), + ); + + const decreased = await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.down, Key.enter]), + output, + error: output, + interactive: true, + }, + () => number({ message: 'Count', max: 3, min: 2, step: 2 }), + ); + + expect(increased).toBe(3); + expect(decreased).toBe(2); + }); +}); diff --git a/packages/tui/tests/output.test.ts b/packages/tui/tests/output.test.ts new file mode 100644 index 00000000..32d4c91e --- /dev/null +++ b/packages/tui/tests/output.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from 'vitest'; +import { alert, createMemoryOutput, error, grid, info, intro, note, outro, parseAnsiText, table, warning, withPromptEnvironment } from '#tui/index'; +import { renderGrid } from '#tui/output/grid'; + +describe('output helpers', () => { + it('renders note-style helpers without returning a value', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + expect(note('Hello')).toBeUndefined(); + expect(error('Nope')).toBeUndefined(); + expect(warning('Careful')).toBeUndefined(); + expect(alert('Heads up')).toBeUndefined(); + expect(info('Facts')).toBeUndefined(); + expect(intro('Start')).toBeUndefined(); + expect(outro('Done')).toBeUndefined(); + }); + + expect(output.text()).toContain('Hello'); + expect(output.text()).toContain('Done'); + }); + + it('renders plain multiline notes without symbol prefixes', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + note('Hello\nWorld'); + }); + + expect(output.text()).toBe(' Hello\n World\n'); + }); + + it('renders typed note helpers with line-oriented styling', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + warning('Careful'); + error('Nope'); + alert('Heads up'); + info('Facts'); + intro('Start\nLonger'); + outro('Done'); + }); + + expect(parseAnsiText(output.text())).toBe(' Careful\n Nope\n Heads up \n Facts\n Start \n Longer \n Done \n'); + expect(output.text()).toContain('\u001B[33m Careful\u001B[39m'); + expect(output.text()).toContain('\u001B[31m Nope\u001B[39m'); + expect(output.text()).toContain('\u001B[41m\u001B[37m Heads up \u001B[39m\u001B[49m'); + expect(output.text()).toContain('\u001B[32m Facts\u001B[39m'); + expect(output.text()).toContain('\u001B[46m\u001B[30m Start \u001B[39m\u001B[49m'); + }); + + it('pads intro and outro lines by visible width', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + intro('東京\nA'); + }); + + expect(parseAnsiText(output.text())).toBe(' 東京 \n A \n'); + }); + + it('renders table and boxed grid helpers without returning a value', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + expect(table(['Name'], [['Ollin']])).toBeUndefined(); + expect(grid(['A', 'B'], 80)).toBeUndefined(); + }); + + expect(output.text()).toContain('| Name'); + expect(output.text()).toContain('┌───┬───┐'); + expect(output.text()).toContain('│ A │ B │'); + }); + + it('keeps grid rendering finite for invalid widths', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + expect(grid(['A', 'B'], 0)).toBeUndefined(); + expect(grid(['C', 'D'], -2)).toBeUndefined(); + }); + + expect(output.text()).toContain('│ A │'); + expect(output.text()).toContain('│ B │'); + expect(output.text()).toContain('│ C │'); + expect(output.text()).toContain('│ D │'); + }); + + it('normalizes fractional grid widths without overlapping boxed rows', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + expect(grid(['A', 'B', 'C', 'D'], 2.5)).toBeUndefined(); + }); + + expect(output.text()).toBe(' ┌───┐\n │ A │\n ├───┤\n │ B │\n ├───┤\n │ C │\n ├───┤\n │ D │\n └───┘\n'); + }); + + it('renders balanced grid rows with table separators', () => { + expect(renderGrid(['component-alpha', 'api-client', 'theme-box', 'status-task', 'prompt-input', 'stream-log'], 50)).toBe( + [ + ' ┌─────────────────┬─────────────┐', + ' │ component-alpha │ api-client │', + ' ├─────────────────┼─────────────┤', + ' │ theme-box │ status-task │', + ' ├─────────────────┼─────────────┤', + ' │ prompt-input │ stream-log │', + ' └─────────────────┴─────────────┘', + ].join('\n'), + ); + }); + + it('does not render empty grids', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + grid([]); + }); + + expect(output.text()).toBe(''); + }); +}); diff --git a/packages/tui/tests/password.test.ts b/packages/tui/tests/password.test.ts new file mode 100644 index 00000000..e668349e --- /dev/null +++ b/packages/tui/tests/password.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, it } from 'vitest'; +import { createMemoryOutput, createScriptedInput, Key, password, PromptValidationError, withPromptEnvironment } from '#tui/index'; + +describe('password prompt', () => { + it('returns typed values without rendering the raw password', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['s', 'e', 'c', 'r', 'e', 't', Key.enter]), + output, + error: output, + interactive: true, + }, + () => password('Password'), + ); + + expect(result).toBe('secret'); + expect(output.text()).not.toContain('secret'); + expect(output.text()).toContain('••••••'); + expect(output.text()).toContain('\u001B[36m ┌\u001B[39m \u001B[36mPassword\u001B[39m '); + expect(output.text()).toContain('┌ \u001B[2mPassword\u001B[22m '); + }); + + it('renders password placeholders before input', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment( + { + input: createScriptedInput([Key.enter]), + output, + error: output, + interactive: true, + }, + () => password('Password', 'Required'), + ); + + expect(output.text()).toContain('\u001B[36m ┌\u001B[39m \u001B[36mPassword\u001B[39m '); + expect(output.text()).toContain('\u001B[2mRequired\u001B[22m'); + }); + + it('returns object-option defaults without rendering the raw value', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.enter]), + output, + error: output, + interactive: true, + }, + () => password({ message: 'Password', default: 'secret' }), + ); + + expect(result).toBe('secret'); + expect(output.text()).not.toContain('secret'); + expect(output.text()).toContain('••••••'); + }); + + it('edits object-option defaults without rendering the raw value', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.backspace, 'x', Key.enter]), + output, + error: output, + interactive: true, + }, + () => password({ message: 'Password', default: 'secret' }), + ); + + expect(result).toBe('secrex'); + expect(output.text()).not.toContain('secret'); + expect(output.text()).not.toContain('secrex'); + expect(output.text()).toContain('•••••'); + expect(output.text()).toContain('••••••'); + }); + + it('transforms values before returning them', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['s', 'e', 'c', 'r', 'e', 't', Key.enter]), + output, + error: output, + interactive: true, + }, + () => password({ message: 'Password', transform: (value) => value.toUpperCase() }), + ); + + expect(result).toBe('SECRET'); + expect(output.text()).not.toContain('secret'); + }); + + it('retries after validation errors without rendering the raw value', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['p', 'a', 's', Key.enter, 's', Key.enter]), + output, + error: output, + interactive: true, + }, + () => password({ message: 'Password', validate: (value) => (value.length < 4 ? 'Password must be at least 4 characters.' : '') }), + ); + + expect(result).toBe('pass'); + expect(output.text()).toContain('Password must be at least 4 characters.'); + expect(output.text()).not.toContain('pas'); + expect(output.text()).not.toContain('pass'); + expect(output.text().split('┌ \u001B[2mPassword\u001B[22m').length - 1).toBe(1); + }); + + it('edits values with backspace and delete keys without rendering the raw value', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['p', 'a', 'z', Key.backspace, 's', 's', Key.left, Key.delete, Key.enter]), + output, + error: output, + interactive: true, + }, + () => password('Password'), + ); + + expect(result).toBe('pas'); + expect(output.text()).not.toContain('pas'); + expect(output.text()).toContain('•••'); + }); + + it('renders cancellation without exposing the raw value', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['s', 'e', 'c', Key.ctrlC]), + output, + error: output, + interactive: true, + }, + () => password('Password'), + ); + + expect(result).toBe('sec'); + expect(output.text()).toContain('Cancelled.'); + expect(output.text()).toContain('\u001B[31m ┌\u001B[39m Password '); + expect(output.text()).toContain('•••'); + expect(output.text()).not.toContain('sec'); + }); + + it('returns empty strings and validates required values in non-interactive mode', async () => { + await expect( + withPromptEnvironment( + { + output: createMemoryOutput(), + error: createMemoryOutput(), + interactive: false, + }, + () => password('Password'), + ), + ).resolves.toBe(''); + + await expect( + withPromptEnvironment( + { + output: createMemoryOutput(), + error: createMemoryOutput(), + interactive: false, + }, + () => password({ message: 'Password', required: true }), + ), + ).rejects.toThrow(PromptValidationError); + }); +}); diff --git a/packages/tui/tests/pause.test.ts b/packages/tui/tests/pause.test.ts new file mode 100644 index 00000000..ed25ca1f --- /dev/null +++ b/packages/tui/tests/pause.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; +import { createMemoryOutput, createScriptedInput, Key, pause, withPromptEnvironment } from '#tui/index'; + +describe('pause prompt', () => { + it('waits for enter from key-driven input', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['x', Key.enter]), + output, + error: output, + interactive: true, + }, + () => pause('Continue'), + ); + + expect(result).toBe(true); + expect(output.text()).toContain('\u001B[36m ┌\u001B[39m \u001B[36mContinue\u001B[39m '); + expect(output.text().endsWith('\n')).toBe(true); + }); + + it('falls back to line input', async () => { + const output = createMemoryOutput(); + + let asked = ''; + + const result = await withPromptEnvironment( + { + input: { + async readLine(message: string): Promise { + asked = message; + + return ''; + }, + }, + output, + error: output, + interactive: true, + }, + () => pause('Continue'), + ); + + expect(result).toBe(true); + expect(asked).toContain('Continue'); + }); + + it('returns false when the configured environment is non-interactive', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.enter]), + output, + error: output, + interactive: false, + }, + () => pause('This should not be rendered'), + ); + + expect(result).toBe(false); + expect(output.text()).not.toContain('This should not be rendered'); + }); +}); diff --git a/packages/tui/tests/progress.test.ts b/packages/tui/tests/progress.test.ts new file mode 100644 index 00000000..1d115824 --- /dev/null +++ b/packages/tui/tests/progress.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, it } from 'vitest'; +import { EventEmitter } from 'node:events'; +import { createMemoryOutput, Progress, progress, withPromptEnvironment } from '#tui/index'; + +describe('progress helper', () => { + it('maps iterable steps and returns callback results', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment({ output, error: output }, async () => { + return progress('Uppercasing States', ['Alabama', 'Alaska'], (state) => String(state).toUpperCase()); + }); + + expect(result).toEqual(['ALABAMA', 'ALASKA']); + expect(output.text()).toContain('Uppercasing States'); + expect(output.text()).toContain('2 / 2'); + expect(output.text()).toContain('\u001B[?25l'); + expect(output.text()).toContain('\u001B[?25h'); + }); + + it('passes the current count to progress callbacks before advancing', async () => { + const output = createMemoryOutput(); + + const counts = await withPromptEnvironment({ output, error: output }, async () => { + return progress('Adding States', ['Alabama', 'Alaska'], (_state, bar) => bar.current()); + }); + + expect(counts).toEqual([0, 1]); + }); + + it('supports manual progress updates with label and hint changes', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + const bar = progress('Adding States', 2); + + bar.start(); + bar.label('ALABAMA').hint('alabama').advance(); + bar.label('ALASKA').hint('alaska').advance(); + bar.finish(); + }); + + expect(output.text()).toContain('Adding States'); + expect(output.text()).toContain('ALABAMA'); + expect(output.text()).toContain('alaska'); + expect(output.text()).toContain('\u001B[1A\u001B[2K'); + }); + + it('renders visible progress for small non-zero percentages', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + const bar = progress('Adding States', 100); + + bar.start(); + bar.advance(); + bar.finish(); + }); + + expect(output.text()).toContain('█'); + expect(output.text()).toContain('1 / 100'); + }); + + it('renders a boxed progress bar with formatted fractions', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + const bar = progress('Adding States', 1000); + + bar.start(); + bar.finish(); + }); + + expect(output.text()).toContain(' ┌ Adding States '); + expect(output.text()).toContain(' │ │'); + expect(output.text()).toContain('└─────────────────────────────────────────────────── 0 / 1,000 ┘'); + }); + + it('returns a completion value separately from the current count', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + const bar = progress('Adding States', 2); + + bar.start(); + bar.advance(); + + expect(bar.current()).toBe(1); + expect(bar.value()).toBe(true); + + bar.finish(); + }); + }); + + it('clamps manual progress updates to the valid range', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + const bar = progress('Adding States', 2); + + bar.advance(3); + expect(bar.current()).toBe(2); + + bar.advance(-5); + expect(bar.current()).toBe(0); + + bar.finish(); + }); + + expect(output.text()).toContain('2 / 2'); + expect(output.text()).toContain('0 / 2'); + }); + + it('normalizes fractional totals and ignores non-finite advance values', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + const bar = progress(2.9, 'Adding States'); + + expect(bar.total).toBe(2); + + bar.advance(Number.NaN); + + expect(bar.current()).toBe(0); + + bar.finish(); + }); + + expect(output.text()).toContain('0 / 2'); + }); + + it('rejects invalid numeric progress step counts before rendering', () => { + expect(() => progress('Adding States', -1)).toThrow('Progress bar must have at least one item.'); + expect(() => progress('Adding States', Number.POSITIVE_INFINITY)).toThrow('Progress bar must have at least one item.'); + }); + + it('renders an error frame before rethrowing callback failures', async () => { + const output = createMemoryOutput(); + + await expect(async () => { + await withPromptEnvironment({ output, error: output }, async () => { + await progress('Adding States', ['Alabama'], () => { + throw new Error('failed'); + }); + }); + }).rejects.toThrow('failed'); + + expect(output.text()).toContain('Adding States'); + expect(output.text()).toContain('0 / 1'); + expect(output.text()).toContain('\u001B[1A\u001B[2K'); + expect(output.text()).toContain('\u001B[?25h'); + }); + + it('restores progress terminal state when process signals are received', async () => { + const output = createMemoryOutput(); + const signals = new EventEmitter(); + + await withPromptEnvironment({ output, error: output }, async () => { + const bar = new Progress(2, 'Adding States', '', signals); + + bar.start(); + + expect(signals.listenerCount('SIGINT')).toBe(1); + expect(signals.listenerCount('SIGTERM')).toBe(1); + + signals.emit('SIGINT'); + + expect(signals.listenerCount('SIGINT')).toBe(0); + expect(signals.listenerCount('SIGTERM')).toBe(0); + }); + + expect(output.text()).toContain('\u001B[?25l'); + expect(output.text()).toContain('\u001B[?25h'); + expect(output.text()).toContain('\u001B[1A\u001B[2K'); + }); +}); diff --git a/packages/tui/tests/prompt.test.ts b/packages/tui/tests/prompt.test.ts new file mode 100644 index 00000000..f949ba1f --- /dev/null +++ b/packages/tui/tests/prompt.test.ts @@ -0,0 +1,145 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { createMemoryOutput, withPromptEnvironment } from '#tui/environment'; +import { createScriptedInput } from '#tui/environment/scripted-input'; +import { Key } from '#tui/key'; +import { ask, cancelUsing, ensureRequired, promptUntilValid, PromptValidationError, validationMessage } from '#tui/prompt'; +import { text } from '#tui/prompts/basic'; + +describe('prompt validation', () => { + afterEach(() => { + cancelUsing(null); + }); + + it('uses the default required message for empty values', () => { + expect(ensureRequired('', true)).toBe('Required.'); + expect(ensureRequired([], true)).toBe('Required.'); + expect(ensureRequired(false, true)).toBe('Required.'); + expect(ensureRequired(null, true)).toBe('Required.'); + }); + + it('accepts non-empty required arrays', () => { + expect(ensureRequired(['choice'], true)).toBeUndefined(); + }); + + it('uses custom required messages and treats empty strings as required configuration', () => { + expect(ensureRequired('', 'Choose something.')).toBe('Choose something.'); + expect(ensureRequired('', '')).toBe('Required.'); + }); + + it('ignores required validation when explicitly disabled', () => { + expect(ensureRequired('', false)).toBeUndefined(); + expect(ensureRequired('', undefined)).toBeUndefined(); + }); + + it('accepts null, undefined, and empty string validator results', async () => { + await expect(validationMessage('value', () => null)).resolves.toBeUndefined(); + + await expect(validationMessage('value', () => undefined)).resolves.toBeUndefined(); + + await expect(validationMessage('value', () => '')).resolves.toBeUndefined(); + }); + + it('rejects invalid validator results', async () => { + await expect(validationMessage('value', () => false as never)).rejects.toThrow('The validator must return a string or null.'); + }); + + it('retries when a prompt reader throws a validation error', async () => { + const output = createMemoryOutput(); + const attempts: number[] = []; + + const result = await withPromptEnvironment( + { + output, + error: output, + interactive: true, + }, + () => + promptUntilValid({ message: 'Name' }, async (attempt) => { + attempts.push(attempt); + + if (attempt === 0) { + throw new PromptValidationError('Try again.'); + } + + return 'Ada'; + }), + ); + + expect(result).toBe('Ada'); + expect(attempts).toEqual([0, 1]); + expect(output.text()).toContain('Try again.'); + }); + + it('retries required validation failures in interactive mode', async () => { + const output = createMemoryOutput(); + const values = ['', 'Ada']; + + const result = await withPromptEnvironment( + { + output, + error: output, + interactive: true, + }, + () => promptUntilValid({ message: 'Name', required: true }, async () => values.shift() ?? ''), + ); + + expect(result).toBe('Ada'); + expect(output.text()).toContain('Required.'); + expect(output.text()).toContain('\u001B[31m! Required.\u001B[39m'); + }); + + it('rejects missing line input support', async () => { + await withPromptEnvironment( + { + input: {}, + output: createMemoryOutput(), + error: createMemoryOutput(), + interactive: true, + }, + async () => { + await expect(ask('Name')).rejects.toThrow('The configured prompt input cannot read lines.'); + }, + ); + }); + + it('uses a custom cancellation return value after rendering the cancelled state', async () => { + const output = createMemoryOutput(); + + cancelUsing(() => 'Manual'); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.ctrlC]), + output, + error: output, + interactive: true, + }, + () => text('Name'), + ); + + expect(result).toBe('Manual'); + expect(output.text()).toContain('Cancelled.'); + }); + + it('propagates custom cancellation errors after rendering the cancelled state', async () => { + const output = createMemoryOutput(); + + cancelUsing(() => { + throw new Error('Stopped.'); + }); + + await expect( + withPromptEnvironment( + { + input: createScriptedInput([Key.ctrlC]), + output, + error: output, + interactive: true, + }, + () => text('Name'), + ), + ).rejects.toThrow('Stopped.'); + + expect(output.text()).toContain('Cancelled.'); + }); +}); diff --git a/packages/tui/tests/search.test.ts b/packages/tui/tests/search.test.ts new file mode 100644 index 00000000..f30e7772 --- /dev/null +++ b/packages/tui/tests/search.test.ts @@ -0,0 +1,968 @@ +import { describe, expect, it } from 'vitest'; +import { createMemoryOutput, createScriptedInput, Key, multisearch, parseAnsiText, PromptValidationError, search, withPromptEnvironment } from '#tui/index'; +import type { SearchPromptOptions } from '#tui/index'; + +const colors = (value: string): Record => { + const options = { + red: 'Red', + green: 'Green', + blue: 'Blue', + }; + + return Object.fromEntries(Object.entries(options).filter(([, label]) => label.toLowerCase().includes(value.toLowerCase()))); +}; + +describe('search prompt', () => { + it('returns keys from associative options', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['u', 'e', Key.down, Key.enter]), + output, + error: output, + interactive: true, + }, + () => search({ message: 'Favorite color?', options: colors }), + ); + + expect(result).toBe('blue'); + expect(output.text()).toContain('\u001B[36m ┌\u001B[39m \u001B[36mFavorite color?\u001B[39m '); + expect(parseAnsiText(output.text())).toContain('│ ue'); + expect(output.text()).toContain('┌ \u001B[2mFavorite color?\u001B[22m '); + expect(output.text()).toContain('│ Blue'); + }); + + it('validates selected values and allows another selection', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.enter, Key.down, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + search({ + message: 'Favorite color?', + options: () => ({ red: 'Red', green: 'Green', blue: 'Blue' }), + validate: (value) => (value === 'red' ? 'Please choose green.' : null), + }), + ); + + expect(result).toBe('green'); + expect(output.text()).toContain('Please choose green.'); + expect(parseAnsiText(output.text())).not.toMatch(/^ │ Red\s*│$/m); + expect(parseAnsiText(output.text())).toMatch(/^ │ Green\s*│$/m); + }); + + it('renders the typed query while navigating results', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment( + { + input: createScriptedInput(['r', Key.down, Key.enter]), + output, + error: output, + interactive: true, + }, + () => search({ message: 'Favorite color?', options: colors }), + ); + + expect(parseAnsiText(output.text())).toContain('│ r'); + expect(output.text()).toContain('\u001B[36m›\u001B[39m Red'); + expect(output.text()).toContain('\u001B[2mGreen\u001B[22m'); + }); + + it('does not render an active search row before navigation highlights a result', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment( + { + input: createScriptedInput([Key.ctrlC]), + output, + error: output, + interactive: true, + }, + () => search({ message: 'Favorite color?', options: colors, default: 'red' }), + ); + + expect(output.text()).not.toContain('›'); + expect(output.text()).toContain('\u001B[2mRed\u001B[22m'); + }); + + it('respects scroll windows when rendering results', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.down, Key.down, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + search({ + message: 'Pick number', + options: ['one', 'two', 'three', 'four'], + scroll: 3, + }), + ); + + expect(result).toBe('three'); + expect(parseAnsiText(output.text())).toContain('│ two'); + expect(parseAnsiText(output.text())).toContain('│ › three'); + expect(parseAnsiText(output.text())).toContain('│ four'); + expect(output.text()).toContain('┃'); + expect(output.text()).toContain('│'); + }); + + it('supports page search navigation keys', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.pageDown, Key.pageUp, Key.pageDown, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + search({ + message: 'Pick number', + options: ['one', 'two', 'three', 'four'], + scroll: 2, + }), + ); + + expect(result).toBe('three'); + expect(output.text()).toContain('│ three'); + }); + + it('supports control-key search navigation', async () => { + const output = createMemoryOutput(); + + const forwardResult = await withPromptEnvironment( + { + input: createScriptedInput([Key.ctrlN, Key.ctrlN, Key.enter]), + output, + error: output, + interactive: true, + }, + () => search({ message: 'Favorite color?', options: colors }), + ); + + const backwardResult = await withPromptEnvironment( + { + input: createScriptedInput([Key.ctrlP, Key.enter]), + output, + error: output, + interactive: true, + }, + () => search({ message: 'Favorite color?', options: colors }), + ); + + expect(forwardResult).toBe('green'); + expect(backwardResult).toBe('blue'); + }); + + it('renders search info for the highlighted result', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.enter]), + output, + error: output, + interactive: true, + }, + () => search({ message: 'Favorite color?', options: colors, info: (value) => `About ${value ?? 'none'}` }), + ); + + expect(output.text()).toContain('About red'); + }); + + it('supports label-first search helpers with info', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.enter]), + output, + error: output, + interactive: true, + }, + () => search('Favorite color?', colors, '', 5, undefined, '', true, undefined, (value) => `About ${value ?? 'none'}`), + ); + + expect(result).toBe('red'); + expect(output.text()).toContain('About red'); + }); + + it('renders an empty result message for unmatched search queries', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment( + { + input: createScriptedInput(['z', Key.ctrlC]), + output, + error: output, + interactive: true, + }, + () => search({ message: 'Favorite color?', options: colors, default: 'red' }), + ); + + expect(parseAnsiText(output.text())).toContain('No results.'); + expect(output.text()).toContain('\u001B[2m No results.\u001B[22m'); + }); + + it('skips disabled search results while navigating', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + search({ + message: 'Favorite color?', + options: [ + { label: 'Red', value: 'red', disabled: true }, + { label: 'Green', value: 'green' }, + ], + }), + ); + + expect(result).toBe('green'); + }); + + it('keeps search highlights empty when submitting without a highlighted result', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.enter, Key.down, Key.enter]), + output, + error: output, + interactive: true, + }, + () => search({ message: 'Favorite color?', options: colors }), + ); + + expect(result).toBe('red'); + }); + + it('clears search highlights with horizontal navigation keys', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.left, Key.enter, Key.down, Key.enter]), + output, + error: output, + interactive: true, + }, + () => search({ message: 'Favorite color?', options: colors }), + ); + + expect(result).toBe('red'); + }); + + it('cancels search prompts with the current highlighted result', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.ctrlC]), + output, + error: output, + interactive: true, + }, + () => search({ message: 'Favorite color?', options: colors }), + ); + + expect(result).toBe('red'); + expect(output.text()).toContain('Cancelled.'); + expect(output.text()).toContain('\u001B[31m ┌\u001B[39m \u001B[2mFavorite color?\u001B[22m '); + expect(parseAnsiText(output.text())).toContain('⚠ Cancelled.'); + }); + + it('uses the default when cancelling a disabled highlighted search result', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.ctrlC]), + output, + error: output, + interactive: true, + }, + () => + search({ + message: 'Favorite color?', + options: [ + { label: 'Red', value: 'red', disabled: true }, + { label: 'Green', value: 'green' }, + ], + default: 'green', + }), + ); + + expect(result).toBe('green'); + expect(output.text()).toContain('Cancelled.'); + }); + + it('rejects optional single search prompts', async () => { + const options = { + message: 'Favorite color?', + options: colors, + required: false, + } as unknown as SearchPromptOptions; + + await expect(search(options)).rejects.toThrow('Argument [required] must be true or a string.'); + }); + + it('transforms search values before validation and return', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + search({ + message: 'Favorite color?', + options: colors, + transform: (value) => value.toUpperCase(), + validate: (value) => (value === 'RED' ? null : 'Unexpected value.'), + }), + ); + + expect(result).toBe('RED'); + }); + + it('returns search defaults for empty line-mode answers', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: { + async readLine(): Promise { + return ''; + }, + }, + output, + error: output, + interactive: true, + }, + () => + search({ + message: 'Favorite color?', + options: colors, + default: 'green', + }), + ); + + expect(result).toBe('green'); + }); + + it('returns search defaults for empty key-mode submissions', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.enter]), + output, + error: output, + interactive: true, + }, + () => + search({ + message: 'Favorite color?', + options: colors, + default: 'green', + }), + ); + + expect(result).toBe('green'); + expect(parseAnsiText(output.text())).toMatch(/^ │ Green\s*│$/m); + }); + + it('transforms non-interactive search defaults before validation and return', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + output, + error: output, + interactive: false, + }, + () => + search({ + message: 'Favorite color?', + options: colors, + default: 'green', + transform: (value) => value.toUpperCase(), + validate: (value) => (value === 'GREEN' ? null : 'Unexpected value.'), + }), + ); + + expect(result).toBe('GREEN'); + }); + + it('trims line-mode search answers before matching choices', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: { + async readLine(): Promise { + return ' Green '; + }, + }, + output, + error: output, + interactive: true, + }, + () => search({ message: 'Favorite color?', options: colors }), + ); + + expect(result).toBe('green'); + }); + + it('rejects disabled exact line-mode matches and retries', async () => { + const output = createMemoryOutput(); + const answers = ['Red', 'Green']; + + const result = await withPromptEnvironment( + { + input: { + async readLine(): Promise { + return answers.shift() ?? ''; + }, + }, + output, + error: output, + interactive: true, + }, + () => + search({ + message: 'Favorite color?', + options: [ + { label: 'Red', value: 'red', disabled: true }, + { label: 'Green', value: 'green' }, + ], + }), + ); + + expect(result).toBe('green'); + expect(output.text()).toContain('Please select a valid option.'); + }); +}); + +describe('multisearch prompt', () => { + it('toggles highlighted search results', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['V', Key.down, Key.space, Key.backspace, 'G', 'r', Key.down, Key.space, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + multisearch({ + message: 'Favorite colors?', + options: (value) => { + const options = { green: 'Green', violet: 'Violet' }; + + return Object.fromEntries(Object.entries(options).filter(([, label]) => label.toLowerCase().includes(value.toLowerCase()))); + }, + }), + ); + + expect(result).toEqual(['violet', 'green']); + expect(output.text()).toContain('\u001B[36m› ◼\u001B[39m Violet'); + expect(output.text()).toContain('1 selected'); + expect(output.text()).toContain('2 selected'); + expect(output.text()).toContain('┌ \u001B[2mFavorite colors?\u001B[22m '); + expect(output.text()).toContain('│ Violet'); + expect(output.text()).toContain('│ Green'); + }); + + it('starts multisearch prompts with default selected values', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.enter]), + output, + error: output, + interactive: true, + }, + () => + multisearch({ + message: 'Favorite colors?', + options: colors, + default: ['green'], + }), + ); + + expect(result).toEqual(['green']); + expect(output.text()).toContain('1 selected'); + expect(output.text()).toContain('│ Green'); + }); + + it('returns selected defaults when multisearch input is exhausted', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([]), + output, + error: output, + interactive: true, + }, + () => + multisearch({ + message: 'Favorite colors?', + options: colors, + default: ['green'], + }), + ); + + expect(result).toEqual(['green']); + expect(output.text()).toContain('│ Green'); + }); + + it('returns an empty array for non-interactive multisearch prompts without defaults', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + output, + error: output, + interactive: false, + }, + () => multisearch({ message: 'Favorite colors?', options: colors }), + ); + + expect(result).toEqual([]); + }); + + it('transforms non-interactive multisearch defaults before validation and return', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + output, + error: output, + interactive: false, + }, + () => + multisearch({ + message: 'Favorite colors?', + options: colors, + default: ['green'], + transform: (value) => [...value, 'red'], + validate: (value) => (value.length === 2 ? null : 'Unexpected value.'), + }), + ); + + expect(result).toEqual(['green', 'red']); + }); + + it('rejects disabled line-mode multisearch choices and retries', async () => { + const output = createMemoryOutput(); + const answers = ['Red', 'Green']; + + const result = await withPromptEnvironment( + { + input: { + async readLine(): Promise { + return answers.shift() ?? ''; + }, + }, + output, + error: output, + interactive: true, + }, + () => + multisearch({ + message: 'Favorite colors?', + options: [ + { label: 'Red', value: 'red', disabled: true }, + { label: 'Green', value: 'green' }, + ], + }), + ); + + expect(result).toEqual(['green']); + expect(output.text()).toContain('Please select valid options.'); + }); + + it('rejects required non-interactive multisearch prompts without defaults', async () => { + const output = createMemoryOutput(); + + await expect( + withPromptEnvironment( + { + output, + error: output, + interactive: false, + }, + () => multisearch({ message: 'Favorite colors?', options: colors, required: true }), + ), + ).rejects.toThrow(PromptValidationError); + }); + + it('renders multisearch submitted frames only after validation passes', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.enter, Key.down, Key.space, Key.enter]), + output, + error: output, + interactive: true, + }, + () => multisearch({ message: 'Favorite colors?', options: colors, required: true }), + ); + + const rendered = parseAnsiText(output.text()); + + expect(result).toEqual(['red']); + expect(rendered).toContain('Required.'); + expect(rendered).not.toMatch(/^ │ None\s*│$/m); + expect(rendered).toMatch(/^ │ Red\s*│$/m); + }); + + it('renders multisearch info for the highlighted result', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.enter]), + output, + error: output, + interactive: true, + }, + () => multisearch({ message: 'Favorite colors?', options: colors, info: (value) => `About ${value ?? 'none'}` }), + ); + + expect(output.text()).toContain('About red'); + expect(output.text()).toContain('0 selected'); + }); + + it('supports label-first multisearch helpers with info', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.space, Key.enter]), + output, + error: output, + interactive: true, + }, + () => multisearch('Favorite colors?', colors, '', 5, false, undefined, 'Use the space bar to select options.', undefined, (value) => `About ${value ?? 'none'}`), + ); + + expect(result).toEqual(['red']); + expect(output.text()).toContain('About red'); + expect(output.text()).toContain('1 selected'); + }); + + it('renders an empty result message for unmatched multisearch queries', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment( + { + input: createScriptedInput(['z', Key.enter]), + output, + error: output, + interactive: true, + }, + () => multisearch({ message: 'Favorite colors?', options: colors }), + ); + + expect(parseAnsiText(output.text())).toContain('No results.'); + expect(output.text()).toContain('\u001B[2m No results.\u001B[22m'); + }); + + it('renders hidden selected counts when multisearch results are filtered', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.space, 'B', Key.enter]), + output, + error: output, + interactive: true, + }, + () => multisearch({ message: 'Favorite colors?', options: colors }), + ); + + expect(output.text()).toContain('1 selected (1 hidden)'); + }); + + it('keeps default multisearch labels while filtered out', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['B', Key.enter]), + output, + error: output, + interactive: true, + }, + () => + multisearch({ + message: 'Favorite colors?', + options: colors, + default: ['green'], + }), + ); + + expect(result).toEqual(['green']); + expect(output.text()).toContain('1 selected (1 hidden)'); + expect(output.text()).toContain('│ Green'); + }); + + it('cancels multisearch prompts with the current selected values', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.space, 'B', Key.ctrlC]), + output, + error: output, + interactive: true, + }, + () => multisearch({ message: 'Favorite colors?', options: colors }), + ); + + expect(result).toEqual(['red']); + expect(output.text()).toContain('Cancelled.'); + expect(output.text()).toContain('\u001B[31m ┌\u001B[39m \u001B[2mFavorite colors?\u001B[22m '); + expect(output.text()).toContain('\u001B[9m\u001B[2mB\u001B[22m\u001B[29m'); + expect(parseAnsiText(output.text())).toContain('│ B'); + }); + + it('toggles all current multisearch results with ctrl-a', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.ctrlA, Key.enter]), + output, + error: output, + interactive: true, + }, + () => multisearch({ message: 'Favorite colors?', options: colors }), + ); + + expect(result).toEqual(['red', 'green', 'blue']); + expect(output.text()).toContain('3 selected'); + expect(output.text()).toContain('│ Red'); + expect(output.text()).toContain('│ Green'); + expect(output.text()).toContain('│ Blue'); + }); + + it('does not navigate multisearch results with ctrl-n or ctrl-p', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.ctrlN, Key.space, Key.enter]), + output, + error: output, + interactive: true, + }, + () => multisearch({ message: 'Favorite colors?', options: colors }), + ); + + expect(result).toEqual([]); + expect(output.text()).not.toContain('Selected: Green'); + }); + + it('supports tab and reverse-tab multisearch navigation', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.tab, Key.tab, Key.shiftTab, Key.space, Key.enter]), + output, + error: output, + interactive: true, + }, + () => multisearch({ message: 'Favorite colors?', options: colors }), + ); + + expect(result).toEqual(['red']); + }); + + it('keeps selected multisearch values visible after clearing the query', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['V', Key.down, Key.space, Key.backspace, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + multisearch({ + message: 'Favorite colors?', + options: (value) => { + if (value === '') { + return { green: 'Green' }; + } + + return { violet: 'Violet' }; + }, + }), + ); + + const latestFrame = output.text().split('Favorite colors?\n').at(-1) ?? ''; + + expect(result).toEqual(['violet']); + expect(latestFrame).toContain('Violet'); + expect(latestFrame).toContain('Green'); + }); + + it('supports home and end multisearch navigation keys', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.end[0], Key.space, Key.home[0], Key.space, Key.enter]), + output, + error: output, + interactive: true, + }, + () => multisearch({ message: 'Favorite colors?', options: colors }), + ); + + expect(result).toEqual(['blue', 'red']); + expect(output.text()).toContain('2 selected'); + expect(output.text()).toContain('│ Blue'); + expect(output.text()).toContain('│ Red'); + }); + + it('ignores end-of-line control navigation while multisearch results are highlighted', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.ctrlE, Key.space, Key.enter]), + output, + error: output, + interactive: true, + }, + () => multisearch({ message: 'Favorite colors?', options: colors }), + ); + + expect(result).toEqual(['red']); + expect(output.text()).toContain('1 selected'); + expect(output.text()).toContain('│ Red'); + }); + + it('supports page multisearch navigation keys', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.pageDown, Key.space, Key.pageUp, Key.space, Key.enter]), + output, + error: output, + interactive: true, + }, + () => multisearch({ message: 'Favorite colors?', options: colors, scroll: 2 }), + ); + + expect(result).toEqual(['blue', 'red']); + expect(output.text()).toContain('2 selected'); + expect(output.text()).toContain('│ Blue'); + expect(output.text()).toContain('│ Red'); + expect(output.text()).toContain('┃'); + expect(output.text()).toContain('│'); + }); + + it('skips disabled multisearch results while navigating and toggling all', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.ctrlA, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + multisearch({ + message: 'Favorite colors?', + options: [ + { label: 'Red', value: 'red', disabled: true }, + { label: 'Green', value: 'green' }, + { label: 'Blue', value: 'blue' }, + ], + }), + ); + + expect(result).toEqual(['green', 'blue']); + expect(output.text()).toContain('2 selected'); + expect(output.text()).toContain('│ Green'); + expect(output.text()).toContain('│ Blue'); + }); + + it('transforms multisearch values before validation and return', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.ctrlA, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + multisearch({ + message: 'Favorite colors?', + options: colors, + transform: (value) => value.toReversed(), + validate: (value) => (value[0] === 'blue' ? null : 'Unexpected order.'), + }), + ); + + expect(result).toEqual(['blue', 'green', 'red']); + }); + + it('trims line-mode multisearch answers before matching choices', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: { + async readLine(): Promise { + return ' Green '; + }, + }, + output, + error: output, + interactive: true, + }, + () => multisearch({ message: 'Favorite colors?', options: colors }), + ); + + expect(result).toEqual(['green']); + }); +}); diff --git a/packages/tui/tests/spinner.test.ts b/packages/tui/tests/spinner.test.ts new file mode 100644 index 00000000..42547afe --- /dev/null +++ b/packages/tui/tests/spinner.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest'; +import { createMemoryOutput, spin, withPromptEnvironment } from '#tui/index'; +import { renderSpinnerFrame, spinnerFrame } from '#tui/status/spinner/render'; + +describe('spinner helper', () => { + it('runs callback-first spinner callbacks', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment({ output, error: output }, async () => { + return spin(async () => 'done', { message: 'Working' }); + }); + + expect(result).toBe('done'); + expect(output.text()).toContain('\u001B[?25l'); + expect(output.text()).toContain('\u001B[1A\u001B[2K'); + expect(output.text()).toContain('\u001B[?25h'); + expect(output.text()).toContain(' ⠶ Working'); + expect(output.text()).not.toContain('Done: Working'); + }); + + it('runs callback-first spinner callbacks without a default message', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment({ output, error: output }, async () => { + return spin(async () => 'done'); + }); + + expect(result).toBe('done'); + expect(output.text()).toContain(' ⠶ '); + expect(output.text()).not.toContain('Loading'); + }); + + it('runs label-first spinner callbacks', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment({ output, error: output }, async () => { + return spin('Working', async () => 'done'); + }); + + expect(result).toBe('done'); + expect(output.text()).toContain(' ⠶ Working'); + expect(output.text()).not.toContain('Done: Working'); + }); + + it('rethrows spinner errors without synthetic failure output', async () => { + const output = createMemoryOutput(); + const failure = new Error('failed'); + + await expect( + withPromptEnvironment({ output, error: output }, async () => { + await spin('Working', async () => { + throw failure; + }); + }), + ).rejects.toBe(failure); + + expect(output.text()).toContain(' ⠶ Working'); + expect(output.text()).toContain('\u001B[1A\u001B[2K'); + expect(output.text()).toContain('\u001B[?25h'); + expect(output.text()).not.toContain('Failed: Working'); + expect(output.text()).not.toContain('Done: Working'); + }); + + it('renders deterministic animated spinner frames', () => { + expect(Array.from({ length: 10 }, (_, index) => spinnerFrame(index))).toEqual(['⠂', '⠒', '⠐', '⠰', '⠠', '⠤', '⠄', '⠆', '⠂', '⠒']); + expect(renderSpinnerFrame('Working', 3)).toBe(' ⠰ Working\n'); + expect(renderSpinnerFrame('Working')).toBe(' ⠶ Working\n'); + }); +}); diff --git a/packages/tui/tests/stream.test.ts b/packages/tui/tests/stream.test.ts new file mode 100644 index 00000000..3c99f3c9 --- /dev/null +++ b/packages/tui/tests/stream.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from 'vitest'; +import { createMemoryOutput, parseAnsiText, stream, withPromptEnvironment } from '#tui/index'; +import { streamFadeStyles } from '#tui/status/stream/fade'; +import { renderStreamFrame } from '#tui/status/stream/render'; + +describe('stream helper', () => { + it('appends streamed content and returns the accumulated value', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + const outputStream = stream(); + + outputStream.append('hello'); + outputStream.write(' world'); + outputStream.close(); + + expect(outputStream.value()).toBe('hello world'); + expect(outputStream.lines()).toEqual(['hello world']); + expect(outputStream.closed()).toBe(true); + }); + + expect(output.text()).toContain('\u001B[?25l'); + expect(output.text()).toContain('\u001B[?25h'); + expect(parseAnsiText(output.text())).toContain(' hello world'); + }); + + it('pipes iterable content through a stateful stream', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + await stream(['one', '\n', 'two']); + }); + + expect(parseAnsiText(output.text())).toContain(' one'); + expect(parseAnsiText(output.text())).toContain(' two'); + }); + + it('closes piped streams after the source is exhausted', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + const outputStream = stream(); + + await outputStream.pipe(['done']); + + expect(outputStream.closed()).toBe(true); + expect(outputStream.value()).toBe('done'); + expect(() => outputStream.write(' again')).toThrow('Stream is closed.'); + }); + + expect(output.text()).toContain('\u001B[?25h'); + }); + + it('rejects writes after close without losing buffered content', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + const outputStream = stream(); + + outputStream.write('finished'); + outputStream.close(); + + expect(() => outputStream.append(' later')).toThrow('Stream is closed.'); + expect(outputStream.value()).toBe('finished'); + }); + + expect(parseAnsiText(output.text())).toContain(' finished'); + }); + + it('cannot be prompted for input', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + const outputStream = stream(); + + expect(() => outputStream.prompt()).toThrow('Stream cannot be prompted'); + }); + }); + + it('wraps rendered stream lines with a leading gutter', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + const outputStream = stream(); + + outputStream.write('a '.repeat(40).trimEnd()); + + expect(outputStream.lines().length).toBeGreaterThan(1); + }); + + expect(output.text()).toContain('\n '); + }); + + it('flushes pending stream chunks before closing', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + const outputStream = stream(); + + for (let index = 0; index < 12; index += 1) { + outputStream.append(String(index)); + } + + outputStream.close(); + + expect(outputStream.value()).toBe('01234567891011'); + expect(outputStream.closed()).toBe(true); + }); + + expect(parseAnsiText(output.text())).toContain(' 01234567891011'); + }); + + it('renders pending stream chunks with fallback fade styles', () => { + const frame = renderStreamFrame({ + fading: ['fresh', ' fading'], + fadeStyles: streamFadeStyles({ trueColor: false }), + value: 'stable ', + }); + + expect(parseAnsiText(frame)).toBe(' stable fresh fading\n'); + expect(frame).toContain('stable fresh\u001B[2m fading\u001B[22m'); + }); + + it('renders true-color stream fade styles', () => { + const frame = renderStreamFrame({ + fading: ['fresh', ' fading'], + fadeStyles: streamFadeStyles({ + background: [0, 0, 0], + foreground: [100, 50, 0], + steps: 2, + trueColor: true, + }), + value: '', + }); + + expect(frame).toContain('\u001B[38;2;100;50;0mfresh\u001B[0m'); + expect(frame).toContain('\u001B[38;2;50;25;0m fading\u001B[0m'); + }); +}); diff --git a/packages/tui/tests/strings.test.ts b/packages/tui/tests/strings.test.ts new file mode 100644 index 00000000..e7e6f3c2 --- /dev/null +++ b/packages/tui/tests/strings.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; +import { parseAnsiSegments, parseAnsiText, truncate, visibleWidth, wrap } from '#tui/index'; + +describe('string utilities', () => { + it('measures visible width without ANSI control sequences', () => { + expect(visibleWidth('\u001B[31mPrompts\u001B[39m')).toBe(7); + }); + + it('truncates by visible width', () => { + expect(truncate('Ollin Prompts', 10)).toBe('Ollin P...'); + }); + + it('closes ANSI styles when truncating styled text', () => { + const result = truncate('\u001B[31mOllin Prompts\u001B[39m', 10); + + expect(parseAnsiText(result)).toBe('Ollin P...'); + expect(result).toContain('\u001B[31m'); + expect(result).toContain('\u001B[0m...'); + expect(result.endsWith('\u001B[39m')).toBe(false); + }); + + it('clips truncation markers by visible width', () => { + expect(truncate('Ollin Prompts', 1, '…')).toBe('…'); + expect(truncate('Ollin Prompts', 1, '東京')).toBe(''); + }); + + it('wraps words to the requested width', () => { + expect(wrap('Ollin Prompts', 7)).toEqual(['Ollin', 'Prompts']); + }); + + it('wraps wide characters without emitting empty lines', () => { + expect(wrap('😀', 1)).toEqual(['😀']); + }); + + it('strips ANSI text', () => { + expect(parseAnsiText('\u001B[32mDone\u001B[39m')).toBe('Done'); + }); + + it('parses plain text into a single ANSI segment', () => { + expect(parseAnsiSegments('Hello, World!')).toEqual([{ text: 'Hello, World!', codes: '' }]); + }); + + it('parses styled and unstyled ANSI segments', () => { + expect(parseAnsiSegments('Hello \u001B[1mBold\u001B[0m World')).toEqual([ + { text: 'Hello ', codes: '' }, + { text: 'Bold', codes: '\u001B[1m' }, + { text: ' World', codes: '' }, + ]); + }); + + it('parses 24-bit ANSI color segments', () => { + expect(parseAnsiSegments('\u001B[38;2;255;100;50mColored\u001B[0m')).toEqual([{ text: 'Colored', codes: '\u001B[38;2;255;100;50m' }]); + }); + + it('preserves ANSI codes across wrapped lines', () => { + const result = wrap('\u001B[31mHello World\u001B[0m', 5); + + expect(result).toHaveLength(2); + expect(result[0]).toContain('\u001B[31m'); + expect(result[0]).toContain('Hello'); + expect(result[0]?.endsWith('\u001B[0m')).toBe(true); + expect(result[1]).toContain('\u001B[31m'); + expect(result[1]).toContain('World'); + expect(result[1]?.endsWith('\u001B[0m')).toBe(true); + }); + + it('wraps multiple ANSI color segments onto separate lines', () => { + const result = wrap('\u001B[31mRed\u001B[0m \u001B[32mGreen\u001B[0m \u001B[34mBlue\u001B[0m', 5); + + expect(result).toHaveLength(3); + expect(result[0]).toContain('Red'); + expect(result[1]).toContain('Green'); + expect(result[2]).toContain('Blue'); + }); +}); diff --git a/packages/tui/tests/suggest.test.ts b/packages/tui/tests/suggest.test.ts new file mode 100644 index 00000000..aeead0c1 --- /dev/null +++ b/packages/tui/tests/suggest.test.ts @@ -0,0 +1,356 @@ +import { describe, expect, it } from 'vitest'; +import { autocomplete, createMemoryOutput, createScriptedInput, Key, parseAnsiText, suggest, withPromptEnvironment } from '#tui/index'; + +const runSuggest = async (keys: string[], options: string[] | ((query: string) => string[])): Promise<{ output: string; result: string }> => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(keys), + output, + error: output, + interactive: true, + }, + () => suggest('Favorite color?', options), + ); + + return { output: output.text(), result }; +}; + +const expectSuggestion = async (keys: string[], options: string[] | ((query: string) => string[]), result: string): Promise => { + await expect(runSuggest(keys, options)).resolves.toMatchObject({ result }); +}; + +const outputFor = async (keys: string[], options: string[] | ((query: string) => string[])): Promise => { + const output = createMemoryOutput(); + + await withPromptEnvironment( + { + input: createScriptedInput(keys), + output, + error: output, + interactive: true, + }, + () => suggest('Favorite color?', options), + ); + + return output.text(); +}; + +describe('suggest prompt', () => { + it('accepts arbitrary typed input', async () => { + await expectSuggestion(['B', 'l', 'a', 'c', 'k', Key.enter], ['Red', 'Green', 'Blue'], 'Black'); + }); + + it('completes input using tab', async () => { + await expectSuggestion(['b', Key.tab, Key.enter], ['Red', 'Green', 'Blue'], 'Blue'); + }); + + it('navigates matches using arrow and emacs keys', async () => { + await expectSuggestion(['b', Key.down, Key.down, Key.down, Key.up, Key.enter], ['Red', 'Blue', 'Black', 'Blurple'], 'Black'); + + await expectSuggestion(['b', Key.ctrlN, Key.ctrlN, Key.ctrlN, Key.ctrlP, Key.enter], ['Red', 'Blue', 'Black', 'Blurple'], 'Black'); + }); + + it('navigates suggestions backwards with shift tab', async () => { + await expectSuggestion(['b', Key.shiftTab, Key.enter], ['Red', 'Blue', 'Black', 'Blurple'], 'Blurple'); + }); + + it('supports page suggestion navigation keys', async () => { + await expectSuggestion(['b', Key.down, Key.pageDown, Key.pageUp, Key.pageDown, Key.enter], ['Red', 'Blue', 'Black', 'Blurple'], 'Blurple'); + }); + + it('supports home and end suggestion navigation keys', async () => { + await expectSuggestion(['b', Key.down, Key.end[0], Key.home[0], Key.enter], ['Red', 'Blue', 'Black', 'Blurple'], 'Blue'); + }); + + it('supports control-line suggestion navigation keys', async () => { + await expectSuggestion(['b', Key.down, Key.ctrlE, Key.enter], ['Red', 'Blue', 'Black', 'Blurple'], 'Blurple'); + + await expectSuggestion(['b', Key.down, Key.ctrlE, Key.ctrlA, Key.enter], ['Red', 'Blue', 'Black', 'Blurple'], 'Blue'); + }); + + it('supports callback options', async () => { + await expectSuggestion(['e', 'e', Key.down, Key.enter], (value) => ['Red', 'Green', 'Blue'].filter((option) => option.toLowerCase().includes(value.toLowerCase())), 'Green'); + }); + + it('renders typed queries and suggestion matches', async () => { + const output = await outputFor(['b', Key.down, Key.enter], ['Red', 'Green', 'Blue']); + + expect(output).toContain('\u001B[36m ┌\u001B[39m \u001B[36mFavorite color?\u001B[39m '); + expect(parseAnsiText(output)).toContain('b'); + expect(output).toContain('›'); + expect(output).toContain('Blue'); + expect(output).toContain('┌ \u001B[2mFavorite color?\u001B[22m '); + }); + + it('highlights suggestions on tab without replacing typed input', async () => { + const output = await outputFor(['b', Key.tab, Key.enter], ['Red', 'Green', 'Blue']); + + expect(parseAnsiText(output)).toContain('b'); + expect(output).not.toContain('Favorite color? Blue'); + }); + + it('clears highlighted suggestions with horizontal navigation keys', async () => { + await expectSuggestion(['b', Key.down, Key.left, Key.enter], ['Red', 'Green', 'Blue'], 'b'); + }); + + it('renders suggest info for the highlighted result', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment( + { + input: createScriptedInput(['b', Key.down, Key.enter]), + output, + error: output, + interactive: true, + }, + () => suggest({ message: 'Favorite color?', options: ['Red', 'Green', 'Blue'], info: (value) => `About ${value ?? 'none'}` }), + ); + + expect(output.text()).toContain('About Blue'); + }); + + it('supports label-first suggest helpers with info', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment( + { + input: createScriptedInput(['b', Key.down, Key.enter]), + output, + error: output, + interactive: true, + }, + () => suggest('Favorite color?', ['Red', 'Green', 'Blue'], '', '', 5, false, undefined, '', undefined, (value) => `About ${value ?? 'none'}`), + ); + + expect(output.text()).toContain('About Blue'); + }); + + it('renders cancelled suggest frames with the current value', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['b', Key.ctrlC]), + output, + error: output, + interactive: true, + }, + () => suggest('Favorite color?', ['Blue']), + ); + + expect(result).toBe('b'); + expect(output.text()).toContain('Cancelled.'); + expect(output.text()).toContain('\u001B[31m ┌\u001B[39m Favorite color? '); + expect(parseAnsiText(output.text())).toContain('b'); + }); +}); + +describe('autocomplete prompt', () => { + it('renders autocomplete prompts with question formatting, hints, and placeholders', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment( + { + input: createScriptedInput([Key.enter]), + output, + error: output, + interactive: true, + }, + () => autocomplete('Favorite color?', ['Red', 'Green', 'Blue'], 'Type a color', '', false, undefined, 'Optional'), + ); + + expect(output.text()).toContain('\u001B[36m ┌\u001B[39m \u001B[36mFavorite color?\u001B[39m '); + expect(output.text()).toContain('\u001B[2mType a color\u001B[22m'); + expect(output.text()).toContain('┌ \u001B[2mFavorite color?\u001B[22m '); + }); + + it('renders autocomplete ghost text without a suggestion list', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment( + { + input: createScriptedInput(['b', Key.enter]), + output, + error: output, + interactive: true, + }, + () => autocomplete('Favorite color?', ['Red', 'Green', 'Blue']), + ); + + expect(parseAnsiText(output.text())).toContain('blue'); + expect(output.text()).toContain('b\u001B[7ml\u001B[27m\u001B[2mue\u001B[22m'); + expect(output.text()).not.toContain('›'); + }); + + it('supports label-first autocomplete helpers with info', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment( + { + input: createScriptedInput(['b', Key.enter]), + output, + error: output, + interactive: true, + }, + () => autocomplete('Favorite color?', ['Red', 'Green', 'Blue'], '', '', false, undefined, '', undefined, (value) => `About ${value ?? 'none'}`), + ); + + expect(output.text()).toContain('About Blue'); + }); + + it('accepts ghost completion with tab', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['b', Key.tab, Key.enter]), + output, + error: output, + interactive: true, + }, + () => autocomplete('Favorite color?', ['Red', 'Green', 'Blue']), + ); + + expect(result).toBe('Blue'); + expect(output.text()).toContain('┌ \u001B[2mFavorite color?\u001B[22m '); + expect(parseAnsiText(output.text())).toContain('Blue'); + }); + + it('does not accept tab completion before the cursor reaches the end', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['b', Key.left, Key.tab, Key.enter]), + output, + error: output, + interactive: true, + }, + () => autocomplete('Favorite color?', ['Blue']), + ); + + expect(result).toBe('b'); + }); + + it('hides autocomplete ghost text while editing before the end', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment( + { + input: createScriptedInput(['b', Key.left, Key.enter]), + output, + error: output, + interactive: true, + }, + () => autocomplete('Favorite color?', ['Blue']), + ); + + const frames = output.text().trimEnd().split('\n'); + + expect(parseAnsiText(output.text())).toContain('b'); + expect(frames.at(-1)).not.toContain('Blue'); + }); + + it('accepts same-length autocomplete matches with right arrow', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['b', 'l', 'u', 'e', Key.right, Key.enter]), + output, + error: output, + interactive: true, + }, + () => autocomplete('Favorite color?', ['Blue']), + ); + + expect(result).toBe('Blue'); + }); + + it('cycles autocomplete matches before accepting completion', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['b', Key.down, Key.tab, Key.enter]), + output, + error: output, + interactive: true, + }, + () => autocomplete('Favorite color?', ['Blue', 'Black', 'Blurple']), + ); + + expect(result).toBe('Black'); + }); + + it('does not cycle autocomplete matches with control navigation keys', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['b', Key.ctrlN, Key.tab, Key.enter]), + output, + error: output, + interactive: true, + }, + () => autocomplete('Favorite color?', ['Blue', 'Black', 'Blurple']), + ); + + expect(result).toBe('Blue'); + }); + + it('does not cycle autocomplete matches with shift tab', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['b', Key.shiftTab, Key.tab, Key.enter]), + output, + error: output, + interactive: true, + }, + () => autocomplete('Favorite color?', ['Blue', 'Black', 'Blurple']), + ); + + expect(result).toBe('Blue'); + }); + + it('does not cycle autocomplete matches with page navigation keys', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['b', Key.pageDown, Key.pageUp, Key.pageDown, Key.tab, Key.enter]), + output, + error: output, + interactive: true, + }, + () => autocomplete({ message: 'Favorite color?', options: ['Blue', 'Black', 'Blurple'], scroll: 2 }), + ); + + expect(result).toBe('Blue'); + expect(output.text()).toContain('┌ \u001B[2mFavorite color?\u001B[22m '); + expect(parseAnsiText(output.text())).toContain('Blue'); + }); + + it('renders cancelled autocomplete frames with the current value', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['b', Key.ctrlC]), + output, + error: output, + interactive: true, + }, + () => autocomplete('Favorite color?', ['Blue']), + ); + + expect(result).toBe('b'); + expect(output.text()).toContain('Cancelled.'); + expect(output.text()).toContain('\u001B[31m ┌\u001B[39m Favorite color? '); + expect(parseAnsiText(output.text())).toContain('b'); + }); +}); diff --git a/packages/tui/tests/table.test.ts b/packages/tui/tests/table.test.ts new file mode 100644 index 00000000..ccc29b5e --- /dev/null +++ b/packages/tui/tests/table.test.ts @@ -0,0 +1,1090 @@ +import { describe, expect, it } from 'vitest'; +import { createMemoryOutput, createScriptedInput, datatable, form, Key, parseAnsiText, table, visibleWidth, withPromptEnvironment } from '#tui/index'; + +describe('table output', () => { + it('renders static table rows', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + table(['Name', 'Runtime'], [['Ollin', 'OpenTUI']]); + }); + + expect(output.text()).toContain('| Name | Runtime |'); + expect(output.text()).toContain('| Ollin | OpenTUI |'); + }); + + it('renders object-form table options with inferred headers', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + table({ + rows: [ + { Name: 'Ollin', Runtime: 'OpenTUI' }, + { Name: 'Prompts', Runtime: 'TypeScript' }, + ], + }); + }); + + expect(output.text()).toContain('| Name | Runtime |'); + expect(output.text()).toContain('| Prompts | TypeScript |'); + }); + + it('pads ANSI-styled table cells by visible width', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + table( + ['Name', 'Runtime'], + [ + ['\u001B[31mRed\u001B[39m', 'Node'], + ['Blue', 'OpenTUI'], + ], + ); + }); + + expect(output.text()).toContain('\u001B[31mRed\u001B[39m | Node'); + expect(output.text()).toContain('| Blue | OpenTUI |'); + }); + + it('pads wide Unicode table cells by visible width', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + table( + ['Name', 'Runtime'], + [ + ['東京', 'Node'], + ['Paris', 'OpenTUI'], + ], + ); + }); + + expect(output.text()).toContain('| 東京 | Node'); + expect(output.text()).toContain('| Paris | OpenTUI |'); + }); + + it('renders missing table cells as empty columns', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + table(['Name', 'Runtime'], [['Ollin'], ['Prompts', 'TypeScript']]); + }); + + expect(output.text()).toContain('| Ollin | |'); + expect(output.text()).toContain('| Prompts | TypeScript |'); + }); + + it('renders object table rows in explicit header order', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + table({ + headers: ['Runtime', 'Name', 'Notes'], + rows: [ + { Name: 'Ollin', Runtime: 'OpenTUI' }, + { Name: 'Prompts', Runtime: 'TypeScript', Notes: 'Port' }, + ], + }); + }); + + expect(output.text()).toContain('| Runtime | Name | Notes |'); + expect(output.text()).toContain('| OpenTUI | Ollin | |'); + expect(output.text()).toContain('| TypeScript | Prompts | Port |'); + }); + + it('normalizes table row cells through the validator layer', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + table(['Name', 'Enabled', 'Notes'], [{ Enabled: false, Name: 'Ollin', Notes: null }]); + }); + + expect(output.text()).toContain('| Name | Enabled | Notes |'); + expect(output.text()).toContain('| Ollin | false | |'); + }); + + it('renders tables without headers from row-only input', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + table([ + ['Ollin', 'OpenTUI'], + ['Prompts', 'TypeScript'], + ]); + }); + + expect(output.text()).not.toContain('---'); + expect(output.text()).toContain('| Ollin | OpenTUI |'); + expect(output.text()).toContain('| Prompts | TypeScript |'); + }); + + it('renders option-object tables with array rows as headerless tables unless headers are explicit', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + table({ + rows: [ + ['Ollin', 'OpenTUI'], + ['Prompts', 'TypeScript'], + ], + }); + }); + + expect(output.text()).not.toContain('| 0 | 1 |'); + expect(output.text()).not.toContain('| ------- | ---------- |'); + expect(output.text()).toContain('| Ollin | OpenTUI |'); + expect(output.text()).toContain('| Prompts | TypeScript |'); + }); + + it('rejects invalid table runtime shapes through the validator layer', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + expect(() => table(null as never)).toThrow(); + expect(() => table(['Name'], null as never)).not.toThrow(); + expect(() => table(['Name'], [null] as never)).toThrow(); + }); + }); +}); + +describe('data table prompt', () => { + it('returns the selected explicit row value', async () => { + const output = createMemoryOutput(); + const selected = { id: 2 }; + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + datatable({ + message: 'Pick project', + headers: ['Name', 'Runtime'], + rows: [ + { value: { id: 1 }, cells: { Name: 'Alpha', Runtime: 'Node' } }, + { value: selected, cells: { Name: 'Beta', Runtime: 'OpenTUI' } }, + ], + }), + ); + + expect(result).toBe(selected); + expect(output.text()).toContain('Pick project'); + expect(output.text()).toContain('›'); + }); + + it('supports label-first data table helpers with filters', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['/', 'slow', Key.enter, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + datatable( + ['Name', 'Tag'], + [ + { Name: 'Alpha', Tag: 'fast', value: 'alpha' }, + { Name: 'Beta', Tag: 'slow', value: 'beta' }, + ], + 10, + 'Pick project', + '', + false, + undefined, + undefined, + (query, row) => !Array.isArray(row) && row.Tag === query, + ), + ); + + expect(result).toBe('beta'); + expect(output.text()).toContain('Pick project slow'); + expect(output.text()).toContain('Beta'); + }); + + it('transforms selected data table values before return', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.enter]), + output, + error: output, + interactive: true, + }, + () => + datatable({ + message: 'Pick project', + rows: [{ Name: 'Alpha', value: 'alpha' }], + transform: (value) => String(value).toUpperCase(), + }), + ); + + expect(result).toBe('ALPHA'); + }); + + it('rejects invalid data table runtime shapes through the validator layer', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + await expect(datatable(null as never)).rejects.toThrow(); + + await expect(datatable(['Name'], [null] as never)).rejects.toThrow(); + }); + }); + + it('renders data table submitted frames only after validation passes', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.enter, Key.down, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + datatable({ + message: 'Pick project', + rows: [ + { Name: 'Alpha', value: 'alpha' }, + { Name: 'Beta', value: 'beta' }, + ], + validate: (value) => (value === 'beta' ? null : 'Choose beta.'), + }), + ); + + const rendered = parseAnsiText(output.text()); + + expect(result).toBe('beta'); + expect(rendered).toContain('Choose beta.'); + expect(rendered).not.toMatch(/^Alpha$/m); + expect(rendered).toMatch(/^Beta$/m); + }); + + it('starts data table selection on matching default values', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.enter]), + output, + error: output, + interactive: true, + }, + () => + datatable({ + default: 'beta', + message: 'Pick project', + rows: [ + { Name: 'Alpha', value: 'alpha' }, + { Name: 'Beta', value: 'beta' }, + ], + }), + ); + + expect(result).toBe('beta'); + expect(output.text()).toContain('Beta'); + }); + + it('returns the current data table selection when key input is exhausted', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([]), + output, + error: output, + interactive: true, + }, + () => + datatable({ + default: 'beta', + message: 'Pick project', + rows: [ + { Name: 'Alpha', value: 'alpha' }, + { Name: 'Beta', value: 'beta' }, + ], + }), + ); + + expect(result).toBe('beta'); + expect(output.text()).toContain('Beta'); + }); + + it('filters rows in explicit search mode', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['/', 'B', Key.enter, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + datatable({ + message: 'Pick project', + rows: [ + { Name: 'Alpha', value: 'alpha' }, + { Name: 'Beta', value: 'beta' }, + ], + }), + ); + + expect(result).toBe('beta'); + expect(output.text()).toContain('Pick project B'); + }); + + it('uses custom data table filters for search mode', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['/', 'slow', Key.enter, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + datatable({ + message: 'Pick project', + filter: (query, row) => !Array.isArray(row) && row.Tag === query, + rows: [ + { Name: 'Alpha', Tag: 'fast', value: 'alpha' }, + { Name: 'Beta', Tag: 'slow', value: 'beta' }, + ], + }), + ); + + expect(result).toBe('beta'); + expect(output.text()).toContain('Pick project slow'); + }); + + it('does not filter rows from printable keys in browse mode', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['B', Key.enter]), + output, + error: output, + interactive: true, + }, + () => + datatable({ + message: 'Pick project', + rows: [ + { Name: 'Alpha', value: 'alpha' }, + { Name: 'Beta', value: 'beta' }, + ], + }), + ); + + expect(result).toBe('alpha'); + }); + + it('cancels data table search with escape', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['/', 'B', Key.escape, Key.down, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + datatable({ + message: 'Pick project', + rows: [ + { Name: 'Alpha', value: 'alpha' }, + { Name: 'Beta', value: 'beta' }, + ], + }), + ); + + expect(result).toBe('beta'); + + const latestFrame = output.text().split('Pick project\n').at(-2) ?? ''; + + expect(latestFrame).not.toContain('Pick project B'); + expect(latestFrame).toContain('Alpha'); + expect(latestFrame).toContain('Beta'); + }); + + it('renders a no-results row for unmatched data table searches', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['/', 'Z', Key.escape, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + datatable({ + message: 'Pick project', + headers: ['Name'], + rows: [ + { Name: 'Alpha', value: 'alpha' }, + { Name: 'Beta', value: 'beta' }, + ], + }), + ); + + expect(result).toBe('alpha'); + expect(output.text()).toContain('Pick project Z'); + expect(output.text()).toContain('No results found.'); + }); + + it('cancels data table prompts with the current selected row', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.ctrlC]), + output, + error: output, + interactive: true, + }, + () => + datatable({ + message: 'Pick project', + rows: [ + { Name: 'Alpha', value: 'alpha' }, + { Name: 'Beta', value: 'beta' }, + ], + }), + ); + + expect(result).toBe('beta'); + expect(output.text()).toContain('Cancelled.'); + expect(parseAnsiText(output.text())).toContain('⚠ Cancelled.'); + expect(output.text()).toContain('/ Search'); + }); + + it('cancels data table search with the current filtered row', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['/', 'B', Key.ctrlC]), + output, + error: output, + interactive: true, + }, + () => + datatable({ + message: 'Pick project', + rows: [ + { Name: 'Alpha', value: 'alpha' }, + { Name: 'Beta', value: 'beta' }, + ], + }), + ); + + expect(result).toBe('beta'); + expect(output.text()).toContain('Cancelled.'); + expect(parseAnsiText(output.text())).toContain('⚠ Cancelled.'); + }); + + it('renders a submitted data table row summary', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + datatable({ + message: 'Pick project', + headers: ['Name', 'Role'], + rows: [ + ['Alice', 'Designer'], + ['Bob', 'Developer'], + ], + }), + ); + + expect(result).toBe(1); + expect(output.text()).toContain('Bob, Developer'); + }); + + it('renders multiline data table cells as visual table rows', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.enter]), + output, + error: output, + interactive: true, + }, + () => + datatable({ + message: 'Pick row', + headers: ['Name', 'Role'], + rows: [ + ['Alice', 'Lead\nDeveloper'], + ['Bob', 'Designer'], + ], + }), + ); + + const activeFrame = output.text().split('Pick row\n').at(-2) ?? ''; + + expect(result).toBe(0); + expect(activeFrame).toContain('| › | Alice | Lead |'); + expect(activeFrame).toContain('| | | Developer |'); + expect(activeFrame).toContain('| | Bob | Designer |'); + }); + + it('keeps highlighted multiline data table rows fully visible', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + datatable({ + message: 'Pick row', + headers: ['Name', 'Role'], + rows: [ + ['Alice', 'Designer'], + ['Bob', 'CEO\nCTO\nDeveloper'], + ['Charlie', 'Designer'], + ], + scroll: 5, + }), + ); + + const activeFrame = output.text().split('Pick row\n').at(-2) ?? ''; + + expect(result).toBe(1); + expect(activeFrame).toContain('CEO'); + expect(activeFrame).toContain('CTO'); + expect(activeFrame).toContain('Developer'); + }); + + it('maintains fixed data table visual height', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.enter]), + output, + error: output, + interactive: true, + }, + () => + datatable({ + message: 'Pick row', + headers: ['Name'], + rows: [['Alice'], ['Bob']], + scroll: 5, + }), + ); + + const activeFrame = output.text().split('Pick row\n').at(-2) ?? ''; + const tableLines = activeFrame.split('\n').filter((line) => line.startsWith('|')); + + expect(result).toBe(0); + expect(tableLines).toHaveLength(7); + expect(tableLines.slice(2)).toHaveLength(5); + }); + + it('uses comfortable data table width for compact rows', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.enter]), + output, + error: output, + interactive: true, + }, + () => + datatable({ + message: 'Pick row', + headers: ['A', 'B'], + rows: [['Hi', 'Lo']], + scroll: 5, + }), + ); + + const activeFrame = output.text().split('Pick row\n').at(-2) ?? ''; + const widestLine = Math.max(...activeFrame.split('\n').map(visibleWidth)); + + expect(result).toBe(0); + expect(widestLine).toBeLessThan(70); + }); + + it('truncates outlier data table columns before they stretch the table', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.end[0], Key.enter]), + output, + error: output, + interactive: true, + }, + () => + datatable({ + message: 'Pick row', + headers: ['Name', 'Value'], + rows: [ + ['Alice', 'Short'], + ['Bob', 'Short'], + ['Charlie', 'Short'], + ['Diana', 'Short'], + ['Ethan', 'Short'], + ['An extremely long value that should be treated as an outlier and truncated', 'Short'], + ], + scroll: 5, + }), + ); + + const activeFrame = output.text().split('Pick row\n').at(-2) ?? ''; + const widestLine = Math.max(...activeFrame.split('\n').map(visibleWidth)); + + expect(result).toBe(5); + expect(widestLine).toBeLessThan(76); + expect(activeFrame).toContain('...'); + }); + + it('renders data table rows without headers', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.enter]), + output, + error: output, + interactive: true, + }, + () => + datatable({ + message: 'Pick row', + headers: [], + rows: [ + ['Alice', 'Developer'], + ['Bob', 'Designer'], + ], + }), + ); + + const activeFrame = output.text().split('Pick row\n').at(-2) ?? ''; + + expect(result).toBe(0); + expect(activeFrame).not.toContain('---'); + expect(activeFrame).toContain('| › | Alice | Developer |'); + expect(activeFrame).toContain('| | Bob | Designer |'); + }); + + it('renders blank data table cells as empty columns', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.enter]), + output, + error: output, + interactive: true, + }, + () => + datatable({ + message: 'Pick row', + headers: ['Name', 'Role'], + rows: [ + ['Alice', null], + ['Bob', undefined], + ], + }), + ); + + const activeFrame = output.text().split('Pick row\n').at(-2) ?? ''; + + expect(result).toBe(0); + expect(activeFrame).toContain('| Name | Role |'); + expect(activeFrame).toMatch(/\| › \| Alice \|\s+\|/); + expect(activeFrame).toMatch(/\| \| Bob\s+\|\s+\|/); + }); + + it('edits Unicode data table search queries without corrupting input', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['/', '😀', Key.backspace, 'B', Key.enter, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + datatable({ + message: 'Pick project', + rows: [ + { Name: 'Alpha', value: 'alpha' }, + { Name: 'Beta', value: 'beta' }, + ], + }), + ); + + expect(result).toBe('beta'); + expect(output.text()).toContain('Pick project B'); + }); + + it('supports cursor edits inside data table search queries', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['/', 'B', 't', Key.left, 'e', Key.enter, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + datatable({ + message: 'Pick project', + rows: [ + { Name: 'Alpha', value: 'alpha' }, + { Name: 'Beta', value: 'beta' }, + ], + }), + ); + + expect(result).toBe('beta'); + expect(output.text()).toContain('Pick project Bet'); + }); + + it('falls back to row indexes when rows have no explicit value', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + datatable({ + message: 'Pick row', + headers: ['Name'], + rows: [['First'], ['Second']], + }), + ); + + expect(result).toBe(1); + }); + + it('uses the first visible row without keyboard input support', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: {}, + output, + error: output, + interactive: true, + }, + () => + datatable({ + message: 'Pick row', + headers: ['Name'], + rows: [ + { Name: 'First', value: 'first' }, + { Name: 'Second', value: 'second' }, + ], + }), + ); + + expect(result).toBe('first'); + }); + + it('renders a scrolling data table window around the selected row', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.down, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + datatable({ + message: 'Pick row', + headers: ['Name'], + rows: [['First'], ['Second'], ['Third'], ['Fourth']], + scroll: 3, + }), + ); + + const latestFrame = output.text().split('Pick row\n').at(-2) ?? ''; + + expect(result).toBe(2); + expect(latestFrame).not.toContain('First'); + expect(latestFrame).toContain('Second'); + expect(latestFrame).toContain('Third'); + expect(latestFrame).toContain('Fourth'); + expect(latestFrame).toContain('Viewing 2-4 of 4'); + expect(latestFrame).toContain('┃'); + }); + + it('does not render data table viewing info when all rows are visible', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment( + { + input: createScriptedInput([Key.enter]), + output, + error: output, + interactive: true, + }, + () => + datatable({ + message: 'Pick row', + headers: ['Name'], + rows: [['First'], ['Second']], + scroll: 5, + }), + ); + + expect(output.text()).not.toContain('Viewing'); + expect(output.text()).not.toContain('┃'); + }); + + it('renders data table viewing info for filtered result windows', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment( + { + input: createScriptedInput(['/', 'a', Key.pageDown, Key.enter, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + datatable({ + message: 'Pick row', + headers: ['Name'], + rows: [['Alpha'], ['Atlas'], ['Beta'], ['Gamma'], ['Delta']], + scroll: 2, + }), + ); + + expect(output.text()).toContain('Viewing 3-4 of 5 results'); + }); + + it('supports page, home, and end data table navigation keys', async () => { + const output = createMemoryOutput(); + + const pageDownResult = await withPromptEnvironment( + { + input: createScriptedInput([Key.pageDown, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + datatable({ + message: 'Pick row', + headers: ['Name'], + rows: [['First'], ['Second'], ['Third'], ['Fourth']], + scroll: 2, + }), + ); + + const endHomeResult = await withPromptEnvironment( + { + input: createScriptedInput([Key.end[0], Key.home[0], Key.enter]), + output, + error: output, + interactive: true, + }, + () => + datatable({ + message: 'Pick row', + headers: ['Name'], + rows: [['First'], ['Second'], ['Third'], ['Fourth']], + scroll: 2, + }), + ); + + expect(pageDownResult).toBe(2); + expect(endHomeResult).toBe(0); + }); + + it('supports tab and reverse-tab data table navigation keys', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.tab, Key.tab, Key.shiftTab, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + datatable({ + message: 'Pick row', + headers: ['Name'], + rows: [['First'], ['Second'], ['Third']], + }), + ); + + expect(result).toBe(1); + }); + + it('supports control-key data table navigation', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.ctrlN, Key.ctrlN, Key.ctrlP, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + datatable({ + message: 'Pick row', + headers: ['Name'], + rows: [['First'], ['Second'], ['Third']], + }), + ); + + expect(result).toBe(1); + }); + + it('supports control-line data table navigation keys', async () => { + const output = createMemoryOutput(); + + const endResult = await withPromptEnvironment( + { + input: createScriptedInput([Key.ctrlE, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + datatable({ + message: 'Pick row', + headers: ['Name'], + rows: [['First'], ['Second'], ['Third']], + }), + ); + + const homeResult = await withPromptEnvironment( + { + input: createScriptedInput([Key.ctrlE, Key.ctrlA, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + datatable({ + message: 'Pick row', + headers: ['Name'], + rows: [['First'], ['Second'], ['Third']], + }), + ); + + expect(endResult).toBe(2); + expect(homeResult).toBe(0); + }); + + it('wraps data table navigation from the first row to the last row', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.up, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + datatable({ + message: 'Pick row', + headers: ['Name'], + rows: [['First'], ['Second'], ['Third']], + }), + ); + + expect(result).toBe(2); + }); + + it('normalizes fractional scroll sizes for data table page navigation', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.pageDown, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + datatable({ + message: 'Pick row', + headers: ['Name'], + rows: [['First'], ['Second'], ['Third'], ['Fourth']], + scroll: 2.9, + }), + ); + + const latestFrame = output.text().split('Pick row\n').at(-2) ?? ''; + + expect(result).toBe(2); + expect(latestFrame).not.toContain('First'); + expect(latestFrame).not.toContain('Second'); + expect(latestFrame).toContain('Third'); + expect(latestFrame).toContain('Fourth'); + }); + + it('works from form builders', async () => { + const output = createMemoryOutput(); + + const responses = await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + form() + .datatable( + { + message: 'Pick project', + rows: [ + { Name: 'Alpha', value: 'alpha' }, + { Name: 'Beta', value: 'beta' }, + ], + }, + 'project', + ) + .submit(), + ); + + expect(responses.project).toBe('beta'); + }); +}); diff --git a/packages/tui/tests/task.test.ts b/packages/tui/tests/task.test.ts new file mode 100644 index 00000000..0587a674 --- /dev/null +++ b/packages/tui/tests/task.test.ts @@ -0,0 +1,315 @@ +import { describe, expect, it } from 'vitest'; +import { createMemoryOutput, task, withPromptEnvironment } from '#tui/index'; +import { eraseRenderedFrame, renderedFrameLineCount } from '#tui/status/frame'; +import { renderTaskFrame } from '#tui/status/task/render'; + +describe('task helper', () => { + it('passes a bounded logger to the callback and returns the callback value', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment({ output, error: output }, async () => { + return task( + 'Running...', + (logger) => { + logger.log('line one'); + logger.log('line two'); + logger.log('line three'); + + return 'done'; + }, + 2, + ); + }); + + expect(result).toBe('done'); + expect(output.text()).toContain('\u001B[?25l'); + expect(output.text()).toContain('\u001B[1A\u001B[2K'); + expect(output.text()).toContain('\u001B[?25h'); + expect(output.text()).toContain('Running...'); + expect(output.text()).not.toContain('line one'); + expect(output.text()).toContain('line two'); + expect(output.text()).toContain('line three'); + }); + + it('keeps task logging finite for invalid limits', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + await task( + 'Running...', + (logger) => { + logger.log('line one'); + logger.log('line two'); + }, + -1, + ); + }); + + expect(output.text()).toContain('Running...'); + expect(output.text()).toContain('line one'); + expect(output.text()).toContain('line two'); + expect(output.text()).toContain(' ⠶ Running...'); + }); + + it('strips cursor reset control sequences from log lines', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + await task('Running...', (logger) => { + logger.log('before\u001B[1G\u001B[2Kafter'); + }); + }); + + expect(output.text()).toContain('beforeafter'); + }); + + it('strips carriage returns from task log lines', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + await task('Running...', (logger) => { + logger.log('before\rafter'); + }); + }); + + expect(output.text()).toContain('beforeafter'); + }); + + it('trims trailing whitespace from completed log lines', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + await task('Running...', (logger) => { + logger.line('trailing '); + }); + }); + + expect(output.text()).toContain('trailing\n'); + expect(output.text()).not.toContain('trailing \n'); + }); + + it('keeps stable task summaries when requested', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + await task( + 'Running...', + (logger) => { + logger.line('line one'); + logger.success('created'); + logger.warning('check this'); + logger.error('failed optional step'); + }, + 10, + true, + ); + }); + + expect(output.text()).not.toContain('line one'); + expect(output.text()).toContain(' • Running...'); + expect(output.text()).toContain('✔ created'); + expect(output.text()).toContain('⚠ check this'); + expect(output.text()).toContain('✘ failed optional step'); + }); + + it('bounds stable task summaries with the task log limit', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + await task( + 'Running...', + (logger) => { + logger.success('first'); + logger.warning('second'); + logger.error('third'); + }, + 2, + true, + ); + }); + + expect(output.text()).not.toContain('success: first'); + expect(output.text()).toContain('⚠ second'); + expect(output.text()).toContain('✘ third'); + }); + + it('omits stable task summaries by default', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + await task('Running...', (logger) => { + logger.success('created'); + }); + }); + + expect(output.text()).not.toContain('success: created'); + expect(output.text()).not.toContain('✔ created'); + expect(output.text()).toContain(' ⠶ Running...'); + }); + + it('accumulates partial task output until committed', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + await task('Running...', (logger) => { + logger.partial('Downloading '); + logger.partial('states'); + logger.commitPartial(); + logger.line('Done'); + }); + }); + + expect(output.text()).toContain('Downloading states'); + expect(output.text()).toContain('Done'); + expect(output.text()).not.toContain('Downloading \n'); + }); + + it('preserves trailing spaces while partial output is still accumulating', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + await task('Running...', (logger) => { + logger.partial('Downloading '); + }); + }); + + expect(output.text()).toContain('Downloading \n'); + }); + + it('starts a new partial line after committing the previous partial', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + await task('Running...', (logger) => { + logger.partial('one'); + logger.commitPartial(); + logger.partial('two'); + logger.commitPartial(); + }); + }); + + expect(output.text()).toContain('one'); + expect(output.text()).toContain('two'); + }); + + it('captures process output while task callbacks run', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + await task('Running...', () => { + process.stdout.write('stdout line\n'); + process.stderr.write('stderr partial'); + }); + }); + + expect(output.text()).toContain('stdout line'); + expect(output.text()).toContain('stderr partial'); + }); + + it('restores process output writers after task callbacks finish', async () => { + const output = createMemoryOutput(); + const stdoutWrite = process.stdout.write; + const stderrWrite = process.stderr.write; + + await withPromptEnvironment({ output, error: output }, async () => { + await task('Running...', () => { + process.stdout.write('inside\n'); + }); + }); + + expect(process.stdout.write).toBe(stdoutWrite); + expect(process.stderr.write).toBe(stderrWrite); + }); + + it('updates task labels through the logger', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + await task('Running...', (logger) => { + logger.label('Building'); + logger.subLabel('assets'); + }); + }); + + expect(output.text()).toContain('Running...'); + expect(output.text()).toContain(' ⠶ Building'); + expect(output.text()).toContain('assets'); + }); + + it('clears task sub-labels through the logger', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + await task( + 'Running...', + (logger) => { + logger.label('Building'); + logger.subLabel(''); + }, + 10, + false, + 'assets', + ); + }); + + expect(output.text()).toContain(' ⠶ Running...\n assets'); + expect(output.text()).toContain(' ⠶ Building'); + expect(output.text()).not.toContain(' ⠶ Building\n assets'); + }); + + it('renders deterministic animated task frames', () => { + expect( + renderTaskFrame({ + frameCount: 2, + label: 'Running...', + limit: 2, + lines: ['line one'], + stableMessages: [], + }), + ).toContain(' ⠐ Running...'); + + expect( + renderTaskFrame({ + label: 'Running...', + limit: 2, + lines: [], + stableMessages: [], + }), + ).toContain(' ⠶ Running...'); + }); + + it('counts and erases rendered task frames', async () => { + const frame = renderTaskFrame({ + label: 'Running...', + limit: 2, + lines: ['line one'], + stableMessages: [], + }); + + const output = createMemoryOutput(); + + expect(renderedFrameLineCount(frame)).toBe(4); + + await withPromptEnvironment({ output, error: output }, async () => { + eraseRenderedFrame(frame); + }); + + expect(output.text()).toBe('\u001B[1A\u001B[2K'.repeat(4)); + }); + + it('restores the cursor when task callbacks fail', async () => { + const output = createMemoryOutput(); + const failure = new Error('failed'); + + await expect( + withPromptEnvironment({ output, error: output }, async () => { + await task('Running...', () => { + throw failure; + }); + }), + ).rejects.toBe(failure); + + expect(output.text()).toContain('\u001B[?25h'); + expect(output.text()).toContain('\u001B[1A\u001B[2K'); + }); +}); diff --git a/packages/tui/tests/terminal.test.ts b/packages/tui/tests/terminal.test.ts new file mode 100644 index 00000000..072fedfe --- /dev/null +++ b/packages/tui/tests/terminal.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; + +import { + backgroundColor, + clear, + clearTerminal, + createMemoryOutput, + cursorToStart, + eraseLine, + erasePreviousLines, + foregroundColor, + hideCursor, + setTerminalTitle, + showCursor, + supportsTrueColor, + title, + withPromptEnvironment, +} from '#tui/index'; + +describe('terminal helpers', () => { + it('writes clear terminal control sequences', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + clearTerminal(); + expect(clear()).toBeUndefined(); + }); + + expect(output.text()).toBe('\u001B[H\u001B[J\u001B[H\u001B[J'); + }); + + it('writes terminal title control sequences', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + setTerminalTitle('Demo'); + expect(title('Prompt')).toBeUndefined(); + }); + + expect(output.text()).toBe('\u001B]0;Demo\u0007\u001B]0;Prompt\u0007'); + }); + + it('writes cursor and erase-line control sequences', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + cursorToStart(); + eraseLine(); + erasePreviousLines(2); + erasePreviousLines(-1); + hideCursor(); + showCursor(); + }); + + expect(output.text()).toBe('\r\u001B[2K\u001B[1A\u001B[2K\u001B[1A\u001B[2K\u001B[?25l\u001B[?25h'); + }); + + it('detects true color terminal modes', () => { + expect(supportsTrueColor('truecolor')).toBe(true); + expect(supportsTrueColor('24bit')).toBe(true); + expect(supportsTrueColor('256color')).toBe(false); + expect(supportsTrueColor(undefined)).toBe(supportsTrueColor(process.env.COLORTERM)); + }); + + it('parses terminal colors with default fallbacks', () => { + expect(foregroundColor([1, 2, 3])).toEqual([1, 2, 3]); + expect(backgroundColor([4, 5, 6])).toEqual([4, 5, 6]); + expect(foregroundColor(['1', 2, 3])).toEqual([204, 204, 204]); + expect(backgroundColor([0, 0, 300])).toEqual([0, 0, 0]); + }); +}); diff --git a/packages/tui/tests/text.test.ts b/packages/tui/tests/text.test.ts new file mode 100644 index 00000000..b64b2867 --- /dev/null +++ b/packages/tui/tests/text.test.ts @@ -0,0 +1,213 @@ +import { describe, expect, it } from 'vitest'; +import { createMemoryOutput, createScriptedInput, Key, parseAnsiText, PromptValidationError, text, withPromptEnvironment } from '#tui/index'; + +describe('text prompt', () => { + it('renders typed values while reading raw key input', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['A', 'd', 'a', Key.enter]), + output, + error: output, + interactive: true, + }, + () => text('Name'), + ); + + expect(result).toBe('Ada'); + expect(output.text()).toContain('\u001B[36m ┌\u001B[39m \u001B[36mName\u001B[39m '); + expect(parseAnsiText(output.text())).toContain('Ada'); + expect(output.text()).toContain('┌ \u001B[2mName\u001B[22m '); + }); + + it('renders placeholders before text input', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment( + { + input: createScriptedInput([Key.enter]), + output, + error: output, + interactive: true, + }, + () => text('Name', 'Jane Doe'), + ); + + expect(output.text()).toContain('\u001B[36m ┌\u001B[39m \u001B[36mName\u001B[39m '); + expect(output.text()).toContain('\u001B[2mJane Doe\u001B[22m'); + }); + + it('accepts default values', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.enter]), + output, + error: output, + interactive: true, + }, + () => text('Name', '', 'Jess'), + ); + + expect(result).toBe('Jess'); + }); + + it('transforms values before returning them', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([' ', 'J', 'e', 's', 's', ' ', Key.enter]), + output, + error: output, + interactive: true, + }, + () => text({ message: 'Name', transform: (value) => value.trim() }), + ); + + expect(result).toBe('Jess'); + }); + + it('retries after validation errors', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['J', 'e', 's', Key.enter, 's', Key.enter]), + output, + error: output, + interactive: true, + }, + () => text({ message: 'Name', validate: (value) => (value !== 'Jess' ? 'Invalid name.' : '') }), + ); + + expect(result).toBe('Jess'); + expect(output.text()).toContain('Invalid name.'); + expect(output.text().split('┌ \u001B[2mName\u001B[22m').length - 1).toBe(1); + }); + + it('edits values with backspace and delete keys', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['J', 'e', 'z', Key.backspace, 's', 's', Key.left, Key.delete, Key.enter]), + output, + error: output, + interactive: true, + }, + () => text('Name'), + ); + + expect(result).toBe('Jes'); + }); + + it('supports line navigation key bindings', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['A', 'r', 'c', 'h', 'e', 'r', Key.ctrlA, 'J', 'e', 's', 's', Key.ctrlE, Key.enter]), + output, + error: output, + interactive: true, + }, + () => text('Name'), + ); + + expect(result).toBe('JessArcher'); + }); + + it('returns empty strings and defaults in non-interactive mode', async () => { + await expect( + withPromptEnvironment( + { + output: createMemoryOutput(), + error: createMemoryOutput(), + interactive: false, + }, + () => text('Name'), + ), + ).resolves.toBe(''); + + await expect( + withPromptEnvironment( + { + output: createMemoryOutput(), + error: createMemoryOutput(), + interactive: false, + }, + () => text('Name', '', 'Jess'), + ), + ).resolves.toBe('Jess'); + }); + + it('validates non-interactive default values', async () => { + await expect( + withPromptEnvironment( + { + output: createMemoryOutput(), + error: createMemoryOutput(), + interactive: false, + }, + () => text({ message: 'Name', required: true }), + ), + ).rejects.toThrow(PromptValidationError); + }); + + it('transforms non-interactive default values before validation and return', async () => { + const result = await withPromptEnvironment( + { + output: createMemoryOutput(), + error: createMemoryOutput(), + interactive: false, + }, + () => + text({ + message: 'Name', + default: ' Ada ', + transform: (value) => value.trim(), + validate: (value) => (value === 'Ada' ? null : 'Unexpected value.'), + }), + ); + + expect(result).toBe('Ada'); + }); + + it('handles failed empty key reads gracefully', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['', Key.enter]), + output, + error: output, + interactive: true, + }, + () => text('Name'), + ); + + expect(result).toBe(''); + }); + + it('renders cancelled text frames with the current value', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['A', Key.ctrlC]), + output, + error: output, + interactive: true, + }, + () => text('Name'), + ); + + expect(result).toBe('A'); + expect(output.text()).toContain('Cancelled.'); + expect(output.text()).toContain('\u001B[31m ┌\u001B[39m Name '); + expect(parseAnsiText(output.text())).toContain('A'); + }); +}); diff --git a/packages/tui/tests/textarea.test.ts b/packages/tui/tests/textarea.test.ts new file mode 100644 index 00000000..694ce9f2 --- /dev/null +++ b/packages/tui/tests/textarea.test.ts @@ -0,0 +1,310 @@ +import { describe, expect, it } from 'vitest'; +import { createMemoryOutput, createScriptedInput, Key, parseAnsiText, PromptValidationError, textarea, withPromptEnvironment } from '#tui/index'; + +describe('textarea prompt', () => { + it('accepts multiline input and submits with ctrl-d', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['A', Key.enter, 'B', Key.ctrlD]), + output, + error: output, + interactive: true, + }, + () => textarea('Description'), + ); + + expect(result).toBe('A\nB'); + expect(output.text()).toContain('Description'); + expect(output.text()).toContain('\u001B[36m ┌\u001B[39m \u001B[36mDescription\u001B[39m '); + expect(output.text()).toContain('Ctrl+D to submit'); + expect(output.text()).toContain('┌ \u001B[2mDescription\u001B[22m '); + expect(output.text()).toContain('│ A'); + expect(output.text()).toContain('│ B'); + }); + + it('renders textarea placeholders before input', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment( + { + input: createScriptedInput([Key.ctrlD]), + output, + error: output, + interactive: true, + }, + () => textarea('Description', 'Type here'), + ); + + expect(output.text()).toContain('\u001B[36m ┌\u001B[39m \u001B[36mDescription\u001B[39m '); + expect(output.text()).toContain('\u001B[2mType here\u001B[22m'); + }); + + it('accepts default values', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.ctrlD]), + output, + error: output, + interactive: true, + }, + () => textarea('Description', '', 'Jess\nJoe'), + ); + + expect(result).toBe('Jess\nJoe'); + }); + + it('transforms values before returning them', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([' ', 'J', 'e', 's', 's', ' ', Key.ctrlD]), + output, + error: output, + interactive: true, + }, + () => textarea({ message: 'Description', transform: (value) => value.trim() }), + ); + + expect(result).toBe('Jess'); + }); + + it('retries after validation errors', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['J', 'e', 's', Key.ctrlD, 's', Key.ctrlD]), + output, + error: output, + interactive: true, + }, + () => textarea({ message: 'Description', validate: (value) => (value !== 'Jess' ? 'Invalid name.' : '') }), + ); + + expect(result).toBe('Jess'); + expect(output.text()).toContain('Invalid name.'); + expect(output.text().split('┌ \u001B[2mDescription\u001B[22m').length - 1).toBe(1); + }); + + it('edits values with backspace and delete keys', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['J', 'e', 'z', Key.backspace, 's', 's', Key.left, Key.delete, Key.ctrlD]), + output, + error: output, + interactive: true, + }, + () => textarea('Description'), + ); + + expect(result).toBe('Jes'); + }); + + it('keeps rendered textarea output within the configured row window', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment( + { + input: createScriptedInput(['A', Key.enter, 'B', Key.enter, 'C', Key.ctrlD]), + output, + error: output, + interactive: true, + }, + () => textarea('Description', '', '', false, undefined, '', 2), + ); + + const latestFrame = (output.text().split('\u001B[36mDescription\u001B[39m').at(-1) ?? '').split('┌ \u001B[2mDescription\u001B[22m').at(0) ?? ''; + + expect(parseAnsiText(latestFrame)).toContain('│ B'); + expect(parseAnsiText(latestFrame)).toContain('│ C'); + expect(parseAnsiText(latestFrame)).not.toContain('A\nB\nC'); + expect(latestFrame).toContain('\u001B[2m│\u001B[22m'); + expect(latestFrame).toContain('\u001B[36m┃\u001B[39m'); + }); + + it('pads textarea frames to the configured rows', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment( + { + input: createScriptedInput(['A', Key.ctrlD]), + output, + error: output, + interactive: true, + }, + () => textarea('Description', '', '', false, undefined, 'Use full sentences.', 3), + ); + + const latestFrame = (output.text().split('\u001B[36mDescription\u001B[39m').at(-1) ?? '').split('┌ \u001B[2mDescription\u001B[22m').at(0) ?? ''; + + expect(parseAnsiText(latestFrame)).toContain('│ A'); + expect(parseAnsiText(latestFrame)).toContain('│ │'); + expect(output.text()).toContain('\u001B[2mUse full sentences.\u001B[22m'); + }); + + it('wraps long textarea lines inside the frame', async () => { + const output = createMemoryOutput(); + const longLine = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + const firstWrappedLine = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ01234567'; + + await withPromptEnvironment( + { + input: createScriptedInput([longLine, Key.ctrlD]), + output, + error: output, + interactive: true, + }, + () => textarea('Description', '', '', false, undefined, '', 2), + ); + + const latestFrame = (output.text().split('\u001B[36mDescription\u001B[39m').at(-1) ?? '').split('┌ \u001B[2mDescription\u001B[22m').at(0) ?? ''; + + expect(parseAnsiText(latestFrame)).toContain(`│ ${firstWrappedLine}`); + expect(parseAnsiText(latestFrame)).toContain('│ 89'); + expect(parseAnsiText(latestFrame)).not.toContain(`│ ${longLine}`); + }); + + it('moves the textarea cursor across wrapped rows before editing', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', Key.up, '!', Key.ctrlD]), + output, + error: output, + interactive: true, + }, + () => textarea('Description', '', '', false, undefined, '', 2), + ); + + expect(result).toBe('ab!cdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'); + }); + + it('uses control navigation keys to move between textarea lines', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['e', 's', 's', Key.enter, 'o', 'e', Key.ctrlP, Key.left, Key.left, 'J', Key.ctrlN, Key.left, 'J', Key.ctrlD]), + output, + error: output, + interactive: true, + }, + () => textarea('Description'), + ); + + expect(result).toBe('Jess\nJoe'); + }); + + it('can move back to an empty textarea line', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['J', 'e', 's', 's', Key.enter, Key.up, Key.down, 'J', 'o', 'e', Key.ctrlD]), + output, + error: output, + interactive: true, + }, + () => textarea('Description'), + ); + + expect(result).toBe('Jess\nJoe'); + }); + + it('moves through ascending textarea line lengths', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['a', Key.enter, 'b', 'c', Key.enter, 'd', 'e', 'f', Key.up, Key.up, Key.down, 'g', Key.ctrlD]), + output, + error: output, + interactive: true, + }, + () => textarea('Description'), + ); + + expect(result).toBe('a\nbgc\ndef'); + }); + + it('moves through descending textarea line lengths', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['a', 'b', 'c', Key.enter, 'd', 'e', Key.enter, 'f', Key.up, Key.up, Key.right, Key.right, Key.down, 'g', Key.ctrlD]), + output, + error: output, + interactive: true, + }, + () => textarea('Description'), + ); + + expect(result).toBe('abc\ndeg\nf'); + }); + + it('returns empty strings and defaults in non-interactive mode', async () => { + await expect( + withPromptEnvironment( + { + output: createMemoryOutput(), + error: createMemoryOutput(), + interactive: false, + }, + () => textarea('Description'), + ), + ).resolves.toBe(''); + + await expect( + withPromptEnvironment( + { + output: createMemoryOutput(), + error: createMemoryOutput(), + interactive: false, + }, + () => textarea('Description', '', 'Jess'), + ), + ).resolves.toBe('Jess'); + }); + + it('validates non-interactive default values', async () => { + await expect( + withPromptEnvironment( + { + output: createMemoryOutput(), + error: createMemoryOutput(), + interactive: false, + }, + () => textarea({ message: 'Description', required: true }), + ), + ).rejects.toThrow(PromptValidationError); + }); + + it('renders cancelled textarea frames with the current value', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['A', Key.enter, 'B', Key.ctrlC]), + output, + error: output, + interactive: true, + }, + () => textarea('Description'), + ); + + expect(result).toBe('A\nB'); + expect(output.text()).toContain('Cancelled.'); + expect(output.text()).toContain('\u001B[31m ┌\u001B[39m Description '); + expect(parseAnsiText(output.text())).toContain('A'); + expect(parseAnsiText(output.text())).toContain('B'); + }); +}); diff --git a/packages/tui/tests/typed-value.test.ts b/packages/tui/tests/typed-value.test.ts new file mode 100644 index 00000000..98604562 --- /dev/null +++ b/packages/tui/tests/typed-value.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, it } from 'vitest'; +import { applyTypedKey, Key } from '#tui/index'; +import { visibleLineWindow } from '#tui/typed-value/lines'; + +const apply = (keys: string[]): string => { + let state = { cursor: 0, value: '' }; + + for (const key of keys) { + const next = applyTypedKey(state, key); + + state = { cursor: next.cursor, value: next.value }; + } + + return state.value; +}; + +const applyMultiline = (keys: string[]): { submitted: boolean; value: string } => { + let state = { cursor: 0, value: '' }; + let submitted = false; + + for (const key of keys) { + const next = applyTypedKey(state, key, true); + + state = { cursor: next.cursor, value: next.value }; + submitted = next.submitted; + } + + return { submitted, value: state.value }; +}; + +const applyMultilineState = (value: string, cursor: number, keys: string[], wrapWidth?: number): { cursor: number; value: string } => { + let state = { cursor, value }; + + for (const key of keys) { + const next = applyTypedKey(state, key, true, wrapWidth); + + state = { cursor: next.cursor, value: next.value }; + } + + return state; +}; + +describe('typed value editing', () => { + it('inserts buffered characters', () => { + expect(apply(['Je', 'ss'])).toBe('Jess'); + }); + + it('handles backspace and delete', () => { + expect(apply(['J', 'e', 'z', Key.backspace, 's', 's'])).toBe('Jess'); + expect(apply(['J', 'e', 'z', Key.left, Key.delete, 's', 's'])).toBe('Jess'); + }); + + it('keeps delete at the end of input unchanged', () => { + expect(apply(['J', 'e', 's', 's', Key.delete])).toBe('Jess'); + }); + + it('deletes typed input back to the line start', () => { + expect(apply(['J', 'a', 'n', 'e', Key.left, Key.left, Key.ctrlU, 'D', 'o'])).toBe('Done'); + }); + + it('supports emacs-style movement keys', () => { + expect(apply(['J', 'z', 'e', Key.ctrlB, Key.ctrlH, Key.ctrlF, 's', 's'])).toBe('Jess'); + }); + + it('marks typed values as cancelled without changing the current value', () => { + expect(applyTypedKey({ cursor: 3, value: 'Ada' }, Key.ctrlC)).toEqual({ + cancelled: true, + cursor: 3, + submitted: false, + value: 'Ada', + }); + }); + + it('clamps UTF-16 cursor offsets to character positions before editing', () => { + expect(applyMultilineState('😀', '😀'.length, ['!'])).toEqual({ + cursor: 2, + value: '😀!', + }); + }); + + it('deletes the previous word after non-BMP characters', () => { + expect(applyMultilineState('😀 hello', [...'😀 hello'].length, [Key.optionBackspace])).toEqual({ + cursor: 2, + value: '😀 ', + }); + }); + + it('deletes the previous punctuation group as a word boundary', () => { + expect(applyMultilineState('hello --', [...'hello --'].length, [Key.optionBackspace])).toEqual({ + cursor: 6, + value: 'hello ', + }); + }); + + it('moves to the start and end of a line', () => { + expect(apply(['A', 'r', Key.home[0], 'J', Key.end[0], 'c', 'h', 'e', 'r'])).toBe('JArcher'); + }); + + it('submits multiline input with ctrl-d', () => { + expect(applyMultiline(['A', Key.enter, 'B', Key.ctrlD])).toEqual({ + submitted: true, + value: 'A\nB', + }); + }); + + it('moves the cursor between textarea lines', () => { + expect(applyMultilineState('abc\nde\nfghi', 6, [Key.up])).toEqual({ + cursor: 2, + value: 'abc\nde\nfghi', + }); + + expect(applyMultilineState('abc\nde\nfghi', 2, [Key.down])).toEqual({ + cursor: 6, + value: 'abc\nde\nfghi', + }); + + expect(applyMultilineState('abc\nde\nfghi', 6, [Key.down])).toEqual({ + cursor: 9, + value: 'abc\nde\nfghi', + }); + }); + + it('moves between textarea lines with control navigation keys', () => { + expect(applyMultilineState('abc\nde\nfghi', 6, [Key.ctrlP])).toEqual({ + cursor: 2, + value: 'abc\nde\nfghi', + }); + + expect(applyMultilineState('abc\nde\nfghi', 2, [Key.ctrlN])).toEqual({ + cursor: 6, + value: 'abc\nde\nfghi', + }); + }); + + it('moves the textarea cursor between wrapped rows', () => { + expect(applyMultilineState('abcdefghijkl', 7, [Key.up], 5)).toEqual({ + cursor: 2, + value: 'abcdefghijkl', + }); + + expect(applyMultilineState('abcdefghijkl', 2, [Key.down], 5)).toEqual({ + cursor: 7, + value: 'abcdefghijkl', + }); + + expect(applyMultilineState('abcdefghijkl', 11, [Key.up], 5)).toEqual({ + cursor: 6, + value: 'abcdefghijkl', + }); + }); + + it('moves to textarea line boundaries', () => { + expect(applyMultilineState('abc\nde\nfghi', 5, [Key.home[0]])).toEqual({ + cursor: 4, + value: 'abc\nde\nfghi', + }); + + expect(applyMultilineState('abc\nde\nfghi', 5, [Key.end[0]])).toEqual({ + cursor: 6, + value: 'abc\nde\nfghi', + }); + + expect(applyMultilineState('abc\nde\nfghi', 9, [Key.ctrlA, 'X', Key.ctrlE, 'Y'])).toEqual({ + cursor: 13, + value: 'abc\nde\nXfghiY', + }); + }); + + it('deletes textarea input back to the current line start', () => { + expect(applyMultilineState('abc\nde\nfghi', 9, [Key.ctrlU])).toEqual({ + cursor: 7, + value: 'abc\nde\nhi', + }); + }); + + it('reports visible textarea line windows', () => { + expect(visibleLineWindow('A\nB\nC', 5, 2)).toEqual({ + lines: ['B', 'C'], + start: 1, + total: 3, + }); + }); + + it('reports wrapped textarea line windows', () => { + expect(visibleLineWindow('abcdefghijkl', 12, 2, 5)).toEqual({ + lines: ['fghij', 'kl'], + start: 1, + total: 3, + }); + }); +}); diff --git a/packages/tui/tsconfig.build.json b/packages/tui/tsconfig.build.json new file mode 100644 index 00000000..4acf1e5e --- /dev/null +++ b/packages/tui/tsconfig.build.json @@ -0,0 +1,15 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "emitDeclarationOnly": false, + "noEmit": false, + "outDir": "dist", + "rootDir": "src", + "sourceMap": true, + "tsBuildInfoFile": "../../provision/.cache/tsbuild/tui-build.tsbuildinfo" + }, + "exclude": ["dist", "tests", "vitest.config.ts"], + "include": ["src/**/*.ts"] +} diff --git a/packages/tui/tsconfig.json b/packages/tui/tsconfig.json new file mode 100644 index 00000000..e222b8e8 --- /dev/null +++ b/packages/tui/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": ["@ollin/tsconfig/base.json", "@ollin/artefacts/tsconfig/tui.json"], + "compilerOptions": { + "baseUrl": ".", + "paths": { + "#tui/*": ["./src/*"], + "#tui/prompts/*": ["./src/prompts/*"] + } + }, + "include": ["src/**/*.ts", "vitest.config.ts"] +} diff --git a/packages/tui/vitest.config.ts b/packages/tui/vitest.config.ts new file mode 100644 index 00000000..a5e4b2f4 --- /dev/null +++ b/packages/tui/vitest.config.ts @@ -0,0 +1,24 @@ +import { dirname, join } from 'node:path'; +import { defineConfig } from 'vitest/config'; +import { fileURLToPath } from 'node:url'; + +const packagePath = dirname(fileURLToPath(import.meta.url)); +const workspacePath = dirname(dirname(packagePath)); +const cachePath = join(workspacePath, 'provision', '.cache', 'vitest', 'tui'); +const sourcePath = join(packagePath, 'src', '$1'); + +export default defineConfig({ + cacheDir: cachePath, + resolve: { + alias: [ + { + find: /^#tui\/(.+)$/u, + replacement: sourcePath, + }, + ], + }, + test: { + environment: 'node', + globals: false, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 00000000..93bbd017 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,3379 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@ollin/artefacts': + specifier: workspace:* + version: link:packages/artefacts + '@ollin/tsconfig': + specifier: workspace:* + version: link:packages/tsconfig + '@types/node': + specifier: ^25.6.2 + version: 25.9.2 + turbo: + specifier: ^2.9.12 + version: 2.9.17 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + vitest: + specifier: ^4.1.5 + version: 4.1.8(@types/node@25.9.2)(vite@8.0.16(@types/node@25.9.2)(jiti@2.7.0)(yaml@2.9.0)) + + packages/acceptance: + dependencies: + '@ollin/tui': + specifier: workspace:* + version: link:../tui + '@opentui/core': + specifier: ^0.4.0 + version: 0.4.0(typescript@6.0.3)(web-tree-sitter@0.25.10) + devDependencies: + '@ollin/artefacts': + specifier: workspace:* + version: link:../artefacts + '@ollin/tsconfig': + specifier: workspace:* + version: link:../tsconfig + '@types/node': + specifier: ^25.6.2 + version: 25.9.2 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + vitest: + specifier: ^4.1.5 + version: 4.1.8(@types/node@25.9.2)(vite@8.0.16(@types/node@25.9.2)(jiti@2.7.0)(yaml@2.9.0)) + + packages/artefacts: + devDependencies: + '@ollin/tsconfig': + specifier: workspace:* + version: link:../tsconfig + '@types/node': + specifier: ^25.6.2 + version: 25.9.2 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + + packages/docs: + dependencies: + '@fontsource/geist-mono': + specifier: ^5.2.8 + version: 5.2.8 + '@fontsource/geist-sans': + specifier: ^5.2.5 + version: 5.2.5 + '@ollin/tui': + specifier: workspace:* + version: link:../tui + '@vueuse/core': + specifier: ^14.3.0 + version: 14.3.0(vue@3.5.38(typescript@6.0.3)) + '@xterm/xterm': + specifier: ^6.0.0 + version: 6.0.0 + reka-ui: + specifier: ^2.9.10 + version: 2.9.10(vue@3.5.38(typescript@6.0.3)) + devDependencies: + '@ollin/tsconfig': + specifier: workspace:* + version: link:../tsconfig + '@tailwindcss/vite': + specifier: ^4.3.0 + version: 4.3.0(vite@8.0.16(@types/node@25.9.2)(jiti@2.7.0)(yaml@2.9.0)) + '@types/node': + specifier: ^25.6.2 + version: 25.9.2 + class-variance-authority: + specifier: ^0.7.1 + version: 0.7.1 + clsx: + specifier: ^2.1.1 + version: 2.1.1 + tailwind-merge: + specifier: ^3.6.0 + version: 3.6.0 + tailwindcss: + specifier: ^4.3.0 + version: 4.3.0 + typedoc: + specifier: ^0.28.19 + version: 0.28.19(typescript@6.0.3) + typedoc-plugin-markdown: + specifier: ^4.12.0 + version: 4.12.0(typedoc@0.28.19(typescript@6.0.3)) + typescript: + specifier: ^6.0.3 + version: 6.0.3 + vite: + specifier: ^8.0.16 + version: 8.0.16(@types/node@25.9.2)(jiti@2.7.0)(yaml@2.9.0) + vitepress: + specifier: ^1.6.4 + version: 1.6.4(@algolia/client-search@5.53.0)(@types/node@25.9.2)(lightningcss@1.32.0)(postcss@8.5.15)(search-insights@2.17.3)(typescript@6.0.3) + vitest: + specifier: ^4.1.5 + version: 4.1.8(@types/node@25.9.2)(vite@8.0.16(@types/node@25.9.2)(jiti@2.7.0)(yaml@2.9.0)) + vue: + specifier: ^3.5.38 + version: 3.5.38(typescript@6.0.3) + + packages/tsconfig: {} + + packages/tui: + dependencies: + '@opentui/core': + specifier: ^0.4.0 + version: 0.4.0(typescript@6.0.3)(web-tree-sitter@0.25.10) + string-width: + specifier: ^7.2.0 + version: 7.2.0 + strip-ansi: + specifier: ^7.1.2 + version: 7.2.0 + zod: + specifier: ^4.4.3 + version: 4.4.3 + devDependencies: + '@ollin/artefacts': + specifier: workspace:* + version: link:../artefacts + '@ollin/tsconfig': + specifier: workspace:* + version: link:../tsconfig + '@types/node': + specifier: ^25.6.2 + version: 25.9.2 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + vitest: + specifier: ^4.1.5 + version: 4.1.8(@types/node@25.9.2)(vite@8.0.16(@types/node@25.9.2)(jiti@2.7.0)(yaml@2.9.0)) + + provision/go: {} + + provision/ts: + devDependencies: + '@ollin/tsconfig': + specifier: workspace:* + version: link:../../packages/tsconfig + +packages: + + '@algolia/abtesting@1.19.0': + resolution: {integrity: sha512-Lhnez3hhXHk25lfxLAMxvkP4fmN3+1RgADhD2ssMDBYuAsDVReeyP+3SGRx+ntq8ijMrLqUyfvO72TB6jsTteQ==} + engines: {node: '>= 14.0.0'} + + '@algolia/autocomplete-core@1.17.7': + resolution: {integrity: sha512-BjiPOW6ks90UKl7TwMv7oNQMnzU+t/wk9mgIDi6b1tXpUek7MW0lbNOUHpvam9pe3lVCf4xPFT+lK7s+e+fs7Q==} + + '@algolia/autocomplete-plugin-algolia-insights@1.17.7': + resolution: {integrity: sha512-Jca5Ude6yUOuyzjnz57og7Et3aXjbwCSDf/8onLHSQgw1qW3ALl9mrMWaXb5FmPVkV3EtkD2F/+NkT6VHyPu9A==} + peerDependencies: + search-insights: '>= 1 < 3' + + '@algolia/autocomplete-preset-algolia@1.17.7': + resolution: {integrity: sha512-ggOQ950+nwbWROq2MOCIL71RE0DdQZsceqrg32UqnhDz8FlO9rL8ONHNsI2R1MH0tkgVIDKI/D0sMiUchsFdWA==} + peerDependencies: + '@algolia/client-search': '>= 4.9.1 < 6' + algoliasearch: '>= 4.9.1 < 6' + + '@algolia/autocomplete-shared@1.17.7': + resolution: {integrity: sha512-o/1Vurr42U/qskRSuhBH+VKxMvkkUVTLU6WZQr+L5lGZZLYWyhdzWjW0iGXY7EkwRTjBqvN2EsR81yCTGV/kmg==} + peerDependencies: + '@algolia/client-search': '>= 4.9.1 < 6' + algoliasearch: '>= 4.9.1 < 6' + + '@algolia/client-abtesting@5.53.0': + resolution: {integrity: sha512-0ZjA5Hcmaoz5Lj6OG0zhfIyeqzJZnLW2CRJA1W17UwMFGRtZAJ9yJKRvPEDA6gkpsIoQxORTSW6sWFiuYncPNQ==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-analytics@5.53.0': + resolution: {integrity: sha512-kWNodP75iiEaOtemC9F/hlxNBG5E2QUjN1BusnE6m2b4l7Qh/BUO3fGCVsmKJI65VO4VKGGmT43ICvHtTcJ2JQ==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-common@5.53.0': + resolution: {integrity: sha512-YPN45TXD9Wrse185t/Ta7nktZsqpv97oOjCzp2sblHnCL6rBc9TDeJAg1IGl2UpdwnSD05Zu/5wLB4watOUMyg==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-insights@5.53.0': + resolution: {integrity: sha512-qAcYTDJE6m924FDDUQvdD6vh7DYaqOeSpFS74IP37/JRV0v4cGBauyxTF2WzDnokUylQDbqreoFIJZfg0Fitmw==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-personalization@5.53.0': + resolution: {integrity: sha512-fQaY+DkSJOpuUVUe8MQTwrdiKAqkJGhpDarB08duBn/sUv7Bkib6MDRQauCcWTWTe4HIW+EbwQP9R4kci1V/Yw==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-query-suggestions@5.53.0': + resolution: {integrity: sha512-o72tsiEZGfeS/dxL9IADfzcZWGEwKDEe5CvtrBuT//3JR+SHuTtHRI2ZTf7D7bcKagcbojvO8hnkHdfoakSlYg==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-search@5.53.0': + resolution: {integrity: sha512-Ds16IyPm/dNJPCU8OzApo2gwGrgWT5BYHhE3NFwZbpCveqyvPDB9sZDDkJ5DsdOGT2aC+R3i0/M1OVXF2qdgPg==} + engines: {node: '>= 14.0.0'} + + '@algolia/ingestion@1.53.0': + resolution: {integrity: sha512-oNbT6z4NwD8Pou9VPINGlN/tlG1afESh2EbxqnP6rwl95xKVD/Zlciis1PpNeO/9U/rrajc1+7DcfKi03tX1KQ==} + engines: {node: '>= 14.0.0'} + + '@algolia/monitoring@1.53.0': + resolution: {integrity: sha512-G+KZb/yd+qAOFn/cEvTGeLxQm8aP3a0od50l3z/ylccY+/o4YG3TNcjU1tFQHW4mXC137GPyR7W70R0kRQDLnA==} + engines: {node: '>= 14.0.0'} + + '@algolia/recommend@5.53.0': + resolution: {integrity: sha512-6aVfYd55Un6IUgPLbo84WfgFZlS3L0vA1ttzXL5vahHewUJ8jYgd89TzlWRTeej7w70mb9RWsVlFYGmJ/diQww==} + engines: {node: '>= 14.0.0'} + + '@algolia/requester-browser-xhr@5.53.0': + resolution: {integrity: sha512-ke27DqgzCOlt+RbeEdCxtXxMQOnAOi8ujr2wid0DmDKzR95Kw/f9sBsuhBxtjevCqJRJszfRTLY0B1pbO6IhkA==} + engines: {node: '>= 14.0.0'} + + '@algolia/requester-fetch@5.53.0': + resolution: {integrity: sha512-GngiOqt2Gq4oLno6yXQVj9om+qSO9SWAoduoTOEg79dKZ62brB8OOIvSJG/vDNoanYi6a7Al9uDZwXvi+bcVTg==} + engines: {node: '>= 14.0.0'} + + '@algolia/requester-node-http@5.53.0': + resolution: {integrity: sha512-6mF9LZMUk0QqWvrnxkxBqhswwz6Xfiwy6/gmTzL5HrlhdVG3ITAqGV2k3XmVThP1h0Ulc3VQwiNCD7/Nr4JNlQ==} + engines: {node: '>= 14.0.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + + '@docsearch/css@3.8.2': + resolution: {integrity: sha512-y05ayQFyUmCXze79+56v/4HpycYF3uFqB78pLPrSV5ZKAlDuIAAJNhaRi8tTdRNXh05yxX/TyNnzD6LwSM89vQ==} + + '@docsearch/js@3.8.2': + resolution: {integrity: sha512-Q5wY66qHn0SwA7Taa0aDbHiJvaFJLOJyHmooQ7y8hlwwQLQ/5WwCcoX0g7ii04Qi2DJlHsd0XXzJ8Ypw9+9YmQ==} + + '@docsearch/react@3.8.2': + resolution: {integrity: sha512-xCRrJQlTt8N9GU0DG4ptwHRkfnSnD/YpdeaXe02iKfqs97TkZJv60yE+1eq/tjPcVnTW8dP5qLP7itifFVV5eg==} + peerDependencies: + '@types/react': '>= 16.8.0 < 19.0.0' + react: '>= 16.8.0 < 19.0.0' + react-dom: '>= 16.8.0 < 19.0.0' + search-insights: '>= 1 < 3' + peerDependenciesMeta: + '@types/react': + optional: true + react: + optional: true + react-dom: + optional: true + search-insights: + optional: true + + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@floating-ui/core@1.7.5': + resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} + + '@floating-ui/dom@1.7.6': + resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} + + '@floating-ui/utils@0.2.11': + resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + + '@floating-ui/vue@1.1.11': + resolution: {integrity: sha512-HzHKCNVxnGS35r9fCHBc3+uCnjw9IWIlCPL683cGgM9Kgj2BiAl8x1mS7vtvP6F9S/e/q4O6MApwSHj8hNLGfw==} + + '@fontsource/geist-mono@5.2.8': + resolution: {integrity: sha512-YqZUb3X42GjRaHkibUNyE8K4Rk9A6I9Ez81YpJYiuJN4ggmTW2ixoQpa9EWCPfXZtIsCQJTyrDoV9OJOZO/UcA==} + + '@fontsource/geist-sans@5.2.5': + resolution: {integrity: sha512-anllOHyJbElRs9fV15TeDRqAeb1IKm4bSknPl6ZMoyPTx1BBy7logudcUwpNjmQLkzn4Q0JGQLRCUKJYoyST6A==} + + '@gerrit0/mini-shiki@3.23.0': + resolution: {integrity: sha512-bEMORlG0cqdjVyCEuU0cDQbORWX+kYCeo0kV1lbxF5bt4r7SID2l9bqsxJEM0zndaxpOUT7riCyIVEuqq/Ynxg==} + + '@iconify-json/simple-icons@1.2.86': + resolution: {integrity: sha512-t3jck5qPQuK1qy+bRn9eCoDQhIB7XSazKz1Fjp8hcan3XOAsTI5Mq/s3F0ekOKSvMQqkVORYK6ns6o6T9f5EMA==} + + '@iconify/types@2.0.0': + resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} + + '@internationalized/date@3.12.2': + resolution: {integrity: sha512-FY1Y+H64NDs+HAF6omlnWxm3mEpfgaCSWtL5l551ZZfImA+kGjPFgrnJrGjH6lfmLL0g8Z/mBu1R3kufeCp6Jw==} + + '@internationalized/number@3.6.7': + resolution: {integrity: sha512-3ji1fcrT+FPAK86UqEhB/psHixYo6niWPJtt7+qRaYFynt/BaJG8GhAPimtWUpEiVSTq8ZM8L5psMxGquiB/Vg==} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@napi-rs/wasm-runtime@1.1.5': + resolution: {integrity: sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@opentui/core-darwin-arm64@0.4.0': + resolution: {integrity: sha512-t7VGLn2LyCGWSrhYdQ2xnq0L0sjg6VOdM6OyFxrZC3HU69m+PXqIhWMpGw/m8V6W7//uM1RX2GQrB/mgJ1Ce0Q==} + cpu: [arm64] + os: [darwin] + + '@opentui/core-darwin-x64@0.4.0': + resolution: {integrity: sha512-/3goEuLFxn9df4fsLw9MSzbp8T3Rcs0NltCS3k73LcCb27pBmHH2FFNDKIj96m5ktK2JIWglEXcX85/i1qy4ug==} + cpu: [x64] + os: [darwin] + + '@opentui/core-linux-arm64-musl@0.4.0': + resolution: {integrity: sha512-8SL+HCpXc1Ott4MYfPzBdNM6XUhknBlqAKOmdDnTxkFSnnadJxhBJo+JYX1jxQP/lkd3jKUpEKDc51k9wud4tg==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@opentui/core-linux-arm64@0.4.0': + resolution: {integrity: sha512-KBv05c0e+FRUpPKRqrE5bDmmIhiJsho43EDXJuYOUYJAuGsTii8J8ws0Q4GYBIxZa/atG1Wv3rEKR7+RSd29lA==} + cpu: [arm64] + os: [linux] + + '@opentui/core-linux-x64-musl@0.4.0': + resolution: {integrity: sha512-EpBHP5S8O83VgI4YIM/CPZTUVIGJjYyk+dcYJl76phtRYF4CwZTvomQqyHb0lzXGZ7LwSDoQxsWtLs/hEOVyPA==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@opentui/core-linux-x64@0.4.0': + resolution: {integrity: sha512-n2aZdF2vkzXyV0C0JL9Ok3EDImtWqewZVcFutMkCRK+EQk3a4oa+bl2Y07XpxGN8FZyJxS+R/HWSoDbKBNKDAg==} + cpu: [x64] + os: [linux] + + '@opentui/core-win32-arm64@0.4.0': + resolution: {integrity: sha512-nUoKrHTHDMyqDGSqN7XfKvjaevgS460Pd99tzLCY4rTZcyDTdEUDfnLgEQrkURRCfUsBbwB1lNnq1aP4u+GeZQ==} + cpu: [arm64] + os: [win32] + + '@opentui/core-win32-x64@0.4.0': + resolution: {integrity: sha512-3uHjGfgjhw+tyxDoF6bs11Gbrt+8VP7k6vAMm9Qyvd8cExDFaNcR1E5Tf/6yR7oxGfh9E951ALue8M7h6l/lLQ==} + cpu: [x64] + os: [win32] + + '@opentui/core@0.4.0': + resolution: {integrity: sha512-G3TmJmaPoxD6SadwevZNE30H/pMZsr/qneVaKc7bmFBxA+uHgxFSAsMoFYaEqcFJM1dGt22kMJb+YY2ZahzqVw==} + peerDependencies: + web-tree-sitter: 0.25.10 + + '@oxc-project/types@0.133.0': + resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==} + + '@rolldown/binding-android-arm64@1.0.3': + resolution: {integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.3': + resolution: {integrity: sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.3': + resolution: {integrity: sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.3': + resolution: {integrity: sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.3': + resolution: {integrity: sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.3': + resolution: {integrity: sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.0.3': + resolution: {integrity: sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.0.3': + resolution: {integrity: sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.0.3': + resolution: {integrity: sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.0.3': + resolution: {integrity: sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.0.3': + resolution: {integrity: sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.0.3': + resolution: {integrity: sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.3': + resolution: {integrity: sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.3': + resolution: {integrity: sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.3': + resolution: {integrity: sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@rollup/rollup-android-arm-eabi@4.61.1': + resolution: {integrity: sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.61.1': + resolution: {integrity: sha512-Jx2g7iSjw4AOT0HDPHM9RV3GNjRXwybWtSFZiZAYUTjUwjVrYIwq3kBf+LnhqJlzXFAqTAh2F7IGI+O568exPw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.61.1': + resolution: {integrity: sha512-0F1L/Z3Eqv8mT2n3dCpeO8GcTvHvVqkP5/t6DMsn0KzhYVcg+s7Ncl5DS8qjKYEeio6Az0Gt6nyBORay5qIlCA==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.61.1': + resolution: {integrity: sha512-qLttcH871ujY4YcVfUSShhOw+CsoTatYz8gRbHO7Bb92QH059/P0y5do1KMs41fY0BpD2x4AJH/gID0zFiqVKQ==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.61.1': + resolution: {integrity: sha512-fUI4RapGE0Oh3mb8mgfvC1O2nU1RpDZUKnDQm3xB1Ipg7C2wTs5Kstz7G2uWK99a8S2yTMq8/P4uycwNa0nJyw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.61.1': + resolution: {integrity: sha512-H5YrdvJaDtI/U9/emrD4b++xkvp3y/JvOe4rizHbxvkyMfRS/CiRYdji+Pl8D0brEaNFWUh1drQxgAGIl6Xudw==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.61.1': + resolution: {integrity: sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.61.1': + resolution: {integrity: sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.61.1': + resolution: {integrity: sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.61.1': + resolution: {integrity: sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.61.1': + resolution: {integrity: sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.61.1': + resolution: {integrity: sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.61.1': + resolution: {integrity: sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.61.1': + resolution: {integrity: sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.61.1': + resolution: {integrity: sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.61.1': + resolution: {integrity: sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.61.1': + resolution: {integrity: sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.61.1': + resolution: {integrity: sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.61.1': + resolution: {integrity: sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.61.1': + resolution: {integrity: sha512-E83TXjI4zm0+5f2qO+UOudaCYIhYwpJ5jq6YCZNIZ+6CbfhKrkAGezeiASBL9ElxAxFsRS9ZhESv8mfnj6TKeg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.61.1': + resolution: {integrity: sha512-fbWnKqVkjrJN38vNe3ahkbk6iejS/3b0Nt7EEtPpE6RBacZcGXNKbzfHN3GUUlXOPghUg0j6XUGrtjX9z1sIvA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.61.1': + resolution: {integrity: sha512-ArMl38iVAbk0New1ogihQNY6iphLi4ZaRsa037gUzv5yeKPY8TD3Dmy4x2RNC1VztU/uqm+G+/RwFrSka3Oy2g==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.61.1': + resolution: {integrity: sha512-0mYtjHS9ucAbcATycCNK9IGBk/cCe/ma7EmSLGZdsxnOA8cjRIyU04wDpVAD9NiOfLUR9KTxdiO53uOkherqjQ==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.61.1': + resolution: {integrity: sha512-gK1iCEPfpoSG9wfBihXxvBMi8ZfcWffYkEsC/Eih+iFENTaewvNcrEQ69lIOWYO5pePHKLHHO7nq5AILGO/HQQ==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.61.1': + resolution: {integrity: sha512-X+zaP2x+j4RXGfbp/seSoRHWnPxzApilDszisZxbYH5C/jTxFhCtDNdPGZb9lJyYPs24wGxruPF7Y+sIXt9Gzw==} + cpu: [x64] + os: [win32] + + '@shikijs/core@2.5.0': + resolution: {integrity: sha512-uu/8RExTKtavlpH7XqnVYBrfBkUc20ngXiX9NSrBhOVZYv/7XQRKUyhtkeflY5QsxC0GbJThCerruZfsUaSldg==} + + '@shikijs/engine-javascript@2.5.0': + resolution: {integrity: sha512-VjnOpnQf8WuCEZtNUdjjwGUbtAVKuZkVQ/5cHy/tojVVRIRtlWMYVjyWhxOmIq05AlSOv72z7hRNRGVBgQOl0w==} + + '@shikijs/engine-oniguruma@2.5.0': + resolution: {integrity: sha512-pGd1wRATzbo/uatrCIILlAdFVKdxImWJGQ5rFiB5VZi2ve5xj3Ax9jny8QvkaV93btQEwR/rSz5ERFpC5mKNIw==} + + '@shikijs/engine-oniguruma@3.23.0': + resolution: {integrity: sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==} + + '@shikijs/langs@2.5.0': + resolution: {integrity: sha512-Qfrrt5OsNH5R+5tJ/3uYBBZv3SuGmnRPejV9IlIbFH3HTGLDlkqgHymAlzklVmKBjAaVmkPkyikAV/sQ1wSL+w==} + + '@shikijs/langs@3.23.0': + resolution: {integrity: sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==} + + '@shikijs/themes@2.5.0': + resolution: {integrity: sha512-wGrk+R8tJnO0VMzmUExHR+QdSaPUl/NKs+a4cQQRWyoc3YFbUzuLEi/KWK1hj+8BfHRKm2jNhhJck1dfstJpiw==} + + '@shikijs/themes@3.23.0': + resolution: {integrity: sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==} + + '@shikijs/transformers@2.5.0': + resolution: {integrity: sha512-SI494W5X60CaUwgi8u4q4m4s3YAFSxln3tzNjOSYqq54wlVgz0/NbbXEb3mdLbqMBztcmS7bVTaEd2w0qMmfeg==} + + '@shikijs/types@2.5.0': + resolution: {integrity: sha512-ygl5yhxki9ZLNuNpPitBWvcy9fsSKKaRuO4BAlMyagszQidxcpLAr0qiW/q43DtSIDxO6hEbtYLiFZNXO/hdGw==} + + '@shikijs/types@3.23.0': + resolution: {integrity: sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==} + + '@shikijs/vscode-textmate@10.0.2': + resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@swc/helpers@0.5.23': + resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} + + '@tailwindcss/node@4.3.0': + resolution: {integrity: sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==} + + '@tailwindcss/oxide-android-arm64@4.3.0': + resolution: {integrity: sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.3.0': + resolution: {integrity: sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.3.0': + resolution: {integrity: sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.3.0': + resolution: {integrity: sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0': + resolution: {integrity: sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.0': + resolution: {integrity: sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-arm64-musl@4.3.0': + resolution: {integrity: sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-linux-x64-gnu@4.3.0': + resolution: {integrity: sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-x64-musl@4.3.0': + resolution: {integrity: sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-wasm32-wasi@4.3.0': + resolution: {integrity: sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.0': + resolution: {integrity: sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.3.0': + resolution: {integrity: sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.3.0': + resolution: {integrity: sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==} + engines: {node: '>= 20'} + + '@tailwindcss/vite@4.3.0': + resolution: {integrity: sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==} + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 || ^8 + + '@tanstack/virtual-core@3.17.0': + resolution: {integrity: sha512-gOxY/hFkPh/XQYhnThBHzkbkX3Ed+z/iushyz+R+JAr213aXxUDgQoTgTdrDpBSRsjFM73P/KfUyWmaF9WHMkQ==} + + '@tanstack/vue-virtual@3.13.28': + resolution: {integrity: sha512-A+jWpXtMpWXKhGLKQrXeC9mk1VgYeMWSJ+o0CTCEi+HLYMSQFdVmPG9lJz7d4XJyIkc5xVwZU9QY67QpScqnxA==} + peerDependencies: + vue: ^2.7.0 || ^3.0.0 + + '@turbo/darwin-64@2.9.17': + resolution: {integrity: sha512-io5jn5RDeU+9YV78rWhwG++HD/OZ/Lxg1sg93+jDGKQNP3UDxY6RX2dmarbCILhNxNuAM8FH3WgGMY9E96Mf8w==} + cpu: [x64] + os: [darwin] + + '@turbo/darwin-arm64@2.9.17': + resolution: {integrity: sha512-83YZTYmN2sxFWf2LTMOwqbOvR3qZMa/TSFwnB6BHVBbIWyoPPe+TAdSTd8KevEx8ml8KkycJ/9A70DFVReyUww==} + cpu: [arm64] + os: [darwin] + + '@turbo/linux-64@2.9.17': + resolution: {integrity: sha512-teKfwJg0zSC+C2ZSOsX3VnAJGVgcN+pgKNmnGWzcpXQ9eIkAQtYP+getrQ2f1Tw/ePudnreQhq8tVP8S73Vy6Q==} + cpu: [x64] + os: [linux] + + '@turbo/linux-arm64@2.9.17': + resolution: {integrity: sha512-mqO36x2CNtJ9CCbEf5xOqH662tVSc1wB0mxh7dopBpgXg0fEifdzwX0IEAUW1WUAaNH986L7iEUUgw3HNqUWgg==} + cpu: [arm64] + os: [linux] + + '@turbo/windows-64@2.9.17': + resolution: {integrity: sha512-jbyoNePufyMoSSrvVr+/mglcjmya/MOgoIrSHPr67iZ1VFgrlMQXHXtptR2lR48gi+86b1XBvsviJZ9A7zrydg==} + cpu: [x64] + os: [win32] + + '@turbo/windows-arm64@2.9.17': + resolution: {integrity: sha512-+ql0wYc99Y2AMvyHCcC/P+xtyV4nz522L+C9HDnyi7ryHXBGM+ZjBP28M7SLBGMDImgpN8sk2szpgbvreMeXVA==} + cpu: [arm64] + os: [win32] + + '@tybys/wasm-util@0.10.2': + resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/hast@3.0.4': + resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + + '@types/linkify-it@5.0.0': + resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} + + '@types/markdown-it@14.1.2': + resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==} + + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + + '@types/mdurl@2.0.0': + resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==} + + '@types/node@25.9.2': + resolution: {integrity: sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw==} + + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + + '@types/web-bluetooth@0.0.21': + resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} + + '@ungap/structured-clone@1.3.1': + resolution: {integrity: sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==} + + '@vitejs/plugin-vue@5.2.4': + resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==} + engines: {node: ^18.0.0 || >=20.0.0} + peerDependencies: + vite: ^5.0.0 || ^6.0.0 + vue: ^3.2.25 + + '@vitest/expect@4.1.8': + resolution: {integrity: sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==} + + '@vitest/mocker@4.1.8': + resolution: {integrity: sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.8': + resolution: {integrity: sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==} + + '@vitest/runner@4.1.8': + resolution: {integrity: sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==} + + '@vitest/snapshot@4.1.8': + resolution: {integrity: sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==} + + '@vitest/spy@4.1.8': + resolution: {integrity: sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==} + + '@vitest/utils@4.1.8': + resolution: {integrity: sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==} + + '@vue/compiler-core@3.5.37': + resolution: {integrity: sha512-TfQz4bsBQTPoTeBWTUPJPq+4FCTTXg2pbp8TjjAyrGaLAu9nfrZTxKLf6mdAlclnwtyUToFaMQu7QRS63Qek1g==} + + '@vue/compiler-core@3.5.38': + resolution: {integrity: sha512-s99aGxWYig9ErHbct27KXEGhrBYlRI6c4MwAgXErOAbX9xiW37/uMa+XUDO69zLz83dng8UUZ70CTOJrLrYrEQ==} + + '@vue/compiler-dom@3.5.37': + resolution: {integrity: sha512-oqfl/QaCVEWxphALFEZ7m+q9z+Sghz9ZCcoJ/oplTGxsOgx2czVzSZxkMkzQrWIahywOeyGHdg9ml/WUz3DMzw==} + + '@vue/compiler-dom@3.5.38': + resolution: {integrity: sha512-JTqp25l8aFfJYF7/KmsXZjAxJz7T+SjmTJLoXVjHtc2BrSgSiW2n9Aem/cWq1OPe68A8JL06B3eVdhlP0H4TVw==} + + '@vue/compiler-sfc@3.5.37': + resolution: {integrity: sha512-hYu+efs678xaPHYxhFRK3ZhkQ/FueMVnROooZqemOYlyQBQg06qkIrpyAUrUWWqMLfifgOdWwU6CL6FuTRvP4A==} + + '@vue/compiler-sfc@3.5.38': + resolution: {integrity: sha512-DuA2GiZawSEW442iw/9+Fkol8hTgb4Ke5KkhmSry65QA7YuyMbIdy8p0XZRMvNwJdgRz307W8g1CSzdvS4nuNg==} + + '@vue/compiler-ssr@3.5.37': + resolution: {integrity: sha512-ihbdCLJLXFKV3efEQlFfzy6TLHRuOQ/+dze2vZfg0DIncVxkcUxwCqPewCiSVdWXFeoiuMMON87wpt+G+yT22A==} + + '@vue/compiler-ssr@3.5.38': + resolution: {integrity: sha512-7s+W5Gc42FGxZMcuwl8H5B29T8BJPMdBT7KHFE+BbAuZ/iTEdTtv7z2XiMjiaUUw4w3ZcCEdHs36RuYJ2VA7bA==} + + '@vue/devtools-api@7.7.9': + resolution: {integrity: sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==} + + '@vue/devtools-kit@7.7.9': + resolution: {integrity: sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==} + + '@vue/devtools-shared@7.7.9': + resolution: {integrity: sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==} + + '@vue/reactivity@3.5.37': + resolution: {integrity: sha512-M7j7YF68IUd2uFNIqhwybpzUG/Sk9HUtk+ULmC+g6JeZ80LyCyGnjv6SYBR86t3fyyuYlZUSb18yu4UYLgw5jg==} + + '@vue/reactivity@3.5.38': + resolution: {integrity: sha512-pG6LV/NDNRbKizcUjFFLAfjaL8mcv4DmR9avNcUw2gDHBzZneuS2TWCmp633ynzxz9YYKNeEPK2I8Wraqy2HUQ==} + + '@vue/runtime-core@3.5.37': + resolution: {integrity: sha512-N0IWRirNPzJp/DuUCR9M+obVUHZArMkmldRApKsJRIWA+XDO6iwF4Zh94HP6uCzYVgWVwr8YuKeWF4H52VxTbw==} + + '@vue/runtime-core@3.5.38': + resolution: {integrity: sha512-iyW8WVfF1CpCXxncZY5Ei6rSd6oZr5DgEom//fUjRBRl56AXPD+s9ATvukRt77ZFTuYlnVA1bxY+dJB94tWVYw==} + + '@vue/runtime-dom@3.5.37': + resolution: {integrity: sha512-9VkutCFwfVOiMRH7mgi7QapsqC8Hxcow3DLvBKf+mRH88P94Ib/D/u6l/ln62ST+fIvmsOO7+Db99LzWv9slJg==} + + '@vue/runtime-dom@3.5.38': + resolution: {integrity: sha512-apX2wt9sdfDshS+a2xueFZLVpt0GkRJZSoPmrW/SA4yzXTznhfcMVW59gr7h4YQeY0vJhdJkk2rsIDwgfFgC5A==} + + '@vue/server-renderer@3.5.37': + resolution: {integrity: sha512-CP7nbxJb1Zc0/oeBqu6CtMf9TN64fz6qE75BZ8mWh04zAEya8EAZmqH2gRQWkoUUjFqv9i7h/mV+E4/LL4JXXw==} + peerDependencies: + vue: 3.5.37 + + '@vue/server-renderer@3.5.38': + resolution: {integrity: sha512-vue8vbf2QlV4quHqzwmJy6dWfmRhP1J8l4wtZg60CL6VoKqcPY2oe7may3+1d9qfpedjK5PRLFqd5k3Isj9mUw==} + peerDependencies: + vue: 3.5.38 + + '@vue/shared@3.5.37': + resolution: {integrity: sha512-JzFx4aYGz+EtBl8zzw8XECNWSmNXTrU5jpub3T1lQ+2X5Ys9QzHWafxhE+E5qS2Ry/mVl8o2QqrwRGYYOFYguw==} + + '@vue/shared@3.5.38': + resolution: {integrity: sha512-FTW0AFZNaK5/mOqvGBwVfUlNLU38TiQn4+DQgIFUnrBBJQ1crMJ82yeGQLV5jyKFsO8yRukpbuP7x+nRbH6aug==} + + '@vueuse/core@12.8.2': + resolution: {integrity: sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==} + + '@vueuse/core@14.3.0': + resolution: {integrity: sha512-aHfz47g0ZhMtTVHmIzMVpJy8ePhhOy68GY5bv110+5DVtZ+W7BsOx+m61UNQqfrWyPztIHIanWa3E2tib3NFIw==} + peerDependencies: + vue: ^3.5.0 + + '@vueuse/integrations@12.8.2': + resolution: {integrity: sha512-fbGYivgK5uBTRt7p5F3zy6VrETlV9RtZjBqd1/HxGdjdckBgBM4ugP8LHpjolqTj14TXTxSK1ZfgPbHYyGuH7g==} + peerDependencies: + async-validator: ^4 + axios: ^1 + change-case: ^5 + drauu: ^0.4 + focus-trap: ^7 + fuse.js: ^7 + idb-keyval: ^6 + jwt-decode: ^4 + nprogress: ^0.2 + qrcode: ^1.5 + sortablejs: ^1 + universal-cookie: ^7 + peerDependenciesMeta: + async-validator: + optional: true + axios: + optional: true + change-case: + optional: true + drauu: + optional: true + focus-trap: + optional: true + fuse.js: + optional: true + idb-keyval: + optional: true + jwt-decode: + optional: true + nprogress: + optional: true + qrcode: + optional: true + sortablejs: + optional: true + universal-cookie: + optional: true + + '@vueuse/metadata@12.8.2': + resolution: {integrity: sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==} + + '@vueuse/metadata@14.3.0': + resolution: {integrity: sha512-BwxmbAzwAVF50+MW57GXOUEV61nFBGnlBvrTqj49PqWJu3uw7hdu72ztXeZ33RdZtDY6kO+bfCAE1PCn88Tktw==} + + '@vueuse/shared@12.8.2': + resolution: {integrity: sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==} + + '@vueuse/shared@14.3.0': + resolution: {integrity: sha512-bZpge9eSXwa4ToSiqJ7j6KRwhAsneMFoSz3LMWKQDkqimm3D/tbFlrklrs/IOqC8tEcYmXQZJ6N0UrjhBirVCg==} + peerDependencies: + vue: ^3.5.0 + + '@xterm/xterm@6.0.0': + resolution: {integrity: sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==} + + algoliasearch@5.53.0: + resolution: {integrity: sha512-OGW1q6b91CRSSeiOnM8LxuR5NYJ2esvw66jUZ4IIvdv+ItNkx3pwLuyR+jaCdbGee4ov5WgUnyPryyh11xvByQ==} + engines: {node: '>= 14.0.0'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + birpc@2.9.0: + resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==} + + brace-expansion@5.0.6: + resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} + engines: {node: 18 || 20 || >=22} + + bun-ffi-structs@0.2.3: + resolution: {integrity: sha512-pgJiXP+hEgFo9qG51J6ItfY4ocs3vniwNzJ9WhoakB3QB2GdzQxX2EXssentPYlB2hOfJrTjO6iIQkWYzUodpg==} + peerDependencies: + typescript: ^5 + + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + + class-variance-authority@0.7.1: + resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + copy-anything@4.0.5: + resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==} + engines: {node: '>=18'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + + diff@9.0.0: + resolution: {integrity: sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==} + engines: {node: '>=0.3.1'} + + emoji-regex-xs@1.0.0: + resolution: {integrity: sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==} + + emoji-regex@10.6.0: + resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + + enhanced-resolve@5.23.0: + resolution: {integrity: sha512-yJN/BOOLxcOW2aQgeif9mSnaUB8KtvmMMp56oA1kx1CRfBKbhZm2pJ+NBY+3eOboHxix8lfjWpHE0Ei5U8RbSA==} + engines: {node: '>=10.13.0'} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + focus-trap@7.8.0: + resolution: {integrity: sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + get-east-asian-width@1.6.0: + resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} + engines: {node: '>=18'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + hast-util-to-html@9.0.5: + resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} + + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + + hookable@5.5.3: + resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} + + html-void-elements@3.0.0: + resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + + is-what@5.5.0: + resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==} + engines: {node: '>=18'} + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + linkify-it@5.0.1: + resolution: {integrity: sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==} + + lunr@2.3.9: + resolution: {integrity: sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + mark.js@8.11.1: + resolution: {integrity: sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==} + + markdown-it@14.2.0: + resolution: {integrity: sha512-1TGiQiJVRQ3NPmZH6sx5Cfnmg6GQm9jvC1ch4TK511NjSJvjzKLzn5pPfZRNZkRPZP0HqCioSndqH8v2nRaWVQ==} + hasBin: true + + marked@17.0.1: + resolution: {integrity: sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==} + engines: {node: '>= 20'} + hasBin: true + + mdast-util-to-hast@13.2.1: + resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + + mdurl@2.0.0: + resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} + + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.2: + resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + minisearch@7.2.0: + resolution: {integrity: sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==} + + mitt@3.0.1: + resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + obug@2.1.2: + resolution: {integrity: sha512-AWGB9WFcRXOQs48Z/udjI5ZcZMHXwX8XPByNpOydgcGsDLIzjGizhoMWJyKAWze7AVW/2W1i+/gPX4YtKe5cyg==} + engines: {node: '>=12.20.0'} + + ohash@2.0.11: + resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} + + oniguruma-to-es@3.1.1: + resolution: {integrity: sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + perfect-debounce@1.0.0: + resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + engines: {node: ^10 || ^12 || >=14} + + preact@10.29.2: + resolution: {integrity: sha512-7tNmwg/7mzzAoB/8kSg6Hl37JraAZw3Z3A0JSY7VXlZwo82Xn0G7wKbNNs2qoF4ZEEsQGTwDAroNdqKs1ofJxQ==} + + property-information@7.2.0: + resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} + + punycode.js@2.3.1: + resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} + engines: {node: '>=6'} + + regex-recursion@6.0.2: + resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} + + regex-utilities@2.3.0: + resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==} + + regex@6.1.0: + resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} + + reka-ui@2.9.10: + resolution: {integrity: sha512-yuvZVTp4fWH2G3qk+ze/x6YYlyc2Xl1d+eMUlIYrKqzTowBKteoDoN17fitURmqSUck3mc7JbcYgp49DnGu2EQ==} + peerDependencies: + vue: '>= 3.4.0' + + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + + rolldown@1.0.3: + resolution: {integrity: sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + rollup@4.61.1: + resolution: {integrity: sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + search-insights@2.17.3: + resolution: {integrity: sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ==} + + shiki@2.5.0: + resolution: {integrity: sha512-mI//trrsaiCIPsja5CNfsyNOqgAZUb6VpJA+340toL42UpzQlXpwRV9nch69X6gaUxrr9kaOOa6e3y3uAkGFxQ==} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + + speakingurl@14.0.1: + resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + + strip-ansi@7.1.2: + resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} + engines: {node: '>=12'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + superjson@2.2.6: + resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==} + engines: {node: '>=16'} + + tabbable@6.4.0: + resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==} + + tailwind-merge@3.6.0: + resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==} + + tailwindcss@4.3.0: + resolution: {integrity: sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==} + + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + turbo@2.9.17: + resolution: {integrity: sha512-91Q3KxfHJn7esFu2Ic6j9pkvQqWjncQCOp7r1gCKChRSb/+T/yIjsavAmbGLmFRKAzSjmWW/FMrcknmJ4hEOPA==} + hasBin: true + + typedoc-plugin-markdown@4.12.0: + resolution: {integrity: sha512-eJDEMAfxCmede22c/Jw7d0FA13ggAQv+KkwQYKYCdqI02cin6Rc9QRwbG/7XvvHWinuFejySnZVUWDtvGk3Vbg==} + engines: {node: '>= 18'} + peerDependencies: + typedoc: 0.28.x + + typedoc@0.28.19: + resolution: {integrity: sha512-wKh+lhdmMFivMlc6vRRcMGXeGEHGU2g8a2CkPTJjJlwRf1iXbimWIPcFolCqe4E0d/FRtGszpIrsp3WLpDB8Pw==} + engines: {node: '>= 18', pnpm: '>= 10'} + hasBin: true + peerDependencies: + typescript: 5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x || 6.0.x + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + uc.micro@2.1.0: + resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} + + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + + vfile-message@4.0.3: + resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} + + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + vite@8.0.16: + resolution: {integrity: sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.1.18 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitepress@1.6.4: + resolution: {integrity: sha512-+2ym1/+0VVrbhNyRoFFesVvBvHAVMZMK0rw60E3X/5349M1GuVdKeazuksqopEdvkKwKGs21Q729jX81/bkBJg==} + hasBin: true + peerDependencies: + markdown-it-mathjax3: ^4 + postcss: ^8 + peerDependenciesMeta: + markdown-it-mathjax3: + optional: true + postcss: + optional: true + + vitest@4.1.8: + resolution: {integrity: sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.8 + '@vitest/browser-preview': 4.1.8 + '@vitest/browser-webdriverio': 4.1.8 + '@vitest/coverage-istanbul': 4.1.8 + '@vitest/coverage-v8': 4.1.8 + '@vitest/ui': 4.1.8 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + vue-demi@0.14.10: + resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==} + engines: {node: '>=12'} + hasBin: true + peerDependencies: + '@vue/composition-api': ^1.0.0-rc.1 + vue: ^3.0.0-0 || ^2.6.0 + peerDependenciesMeta: + '@vue/composition-api': + optional: true + + vue@3.5.37: + resolution: {integrity: sha512-So4bMq165gsD4+hDVC1/bcbsOpTlUFGGkpuH2sx9vCflChIWahy4C0K4ZcX/COe0ad1IToIRT3VQz0dl4XPihg==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + vue@3.5.38: + resolution: {integrity: sha512-vAMKHfImQlYSy0C+PBue4s3ERZ2xGKfgZg5GXAsLInq1dyh2H78ILVP5sK0KPFPVW4kv+OGCIvBEondcjpZp7A==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + web-tree-sitter@0.25.10: + resolution: {integrity: sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA==} + peerDependencies: + '@types/emscripten': ^1.40.0 + peerDependenciesMeta: + '@types/emscripten': + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + + yoga-layout@3.2.1: + resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==} + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + +snapshots: + + '@algolia/abtesting@1.19.0': + dependencies: + '@algolia/client-common': 5.53.0 + '@algolia/requester-browser-xhr': 5.53.0 + '@algolia/requester-fetch': 5.53.0 + '@algolia/requester-node-http': 5.53.0 + + '@algolia/autocomplete-core@1.17.7(@algolia/client-search@5.53.0)(algoliasearch@5.53.0)(search-insights@2.17.3)': + dependencies: + '@algolia/autocomplete-plugin-algolia-insights': 1.17.7(@algolia/client-search@5.53.0)(algoliasearch@5.53.0)(search-insights@2.17.3) + '@algolia/autocomplete-shared': 1.17.7(@algolia/client-search@5.53.0)(algoliasearch@5.53.0) + transitivePeerDependencies: + - '@algolia/client-search' + - algoliasearch + - search-insights + + '@algolia/autocomplete-plugin-algolia-insights@1.17.7(@algolia/client-search@5.53.0)(algoliasearch@5.53.0)(search-insights@2.17.3)': + dependencies: + '@algolia/autocomplete-shared': 1.17.7(@algolia/client-search@5.53.0)(algoliasearch@5.53.0) + search-insights: 2.17.3 + transitivePeerDependencies: + - '@algolia/client-search' + - algoliasearch + + '@algolia/autocomplete-preset-algolia@1.17.7(@algolia/client-search@5.53.0)(algoliasearch@5.53.0)': + dependencies: + '@algolia/autocomplete-shared': 1.17.7(@algolia/client-search@5.53.0)(algoliasearch@5.53.0) + '@algolia/client-search': 5.53.0 + algoliasearch: 5.53.0 + + '@algolia/autocomplete-shared@1.17.7(@algolia/client-search@5.53.0)(algoliasearch@5.53.0)': + dependencies: + '@algolia/client-search': 5.53.0 + algoliasearch: 5.53.0 + + '@algolia/client-abtesting@5.53.0': + dependencies: + '@algolia/client-common': 5.53.0 + '@algolia/requester-browser-xhr': 5.53.0 + '@algolia/requester-fetch': 5.53.0 + '@algolia/requester-node-http': 5.53.0 + + '@algolia/client-analytics@5.53.0': + dependencies: + '@algolia/client-common': 5.53.0 + '@algolia/requester-browser-xhr': 5.53.0 + '@algolia/requester-fetch': 5.53.0 + '@algolia/requester-node-http': 5.53.0 + + '@algolia/client-common@5.53.0': {} + + '@algolia/client-insights@5.53.0': + dependencies: + '@algolia/client-common': 5.53.0 + '@algolia/requester-browser-xhr': 5.53.0 + '@algolia/requester-fetch': 5.53.0 + '@algolia/requester-node-http': 5.53.0 + + '@algolia/client-personalization@5.53.0': + dependencies: + '@algolia/client-common': 5.53.0 + '@algolia/requester-browser-xhr': 5.53.0 + '@algolia/requester-fetch': 5.53.0 + '@algolia/requester-node-http': 5.53.0 + + '@algolia/client-query-suggestions@5.53.0': + dependencies: + '@algolia/client-common': 5.53.0 + '@algolia/requester-browser-xhr': 5.53.0 + '@algolia/requester-fetch': 5.53.0 + '@algolia/requester-node-http': 5.53.0 + + '@algolia/client-search@5.53.0': + dependencies: + '@algolia/client-common': 5.53.0 + '@algolia/requester-browser-xhr': 5.53.0 + '@algolia/requester-fetch': 5.53.0 + '@algolia/requester-node-http': 5.53.0 + + '@algolia/ingestion@1.53.0': + dependencies: + '@algolia/client-common': 5.53.0 + '@algolia/requester-browser-xhr': 5.53.0 + '@algolia/requester-fetch': 5.53.0 + '@algolia/requester-node-http': 5.53.0 + + '@algolia/monitoring@1.53.0': + dependencies: + '@algolia/client-common': 5.53.0 + '@algolia/requester-browser-xhr': 5.53.0 + '@algolia/requester-fetch': 5.53.0 + '@algolia/requester-node-http': 5.53.0 + + '@algolia/recommend@5.53.0': + dependencies: + '@algolia/client-common': 5.53.0 + '@algolia/requester-browser-xhr': 5.53.0 + '@algolia/requester-fetch': 5.53.0 + '@algolia/requester-node-http': 5.53.0 + + '@algolia/requester-browser-xhr@5.53.0': + dependencies: + '@algolia/client-common': 5.53.0 + + '@algolia/requester-fetch@5.53.0': + dependencies: + '@algolia/client-common': 5.53.0 + + '@algolia/requester-node-http@5.53.0': + dependencies: + '@algolia/client-common': 5.53.0 + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@docsearch/css@3.8.2': {} + + '@docsearch/js@3.8.2(@algolia/client-search@5.53.0)(search-insights@2.17.3)': + dependencies: + '@docsearch/react': 3.8.2(@algolia/client-search@5.53.0)(search-insights@2.17.3) + preact: 10.29.2 + transitivePeerDependencies: + - '@algolia/client-search' + - '@types/react' + - react + - react-dom + - search-insights + + '@docsearch/react@3.8.2(@algolia/client-search@5.53.0)(search-insights@2.17.3)': + dependencies: + '@algolia/autocomplete-core': 1.17.7(@algolia/client-search@5.53.0)(algoliasearch@5.53.0)(search-insights@2.17.3) + '@algolia/autocomplete-preset-algolia': 1.17.7(@algolia/client-search@5.53.0)(algoliasearch@5.53.0) + '@docsearch/css': 3.8.2 + algoliasearch: 5.53.0 + optionalDependencies: + search-insights: 2.17.3 + transitivePeerDependencies: + - '@algolia/client-search' + + '@emnapi/core@1.10.0': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.21.5': + optional: true + + '@esbuild/android-arm64@0.21.5': + optional: true + + '@esbuild/android-arm@0.21.5': + optional: true + + '@esbuild/android-x64@0.21.5': + optional: true + + '@esbuild/darwin-arm64@0.21.5': + optional: true + + '@esbuild/darwin-x64@0.21.5': + optional: true + + '@esbuild/freebsd-arm64@0.21.5': + optional: true + + '@esbuild/freebsd-x64@0.21.5': + optional: true + + '@esbuild/linux-arm64@0.21.5': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + + '@esbuild/linux-riscv64@0.21.5': + optional: true + + '@esbuild/linux-s390x@0.21.5': + optional: true + + '@esbuild/linux-x64@0.21.5': + optional: true + + '@esbuild/netbsd-x64@0.21.5': + optional: true + + '@esbuild/openbsd-x64@0.21.5': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + + '@esbuild/win32-arm64@0.21.5': + optional: true + + '@esbuild/win32-ia32@0.21.5': + optional: true + + '@esbuild/win32-x64@0.21.5': + optional: true + + '@floating-ui/core@1.7.5': + dependencies: + '@floating-ui/utils': 0.2.11 + + '@floating-ui/dom@1.7.6': + dependencies: + '@floating-ui/core': 1.7.5 + '@floating-ui/utils': 0.2.11 + + '@floating-ui/utils@0.2.11': {} + + '@floating-ui/vue@1.1.11(vue@3.5.38(typescript@6.0.3))': + dependencies: + '@floating-ui/dom': 1.7.6 + '@floating-ui/utils': 0.2.11 + vue-demi: 0.14.10(vue@3.5.38(typescript@6.0.3)) + transitivePeerDependencies: + - '@vue/composition-api' + - vue + + '@fontsource/geist-mono@5.2.8': {} + + '@fontsource/geist-sans@5.2.5': {} + + '@gerrit0/mini-shiki@3.23.0': + dependencies: + '@shikijs/engine-oniguruma': 3.23.0 + '@shikijs/langs': 3.23.0 + '@shikijs/themes': 3.23.0 + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + + '@iconify-json/simple-icons@1.2.86': + dependencies: + '@iconify/types': 2.0.0 + + '@iconify/types@2.0.0': {} + + '@internationalized/date@3.12.2': + dependencies: + '@swc/helpers': 0.5.23 + + '@internationalized/number@3.6.7': + dependencies: + '@swc/helpers': 0.5.23 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.2 + optional: true + + '@opentui/core-darwin-arm64@0.4.0': + optional: true + + '@opentui/core-darwin-x64@0.4.0': + optional: true + + '@opentui/core-linux-arm64-musl@0.4.0': + optional: true + + '@opentui/core-linux-arm64@0.4.0': + optional: true + + '@opentui/core-linux-x64-musl@0.4.0': + optional: true + + '@opentui/core-linux-x64@0.4.0': + optional: true + + '@opentui/core-win32-arm64@0.4.0': + optional: true + + '@opentui/core-win32-x64@0.4.0': + optional: true + + '@opentui/core@0.4.0(typescript@6.0.3)(web-tree-sitter@0.25.10)': + dependencies: + bun-ffi-structs: 0.2.3(typescript@6.0.3) + diff: 9.0.0 + marked: 17.0.1 + string-width: 7.2.0 + strip-ansi: 7.1.2 + web-tree-sitter: 0.25.10 + yoga-layout: 3.2.1 + optionalDependencies: + '@opentui/core-darwin-arm64': 0.4.0 + '@opentui/core-darwin-x64': 0.4.0 + '@opentui/core-linux-arm64': 0.4.0 + '@opentui/core-linux-arm64-musl': 0.4.0 + '@opentui/core-linux-x64': 0.4.0 + '@opentui/core-linux-x64-musl': 0.4.0 + '@opentui/core-win32-arm64': 0.4.0 + '@opentui/core-win32-x64': 0.4.0 + transitivePeerDependencies: + - typescript + + '@oxc-project/types@0.133.0': {} + + '@rolldown/binding-android-arm64@1.0.3': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.3': + optional: true + + '@rolldown/binding-darwin-x64@1.0.3': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.3': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.3': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.3': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.3': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.0.3': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.0.3': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.3': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.3': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.3': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.3': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.3': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.3': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@rollup/rollup-android-arm-eabi@4.61.1': + optional: true + + '@rollup/rollup-android-arm64@4.61.1': + optional: true + + '@rollup/rollup-darwin-arm64@4.61.1': + optional: true + + '@rollup/rollup-darwin-x64@4.61.1': + optional: true + + '@rollup/rollup-freebsd-arm64@4.61.1': + optional: true + + '@rollup/rollup-freebsd-x64@4.61.1': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.61.1': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.61.1': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.61.1': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.61.1': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.61.1': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.61.1': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.61.1': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.61.1': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.61.1': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.61.1': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.61.1': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.61.1': + optional: true + + '@rollup/rollup-linux-x64-musl@4.61.1': + optional: true + + '@rollup/rollup-openbsd-x64@4.61.1': + optional: true + + '@rollup/rollup-openharmony-arm64@4.61.1': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.61.1': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.61.1': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.61.1': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.61.1': + optional: true + + '@shikijs/core@2.5.0': + dependencies: + '@shikijs/engine-javascript': 2.5.0 + '@shikijs/engine-oniguruma': 2.5.0 + '@shikijs/types': 2.5.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + hast-util-to-html: 9.0.5 + + '@shikijs/engine-javascript@2.5.0': + dependencies: + '@shikijs/types': 2.5.0 + '@shikijs/vscode-textmate': 10.0.2 + oniguruma-to-es: 3.1.1 + + '@shikijs/engine-oniguruma@2.5.0': + dependencies: + '@shikijs/types': 2.5.0 + '@shikijs/vscode-textmate': 10.0.2 + + '@shikijs/engine-oniguruma@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + + '@shikijs/langs@2.5.0': + dependencies: + '@shikijs/types': 2.5.0 + + '@shikijs/langs@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + + '@shikijs/themes@2.5.0': + dependencies: + '@shikijs/types': 2.5.0 + + '@shikijs/themes@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + + '@shikijs/transformers@2.5.0': + dependencies: + '@shikijs/core': 2.5.0 + '@shikijs/types': 2.5.0 + + '@shikijs/types@2.5.0': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + + '@shikijs/types@3.23.0': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + + '@shikijs/vscode-textmate@10.0.2': {} + + '@standard-schema/spec@1.1.0': {} + + '@swc/helpers@0.5.23': + dependencies: + tslib: 2.8.1 + + '@tailwindcss/node@4.3.0': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.23.0 + jiti: 2.7.0 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.3.0 + + '@tailwindcss/oxide-android-arm64@4.3.0': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.3.0': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.3.0': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.3.0': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.0': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.3.0': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.3.0': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.3.0': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.3.0': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.0': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.3.0': + optional: true + + '@tailwindcss/oxide@4.3.0': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.3.0 + '@tailwindcss/oxide-darwin-arm64': 4.3.0 + '@tailwindcss/oxide-darwin-x64': 4.3.0 + '@tailwindcss/oxide-freebsd-x64': 4.3.0 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.0 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.0 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.0 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.0 + '@tailwindcss/oxide-linux-x64-musl': 4.3.0 + '@tailwindcss/oxide-wasm32-wasi': 4.3.0 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.0 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.0 + + '@tailwindcss/vite@4.3.0(vite@8.0.16(@types/node@25.9.2)(jiti@2.7.0)(yaml@2.9.0))': + dependencies: + '@tailwindcss/node': 4.3.0 + '@tailwindcss/oxide': 4.3.0 + tailwindcss: 4.3.0 + vite: 8.0.16(@types/node@25.9.2)(jiti@2.7.0)(yaml@2.9.0) + + '@tanstack/virtual-core@3.17.0': {} + + '@tanstack/vue-virtual@3.13.28(vue@3.5.38(typescript@6.0.3))': + dependencies: + '@tanstack/virtual-core': 3.17.0 + vue: 3.5.38(typescript@6.0.3) + + '@turbo/darwin-64@2.9.17': + optional: true + + '@turbo/darwin-arm64@2.9.17': + optional: true + + '@turbo/linux-64@2.9.17': + optional: true + + '@turbo/linux-arm64@2.9.17': + optional: true + + '@turbo/windows-64@2.9.17': + optional: true + + '@turbo/windows-arm64@2.9.17': + optional: true + + '@tybys/wasm-util@0.10.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/hast@3.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/linkify-it@5.0.0': {} + + '@types/markdown-it@14.1.2': + dependencies: + '@types/linkify-it': 5.0.0 + '@types/mdurl': 2.0.0 + + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/mdurl@2.0.0': {} + + '@types/node@25.9.2': + dependencies: + undici-types: 7.24.6 + + '@types/unist@3.0.3': {} + + '@types/web-bluetooth@0.0.21': {} + + '@ungap/structured-clone@1.3.1': {} + + '@vitejs/plugin-vue@5.2.4(vite@5.4.21(@types/node@25.9.2)(lightningcss@1.32.0))(vue@3.5.37(typescript@6.0.3))': + dependencies: + vite: 5.4.21(@types/node@25.9.2)(lightningcss@1.32.0) + vue: 3.5.37(typescript@6.0.3) + + '@vitest/expect@4.1.8': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.8 + '@vitest/utils': 4.1.8 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.8(vite@8.0.16(@types/node@25.9.2)(jiti@2.7.0)(yaml@2.9.0))': + dependencies: + '@vitest/spy': 4.1.8 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.0.16(@types/node@25.9.2)(jiti@2.7.0)(yaml@2.9.0) + + '@vitest/pretty-format@4.1.8': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.8': + dependencies: + '@vitest/utils': 4.1.8 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.8': + dependencies: + '@vitest/pretty-format': 4.1.8 + '@vitest/utils': 4.1.8 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.8': {} + + '@vitest/utils@4.1.8': + dependencies: + '@vitest/pretty-format': 4.1.8 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + + '@vue/compiler-core@3.5.37': + dependencies: + '@babel/parser': 7.29.7 + '@vue/shared': 3.5.37 + entities: 7.0.1 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-core@3.5.38': + dependencies: + '@babel/parser': 7.29.7 + '@vue/shared': 3.5.38 + entities: 7.0.1 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.5.37': + dependencies: + '@vue/compiler-core': 3.5.37 + '@vue/shared': 3.5.37 + + '@vue/compiler-dom@3.5.38': + dependencies: + '@vue/compiler-core': 3.5.38 + '@vue/shared': 3.5.38 + + '@vue/compiler-sfc@3.5.37': + dependencies: + '@babel/parser': 7.29.7 + '@vue/compiler-core': 3.5.37 + '@vue/compiler-dom': 3.5.37 + '@vue/compiler-ssr': 3.5.37 + '@vue/shared': 3.5.37 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.15 + source-map-js: 1.2.1 + + '@vue/compiler-sfc@3.5.38': + dependencies: + '@babel/parser': 7.29.7 + '@vue/compiler-core': 3.5.38 + '@vue/compiler-dom': 3.5.38 + '@vue/compiler-ssr': 3.5.38 + '@vue/shared': 3.5.38 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.15 + source-map-js: 1.2.1 + + '@vue/compiler-ssr@3.5.37': + dependencies: + '@vue/compiler-dom': 3.5.37 + '@vue/shared': 3.5.37 + + '@vue/compiler-ssr@3.5.38': + dependencies: + '@vue/compiler-dom': 3.5.38 + '@vue/shared': 3.5.38 + + '@vue/devtools-api@7.7.9': + dependencies: + '@vue/devtools-kit': 7.7.9 + + '@vue/devtools-kit@7.7.9': + dependencies: + '@vue/devtools-shared': 7.7.9 + birpc: 2.9.0 + hookable: 5.5.3 + mitt: 3.0.1 + perfect-debounce: 1.0.0 + speakingurl: 14.0.1 + superjson: 2.2.6 + + '@vue/devtools-shared@7.7.9': + dependencies: + rfdc: 1.4.1 + + '@vue/reactivity@3.5.37': + dependencies: + '@vue/shared': 3.5.37 + + '@vue/reactivity@3.5.38': + dependencies: + '@vue/shared': 3.5.38 + + '@vue/runtime-core@3.5.37': + dependencies: + '@vue/reactivity': 3.5.37 + '@vue/shared': 3.5.37 + + '@vue/runtime-core@3.5.38': + dependencies: + '@vue/reactivity': 3.5.38 + '@vue/shared': 3.5.38 + + '@vue/runtime-dom@3.5.37': + dependencies: + '@vue/reactivity': 3.5.37 + '@vue/runtime-core': 3.5.37 + '@vue/shared': 3.5.37 + csstype: 3.2.3 + + '@vue/runtime-dom@3.5.38': + dependencies: + '@vue/reactivity': 3.5.38 + '@vue/runtime-core': 3.5.38 + '@vue/shared': 3.5.38 + csstype: 3.2.3 + + '@vue/server-renderer@3.5.37(vue@3.5.37(typescript@6.0.3))': + dependencies: + '@vue/compiler-ssr': 3.5.37 + '@vue/shared': 3.5.37 + vue: 3.5.37(typescript@6.0.3) + + '@vue/server-renderer@3.5.38(vue@3.5.38(typescript@6.0.3))': + dependencies: + '@vue/compiler-ssr': 3.5.38 + '@vue/shared': 3.5.38 + vue: 3.5.38(typescript@6.0.3) + + '@vue/shared@3.5.37': {} + + '@vue/shared@3.5.38': {} + + '@vueuse/core@12.8.2(typescript@6.0.3)': + dependencies: + '@types/web-bluetooth': 0.0.21 + '@vueuse/metadata': 12.8.2 + '@vueuse/shared': 12.8.2(typescript@6.0.3) + vue: 3.5.37(typescript@6.0.3) + transitivePeerDependencies: + - typescript + + '@vueuse/core@14.3.0(vue@3.5.38(typescript@6.0.3))': + dependencies: + '@types/web-bluetooth': 0.0.21 + '@vueuse/metadata': 14.3.0 + '@vueuse/shared': 14.3.0(vue@3.5.38(typescript@6.0.3)) + vue: 3.5.38(typescript@6.0.3) + + '@vueuse/integrations@12.8.2(focus-trap@7.8.0)(typescript@6.0.3)': + dependencies: + '@vueuse/core': 12.8.2(typescript@6.0.3) + '@vueuse/shared': 12.8.2(typescript@6.0.3) + vue: 3.5.37(typescript@6.0.3) + optionalDependencies: + focus-trap: 7.8.0 + transitivePeerDependencies: + - typescript + + '@vueuse/metadata@12.8.2': {} + + '@vueuse/metadata@14.3.0': {} + + '@vueuse/shared@12.8.2(typescript@6.0.3)': + dependencies: + vue: 3.5.37(typescript@6.0.3) + transitivePeerDependencies: + - typescript + + '@vueuse/shared@14.3.0(vue@3.5.38(typescript@6.0.3))': + dependencies: + vue: 3.5.38(typescript@6.0.3) + + '@xterm/xterm@6.0.0': {} + + algoliasearch@5.53.0: + dependencies: + '@algolia/abtesting': 1.19.0 + '@algolia/client-abtesting': 5.53.0 + '@algolia/client-analytics': 5.53.0 + '@algolia/client-common': 5.53.0 + '@algolia/client-insights': 5.53.0 + '@algolia/client-personalization': 5.53.0 + '@algolia/client-query-suggestions': 5.53.0 + '@algolia/client-search': 5.53.0 + '@algolia/ingestion': 1.53.0 + '@algolia/monitoring': 1.53.0 + '@algolia/recommend': 5.53.0 + '@algolia/requester-browser-xhr': 5.53.0 + '@algolia/requester-fetch': 5.53.0 + '@algolia/requester-node-http': 5.53.0 + + ansi-regex@6.2.2: {} + + argparse@2.0.1: {} + + aria-hidden@1.2.6: + dependencies: + tslib: 2.8.1 + + assertion-error@2.0.1: {} + + balanced-match@4.0.4: {} + + birpc@2.9.0: {} + + brace-expansion@5.0.6: + dependencies: + balanced-match: 4.0.4 + + bun-ffi-structs@0.2.3(typescript@6.0.3): + dependencies: + typescript: 6.0.3 + + ccount@2.0.1: {} + + chai@6.2.2: {} + + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + + class-variance-authority@0.7.1: + dependencies: + clsx: 2.1.1 + + clsx@2.1.1: {} + + comma-separated-tokens@2.0.3: {} + + convert-source-map@2.0.0: {} + + copy-anything@4.0.5: + dependencies: + is-what: 5.5.0 + + csstype@3.2.3: {} + + defu@6.1.7: {} + + dequal@2.0.3: {} + + detect-libc@2.1.2: {} + + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + + diff@9.0.0: {} + + emoji-regex-xs@1.0.0: {} + + emoji-regex@10.6.0: {} + + enhanced-resolve@5.23.0: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + + entities@4.5.0: {} + + entities@7.0.1: {} + + es-module-lexer@2.1.0: {} + + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + + estree-walker@2.0.2: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.3.0: {} + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + focus-trap@7.8.0: + dependencies: + tabbable: 6.4.0 + + fsevents@2.3.3: + optional: true + + get-east-asian-width@1.6.0: {} + + graceful-fs@4.2.11: {} + + hast-util-to-html@9.0.5: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + comma-separated-tokens: 2.0.3 + hast-util-whitespace: 3.0.0 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.1 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + stringify-entities: 4.0.4 + zwitch: 2.0.4 + + hast-util-whitespace@3.0.0: + dependencies: + '@types/hast': 3.0.4 + + hookable@5.5.3: {} + + html-void-elements@3.0.0: {} + + is-what@5.5.0: {} + + jiti@2.7.0: {} + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + linkify-it@5.0.1: + dependencies: + uc.micro: 2.1.0 + + lunr@2.3.9: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + mark.js@8.11.1: {} + + markdown-it@14.2.0: + dependencies: + argparse: 2.0.1 + entities: 4.5.0 + linkify-it: 5.0.1 + mdurl: 2.0.0 + punycode.js: 2.3.1 + uc.micro: 2.1.0 + + marked@17.0.1: {} + + mdast-util-to-hast@13.2.1: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.3.1 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.1 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + + mdurl@2.0.0: {} + + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-encode@2.0.1: {} + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.2: {} + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.6 + + minisearch@7.2.0: {} + + mitt@3.0.1: {} + + nanoid@3.3.12: {} + + obug@2.1.2: {} + + ohash@2.0.11: {} + + oniguruma-to-es@3.1.1: + dependencies: + emoji-regex-xs: 1.0.0 + regex: 6.1.0 + regex-recursion: 6.0.2 + + pathe@2.0.3: {} + + perfect-debounce@1.0.0: {} + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + postcss@8.5.15: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + preact@10.29.2: {} + + property-information@7.2.0: {} + + punycode.js@2.3.1: {} + + regex-recursion@6.0.2: + dependencies: + regex-utilities: 2.3.0 + + regex-utilities@2.3.0: {} + + regex@6.1.0: + dependencies: + regex-utilities: 2.3.0 + + reka-ui@2.9.10(vue@3.5.38(typescript@6.0.3)): + dependencies: + '@floating-ui/dom': 1.7.6 + '@floating-ui/vue': 1.1.11(vue@3.5.38(typescript@6.0.3)) + '@internationalized/date': 3.12.2 + '@internationalized/number': 3.6.7 + '@tanstack/vue-virtual': 3.13.28(vue@3.5.38(typescript@6.0.3)) + '@vueuse/core': 14.3.0(vue@3.5.38(typescript@6.0.3)) + '@vueuse/shared': 14.3.0(vue@3.5.38(typescript@6.0.3)) + aria-hidden: 1.2.6 + defu: 6.1.7 + ohash: 2.0.11 + vue: 3.5.38(typescript@6.0.3) + transitivePeerDependencies: + - '@vue/composition-api' + + rfdc@1.4.1: {} + + rolldown@1.0.3: + dependencies: + '@oxc-project/types': 0.133.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.3 + '@rolldown/binding-darwin-arm64': 1.0.3 + '@rolldown/binding-darwin-x64': 1.0.3 + '@rolldown/binding-freebsd-x64': 1.0.3 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.3 + '@rolldown/binding-linux-arm64-gnu': 1.0.3 + '@rolldown/binding-linux-arm64-musl': 1.0.3 + '@rolldown/binding-linux-ppc64-gnu': 1.0.3 + '@rolldown/binding-linux-s390x-gnu': 1.0.3 + '@rolldown/binding-linux-x64-gnu': 1.0.3 + '@rolldown/binding-linux-x64-musl': 1.0.3 + '@rolldown/binding-openharmony-arm64': 1.0.3 + '@rolldown/binding-wasm32-wasi': 1.0.3 + '@rolldown/binding-win32-arm64-msvc': 1.0.3 + '@rolldown/binding-win32-x64-msvc': 1.0.3 + + rollup@4.61.1: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.61.1 + '@rollup/rollup-android-arm64': 4.61.1 + '@rollup/rollup-darwin-arm64': 4.61.1 + '@rollup/rollup-darwin-x64': 4.61.1 + '@rollup/rollup-freebsd-arm64': 4.61.1 + '@rollup/rollup-freebsd-x64': 4.61.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.61.1 + '@rollup/rollup-linux-arm-musleabihf': 4.61.1 + '@rollup/rollup-linux-arm64-gnu': 4.61.1 + '@rollup/rollup-linux-arm64-musl': 4.61.1 + '@rollup/rollup-linux-loong64-gnu': 4.61.1 + '@rollup/rollup-linux-loong64-musl': 4.61.1 + '@rollup/rollup-linux-ppc64-gnu': 4.61.1 + '@rollup/rollup-linux-ppc64-musl': 4.61.1 + '@rollup/rollup-linux-riscv64-gnu': 4.61.1 + '@rollup/rollup-linux-riscv64-musl': 4.61.1 + '@rollup/rollup-linux-s390x-gnu': 4.61.1 + '@rollup/rollup-linux-x64-gnu': 4.61.1 + '@rollup/rollup-linux-x64-musl': 4.61.1 + '@rollup/rollup-openbsd-x64': 4.61.1 + '@rollup/rollup-openharmony-arm64': 4.61.1 + '@rollup/rollup-win32-arm64-msvc': 4.61.1 + '@rollup/rollup-win32-ia32-msvc': 4.61.1 + '@rollup/rollup-win32-x64-gnu': 4.61.1 + '@rollup/rollup-win32-x64-msvc': 4.61.1 + fsevents: 2.3.3 + + search-insights@2.17.3: {} + + shiki@2.5.0: + dependencies: + '@shikijs/core': 2.5.0 + '@shikijs/engine-javascript': 2.5.0 + '@shikijs/engine-oniguruma': 2.5.0 + '@shikijs/langs': 2.5.0 + '@shikijs/themes': 2.5.0 + '@shikijs/types': 2.5.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + space-separated-tokens@2.0.2: {} + + speakingurl@14.0.1: {} + + stackback@0.0.2: {} + + std-env@4.1.0: {} + + string-width@7.2.0: + dependencies: + emoji-regex: 10.6.0 + get-east-asian-width: 1.6.0 + strip-ansi: 7.2.0 + + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + + strip-ansi@7.1.2: + dependencies: + ansi-regex: 6.2.2 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + + superjson@2.2.6: + dependencies: + copy-anything: 4.0.5 + + tabbable@6.4.0: {} + + tailwind-merge@3.6.0: {} + + tailwindcss@4.3.0: {} + + tapable@2.3.3: {} + + tinybench@2.9.0: {} + + tinyexec@1.2.4: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinyrainbow@3.1.0: {} + + trim-lines@3.0.1: {} + + tslib@2.8.1: {} + + turbo@2.9.17: + optionalDependencies: + '@turbo/darwin-64': 2.9.17 + '@turbo/darwin-arm64': 2.9.17 + '@turbo/linux-64': 2.9.17 + '@turbo/linux-arm64': 2.9.17 + '@turbo/windows-64': 2.9.17 + '@turbo/windows-arm64': 2.9.17 + + typedoc-plugin-markdown@4.12.0(typedoc@0.28.19(typescript@6.0.3)): + dependencies: + typedoc: 0.28.19(typescript@6.0.3) + + typedoc@0.28.19(typescript@6.0.3): + dependencies: + '@gerrit0/mini-shiki': 3.23.0 + lunr: 2.3.9 + markdown-it: 14.2.0 + minimatch: 10.2.5 + typescript: 6.0.3 + yaml: 2.9.0 + + typescript@6.0.3: {} + + uc.micro@2.1.0: {} + + undici-types@7.24.6: {} + + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + + unist-util-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + vfile-message@4.0.3: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.3 + + vite@5.4.21(@types/node@25.9.2)(lightningcss@1.32.0): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.15 + rollup: 4.61.1 + optionalDependencies: + '@types/node': 25.9.2 + fsevents: 2.3.3 + lightningcss: 1.32.0 + + vite@8.0.16(@types/node@25.9.2)(jiti@2.7.0)(yaml@2.9.0): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.15 + rolldown: 1.0.3 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 25.9.2 + fsevents: 2.3.3 + jiti: 2.7.0 + yaml: 2.9.0 + + vitepress@1.6.4(@algolia/client-search@5.53.0)(@types/node@25.9.2)(lightningcss@1.32.0)(postcss@8.5.15)(search-insights@2.17.3)(typescript@6.0.3): + dependencies: + '@docsearch/css': 3.8.2 + '@docsearch/js': 3.8.2(@algolia/client-search@5.53.0)(search-insights@2.17.3) + '@iconify-json/simple-icons': 1.2.86 + '@shikijs/core': 2.5.0 + '@shikijs/transformers': 2.5.0 + '@shikijs/types': 2.5.0 + '@types/markdown-it': 14.1.2 + '@vitejs/plugin-vue': 5.2.4(vite@5.4.21(@types/node@25.9.2)(lightningcss@1.32.0))(vue@3.5.37(typescript@6.0.3)) + '@vue/devtools-api': 7.7.9 + '@vue/shared': 3.5.37 + '@vueuse/core': 12.8.2(typescript@6.0.3) + '@vueuse/integrations': 12.8.2(focus-trap@7.8.0)(typescript@6.0.3) + focus-trap: 7.8.0 + mark.js: 8.11.1 + minisearch: 7.2.0 + shiki: 2.5.0 + vite: 5.4.21(@types/node@25.9.2)(lightningcss@1.32.0) + vue: 3.5.37(typescript@6.0.3) + optionalDependencies: + postcss: 8.5.15 + transitivePeerDependencies: + - '@algolia/client-search' + - '@types/node' + - '@types/react' + - async-validator + - axios + - change-case + - drauu + - fuse.js + - idb-keyval + - jwt-decode + - less + - lightningcss + - nprogress + - qrcode + - react + - react-dom + - sass + - sass-embedded + - search-insights + - sortablejs + - stylus + - sugarss + - terser + - typescript + - universal-cookie + + vitest@4.1.8(@types/node@25.9.2)(vite@8.0.16(@types/node@25.9.2)(jiti@2.7.0)(yaml@2.9.0)): + dependencies: + '@vitest/expect': 4.1.8 + '@vitest/mocker': 4.1.8(vite@8.0.16(@types/node@25.9.2)(jiti@2.7.0)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.8 + '@vitest/runner': 4.1.8 + '@vitest/snapshot': 4.1.8 + '@vitest/spy': 4.1.8 + '@vitest/utils': 4.1.8 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.2 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 8.0.16(@types/node@25.9.2)(jiti@2.7.0)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 25.9.2 + transitivePeerDependencies: + - msw + + vue-demi@0.14.10(vue@3.5.38(typescript@6.0.3)): + dependencies: + vue: 3.5.38(typescript@6.0.3) + + vue@3.5.37(typescript@6.0.3): + dependencies: + '@vue/compiler-dom': 3.5.37 + '@vue/compiler-sfc': 3.5.37 + '@vue/runtime-dom': 3.5.37 + '@vue/server-renderer': 3.5.37(vue@3.5.37(typescript@6.0.3)) + '@vue/shared': 3.5.37 + optionalDependencies: + typescript: 6.0.3 + + vue@3.5.38(typescript@6.0.3): + dependencies: + '@vue/compiler-dom': 3.5.38 + '@vue/compiler-sfc': 3.5.38 + '@vue/runtime-dom': 3.5.38 + '@vue/server-renderer': 3.5.38(vue@3.5.38(typescript@6.0.3)) + '@vue/shared': 3.5.38 + optionalDependencies: + typescript: 6.0.3 + + web-tree-sitter@0.25.10: {} + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + yaml@2.9.0: {} + + yoga-layout@3.2.1: {} + + zod@4.4.3: {} + + zwitch@2.0.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 00000000..a7606e20 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,12 @@ +packages: + - "packages/*" + - "provision/go" + - "provision/ts" + +allowBuilds: + esbuild: true + vue-demi: true + +onlyBuiltDependencies: + - "@opentui/core" + - esbuild diff --git a/provision/.gitignore b/provision/.gitignore new file mode 100644 index 00000000..ceddaa37 --- /dev/null +++ b/provision/.gitignore @@ -0,0 +1 @@ +.cache/ diff --git a/provision/README.md b/provision/README.md new file mode 100644 index 00000000..49210c24 --- /dev/null +++ b/provision/README.md @@ -0,0 +1,24 @@ +# Provision Workspace + +The `provision` workspace owns project tooling that is not part of the public +package runtime. + +- `go` contains the local shell bridge package and builds its binary into + `.cache/bin`. +- `ts` contains shared TypeScript helpers for provisioned paths. +- `.cache` is the only mutable tool-cache root for workspace commands. + +Cache buckets are named by tool: + +- `.cache/turbo` +- `.cache/vitest` +- `.cache/tsbuild` +- `.cache/playwright` +- `.cache/xdg` +- `.cache/go-build` +- `.cache/go-mod` +- `.cache/go-tmp` +- `.cache/bin` + +Do not add package-local cache directories for new tools. Add a named helper in +`provision/ts/src/cache.ts` and point the tool at `provision/.cache/`. diff --git a/provision/go/.gitignore b/provision/go/.gitignore new file mode 100644 index 00000000..3515c4b9 --- /dev/null +++ b/provision/go/.gitignore @@ -0,0 +1,2 @@ +*.test +coverage.out diff --git a/provision/go/exports.go b/provision/go/exports.go new file mode 100644 index 00000000..545c15bf --- /dev/null +++ b/provision/go/exports.go @@ -0,0 +1,178 @@ +package tui + +import ( + "github.com/oullin/tui/internal/environment" + "github.com/oullin/tui/internal/form" + "github.com/oullin/tui/internal/key" + "github.com/oullin/tui/internal/output" + "github.com/oullin/tui/internal/prompt" + "github.com/oullin/tui/internal/prompts" + "github.com/oullin/tui/internal/status" + textutil "github.com/oullin/tui/internal/strings" + "github.com/oullin/tui/internal/terminal" + "github.com/oullin/tui/internal/typedvalue" +) + +type Environment = environment.Environment +type Input = environment.Input +type Output = environment.Output +type Patch = environment.Patch +type MemoryOutput = environment.MemoryOutput +type ScriptedInput = environment.ScriptedInput +type StdioInput = environment.StdioInput +type WriterOutput = environment.WriterOutput + +var CurrentEnvironment = environment.Current +var Configure = environment.Configure +var WithEnvironment = environment.With +var NewMemoryOutput = environment.NewMemoryOutput +var NewScriptedInput = environment.NewScriptedInput +var NewStdioInput = environment.NewStdioInput +var NewWriterOutput = environment.NewWriterOutput +var CancelUsing = environment.CancelUsing +var CancelPrompt = environment.CancelPrompt + +type KeyValue = key.Value +type KeyEvent = key.Event + +var ( + KeyUp = key.Up + KeyDown = key.Down + KeyRight = key.Right + KeyLeft = key.Left + KeyEscape = key.Escape + KeyDelete = key.Delete + KeyBackspace = key.Backspace + KeyEnter = key.Enter + KeySpace = key.Space + KeyTab = key.Tab + KeyShiftTab = key.ShiftTab + KeyCtrlC = key.CtrlC + KeyCtrlA = key.CtrlA + KeyCtrlD = key.CtrlD + KeyCtrlU = key.CtrlU + KeyOptionBackspace = key.OptionBackspace +) + +var KeyFirst = key.First +var KeyFromEvent = key.FromEvent +var KeyOneOf = key.OneOf + +type Required = prompt.Required +type Validator[T any] = prompt.Validator[T] +type PromptValidationError = environment.PromptValidationError + +var ValidationError = prompt.ValidationError +var EnsureValid = prompt.EnsureValid[any] + +type TextOptions = prompts.TextOptions +type NumberOptions = prompts.NumberOptions +type Choice = prompts.Choice +type ChoiceOptions = prompts.ChoiceOptions +type MultiChoiceOptions = prompts.MultiChoiceOptions +type ConfirmOptions = prompts.ConfirmOptions +type SearchSource = prompts.SearchSource +type SearchOptions = prompts.SearchOptions +type MultiSearchOptions = prompts.MultiSearchOptions +type SuggestSource = prompts.SuggestSource +type SuggestOptions = prompts.SuggestOptions + +var Text = prompts.Text +var Textarea = prompts.Textarea +var Password = prompts.Password +var Number = prompts.Number +var Pause = prompts.Pause +var NewStringChoices = prompts.NewStringChoices +var Select = prompts.Select +var Multiselect = prompts.Multiselect +var Confirm = prompts.Confirm +var Search = prompts.Search +var Multisearch = prompts.MultiSearch +var Suggest = prompts.Suggest +var Autocomplete = prompts.Autocomplete + +type FormBuilder = form.Builder +type FormResponses = form.Responses +type FormStep = form.Step +type FormStepFunc = form.StepFunc +type FormConditionFunc = form.ConditionFunc + +var Form = form.New +var FormBool = form.Bool +var ErrFormReverted = form.ErrReverted + +type NoteType = output.NoteType +type TableRow = output.TableRow +type DataTableRow = output.DataTableRow +type DataTableOptions = output.DataTableOptions +type NotificationCommand = output.NotificationCommand +type NotificationRuntime = output.NotificationRuntime + +const ( + NoteAlert = output.NoteAlert + NoteError = output.NoteError + NoteInfo = output.NoteInfo + NoteIntro = output.NoteIntro + NoteOutro = output.NoteOutro + NoteWarning = output.NoteWarning +) + +var Alert = output.Alert +var Clear = output.Clear +var DataTable = output.DataTable +var Error = output.Error +var Grid = output.Grid +var Info = output.Info +var Intro = output.Intro +var Note = output.Note +var NotifyForPlatform = output.NotifyForPlatform +var NotificationCommandFor = output.NotificationCommandFor +var NotificationCommands = output.NotificationCommands +var Outro = output.Outro +var Table = output.Table +var TableObjects = output.TableObjects +var Title = output.Title +var Warning = output.Warning + +type Progress = status.Progress +type Stream = status.Stream +type Logger = status.Logger + +var NewProgress = status.NewProgress +var RunProgress = status.RunProgress[string, string] +var Spin = status.Spin[any] +var NewStream = status.NewStream +var Task = status.Task[any] + +type AnsiSegment = textutil.AnsiSegment + +var VisibleWidth = textutil.VisibleWidth +var Truncate = textutil.Truncate +var ParseAnsiSegments = textutil.ParseAnsiSegments +var ParseAnsiText = textutil.ParseAnsiText +var Wrap = textutil.Wrap + +type TerminalSize = terminal.Size +type TerminalColor = terminal.Color + +var DefaultTerminalSize = terminal.DefaultSize +var ClearTerminalSequence = terminal.ClearSequence +var TerminalTitleSequence = terminal.TitleSequence +var EraseLineSequence = terminal.EraseLineSequence +var ErasePreviousLinesSequence = terminal.ErasePreviousLinesSequence +var CursorToStartSequence = terminal.CursorToStartSequence +var HideCursorSequence = terminal.HideCursorSequence +var ShowCursorSequence = terminal.ShowCursorSequence +var SupportsTrueColor = terminal.SupportsTrueColor +var DefaultForegroundColor = terminal.DefaultForegroundColor +var DefaultBackgroundColor = terminal.DefaultBackgroundColor +var ParseTerminalColor = terminal.ParseColor + +type TypedValueState = typedvalue.State +type AppliedKey = typedvalue.AppliedKey + +var InitialTypedValueState = typedvalue.InitialState +var ApplyTypedKey = typedvalue.ApplyKey +var TypedValueCharacters = typedvalue.Characters +var TypedValueFromCharacters = typedvalue.FromCharacters +var TypedValueCharacterLength = typedvalue.CharacterLength diff --git a/provision/go/exports_test.go b/provision/go/exports_test.go new file mode 100644 index 00000000..6ade9760 --- /dev/null +++ b/provision/go/exports_test.go @@ -0,0 +1,50 @@ +package tui_test + +import ( + "strings" + "testing" + + tui "github.com/oullin/tui" +) + +func TestPublicAPIConsumesFormAndOutput(t *testing.T) { + memoryOutput := tui.NewMemoryOutput() + interactive := true + err := tui.WithEnvironment(tui.Patch{ + Input: tui.NewScriptedInput([]string{"A", "d", "a", tui.KeyFirst(tui.KeyEnter)}), + Output: memoryOutput, + ErrorOutput: memoryOutput, + Interactive: &interactive, + }, func() error { + responses, err := tui.Form(). + Text(tui.TextOptions{Message: "Name"}). + Info("Saved", "note"). + Submit() + if err != nil { + return err + } + if responses.At(0) != "Ada" || responses.Named("note") != nil { + t.Fatalf("responses positional=%#v named=%#v", responses.Values(), responses.Names()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } + + if !strings.Contains(memoryOutput.Text(), "Saved") { + t.Fatalf("output missing note: %q", memoryOutput.Text()) + } +} + +func TestPublicAPIExportsCoreHelpers(t *testing.T) { + if tui.KeyFromEvent(tui.KeyEvent{Name: "u", Ctrl: true}) != tui.KeyFirst(tui.KeyCtrlU) { + t.Fatalf("ctrl-u key mapping mismatch") + } + if tui.VisibleWidth("\x1b[31mTokyo\x1b[39m") != 5 { + t.Fatalf("visible width mismatch") + } + if tui.ClearTerminalSequence() == "" || tui.HideCursorSequence() == "" { + t.Fatalf("terminal sequences should be exported") + } +} diff --git a/provision/go/go.mod b/provision/go/go.mod new file mode 100644 index 00000000..40c58976 --- /dev/null +++ b/provision/go/go.mod @@ -0,0 +1,23 @@ +module github.com/oullin/tui + +go 1.26.3 + +require github.com/mattn/go-runewidth v0.0.24 + +require ( + github.com/clipperhouse/uax29/v2 v2.2.0 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/sagikazarmark/locafero v0.11.0 // indirect + github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/spf13/viper v1.21.0 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/term v0.44.0 // indirect + golang.org/x/text v0.28.0 // indirect +) diff --git a/provision/go/go.sum b/provision/go/go.sum new file mode 100644 index 00000000..47f62dab --- /dev/null +++ b/provision/go/go.sum @@ -0,0 +1,33 @@ +github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY= +github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU= +github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= +github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/provision/go/internal/environment/environment.go b/provision/go/internal/environment/environment.go new file mode 100644 index 00000000..195fadb7 --- /dev/null +++ b/provision/go/internal/environment/environment.go @@ -0,0 +1,96 @@ +package environment + +import "sync" + +type Output interface { + Write(content string) +} + +type Input interface { + ReadKey() (string, bool, error) + ReadLine(message string) (string, error) +} + +type Environment struct { + Input Input + Output Output + ErrorOutput Output + Interactive bool +} + +type Patch struct { + Input Input + Output Output + ErrorOutput Output + Interactive *bool +} + +var ( + current = Environment{ + Input: NewScriptedInput(nil), + Output: NewMemoryOutput(), + ErrorOutput: NewMemoryOutput(), + Interactive: false, + } + currentMu sync.Mutex + scopes = map[uint64][]Environment{} +) + +func Current() Environment { + currentMu.Lock() + defer currentMu.Unlock() + + if scoped, ok := currentScope(); ok { + return scoped + } + + return current +} + +func Configure(patch Patch) { + currentMu.Lock() + defer currentMu.Unlock() + + if scoped, ok := currentScope(); ok { + updateCurrentScope(merge(scoped, patch)) + + return + } + + current = merge(current, patch) +} + +func With(patch Patch, callback func() error) error { + currentMu.Lock() + base := current + if scoped, ok := currentScope(); ok { + base = scoped + } + pushScope(merge(base, patch)) + currentMu.Unlock() + + defer func() { + currentMu.Lock() + popScope() + currentMu.Unlock() + }() + + return callback() +} + +func merge(environment Environment, patch Patch) Environment { + if patch.Input != nil { + environment.Input = patch.Input + } + if patch.Output != nil { + environment.Output = patch.Output + } + if patch.ErrorOutput != nil { + environment.ErrorOutput = patch.ErrorOutput + } + if patch.Interactive != nil { + environment.Interactive = *patch.Interactive + } + + return environment +} diff --git a/provision/go/internal/environment/environment_test.go b/provision/go/internal/environment/environment_test.go new file mode 100644 index 00000000..ea30ef9f --- /dev/null +++ b/provision/go/internal/environment/environment_test.go @@ -0,0 +1,121 @@ +package environment + +import ( + "errors" + "testing" +) + +func TestWithRestoresPreviousEnvironmentWhenCallbackFails(t *testing.T) { + output := NewMemoryOutput() + errorOutput := NewMemoryOutput() + previous := Current() + interactive := false + + Configure(Patch{Output: output, ErrorOutput: errorOutput, Interactive: &interactive}) + + scopedOutput := NewMemoryOutput() + scopedInteractive := true + err := With(Patch{Output: scopedOutput, Interactive: &scopedInteractive}, func() error { + current := Current() + if current.Output != scopedOutput { + t.Fatalf("Current().Output was not scoped output") + } + if current.ErrorOutput != errorOutput { + t.Fatalf("Current().ErrorOutput was not preserved") + } + if !current.Interactive { + t.Fatalf("Current().Interactive = false, want true") + } + + return errors.New("failed") + }) + + if err == nil || err.Error() != "failed" { + t.Fatalf("With() error = %v", err) + } + + current := Current() + if current.Output != output { + t.Fatalf("Current().Output was not restored") + } + if current.ErrorOutput != errorOutput { + t.Fatalf("Current().ErrorOutput was not restored") + } + if current.Interactive { + t.Fatalf("Current().Interactive = true, want false") + } + + Configure(Patch{ + Input: previous.Input, + Output: previous.Output, + ErrorOutput: previous.ErrorOutput, + Interactive: &previous.Interactive, + }) +} + +func TestConfigureMergesPartialPromptConfiguration(t *testing.T) { + previous := Current() + output := NewMemoryOutput() + errorOutput := NewMemoryOutput() + interactive := false + + Configure(Patch{Output: output, ErrorOutput: errorOutput, Interactive: &interactive}) + + nextOutput := NewMemoryOutput() + Configure(Patch{Output: nextOutput}) + + current := Current() + if current.Output != nextOutput { + t.Fatalf("Current().Output was not updated") + } + if current.ErrorOutput != errorOutput { + t.Fatalf("Current().ErrorOutput was not preserved") + } + if current.Interactive { + t.Fatalf("Current().Interactive = true, want false") + } + + Configure(Patch{ + Input: previous.Input, + Output: previous.Output, + ErrorOutput: previous.ErrorOutput, + Interactive: &previous.Interactive, + }) +} + +func TestMemoryOutputCapturesAndClears(t *testing.T) { + output := NewMemoryOutput() + + output.Write("first") + output.Write(" second") + + if got := output.Text(); got != "first second" { + t.Fatalf("Text() = %q", got) + } + + output.Clear() + + if got := output.Text(); got != "" { + t.Fatalf("Text() after Clear() = %q", got) + } +} + +func TestScriptedInputDrainsForKeysAndLines(t *testing.T) { + input := NewScriptedInput([]string{"a", "line"}) + + if got, ok, err := input.ReadKey(); err != nil || !ok || got != "a" { + t.Fatalf("ReadKey() = %q, %v, %v", got, ok, err) + } + + if got, err := input.ReadLine("Question"); err != nil || got != "line" { + t.Fatalf("ReadLine() = %q, %v", got, err) + } + + if got, ok, err := input.ReadKey(); err != nil || ok || got != "" { + t.Fatalf("ReadKey(empty) = %q, %v, %v", got, ok, err) + } + + if got, err := input.ReadLine("Question"); err != nil || got != "" { + t.Fatalf("ReadLine(empty) = %q, %v", got, err) + } +} diff --git a/provision/go/internal/environment/errors.go b/provision/go/internal/environment/errors.go new file mode 100644 index 00000000..c0312899 --- /dev/null +++ b/provision/go/internal/environment/errors.go @@ -0,0 +1,29 @@ +package environment + +import "errors" + +var ErrKeyInputUnsupported = errors.New("key input is unsupported") + +type PromptValidationError struct { + Message string +} + +func (err PromptValidationError) Error() string { + return err.Message +} + +type CancelHandler func(fallback string) (string, error) + +var cancelHandler CancelHandler + +func CancelUsing(handler CancelHandler) { + cancelHandler = handler +} + +func CancelPrompt(fallback string) (string, error) { + if cancelHandler == nil { + return fallback, nil + } + + return cancelHandler(fallback) +} diff --git a/provision/go/internal/environment/memory_output.go b/provision/go/internal/environment/memory_output.go new file mode 100644 index 00000000..f12a2c89 --- /dev/null +++ b/provision/go/internal/environment/memory_output.go @@ -0,0 +1,33 @@ +package environment + +import "sync" + +type MemoryOutput struct { + mu sync.Mutex + buffer string +} + +func NewMemoryOutput() *MemoryOutput { + return &MemoryOutput{} +} + +func (output *MemoryOutput) Write(content string) { + output.mu.Lock() + defer output.mu.Unlock() + + output.buffer += content +} + +func (output *MemoryOutput) Clear() { + output.mu.Lock() + defer output.mu.Unlock() + + output.buffer = "" +} + +func (output *MemoryOutput) Text() string { + output.mu.Lock() + defer output.mu.Unlock() + + return output.buffer +} diff --git a/provision/go/internal/environment/scope.go b/provision/go/internal/environment/scope.go new file mode 100644 index 00000000..0451faed --- /dev/null +++ b/provision/go/internal/environment/scope.go @@ -0,0 +1,80 @@ +package environment + +import ( + "runtime" + "strconv" + "strings" +) + +func currentScope() (Environment, bool) { + id, ok := currentScopeID() + if !ok { + return Environment{}, false + } + + stack := scopes[id] + if len(stack) == 0 { + return Environment{}, false + } + + return stack[len(stack)-1], true +} + +func updateCurrentScope(environment Environment) { + id, ok := currentScopeID() + if !ok { + return + } + + stack := scopes[id] + if len(stack) == 0 { + return + } + + stack[len(stack)-1] = environment + scopes[id] = stack +} + +func pushScope(environment Environment) { + id, ok := currentScopeID() + if !ok { + current = environment + + return + } + + scopes[id] = append(scopes[id], environment) +} + +func popScope() { + id, ok := currentScopeID() + if !ok { + return + } + + stack := scopes[id] + if len(stack) <= 1 { + delete(scopes, id) + + return + } + + scopes[id] = stack[:len(stack)-1] +} + +func currentScopeID() (uint64, bool) { + var buffer [64]byte + size := runtime.Stack(buffer[:], false) + line := string(buffer[:size]) + id, _, ok := strings.Cut(strings.TrimPrefix(line, "goroutine "), " ") + if !ok { + return 0, false + } + + parsed, err := strconv.ParseUint(id, 10, 64) + if err != nil { + return 0, false + } + + return parsed, true +} diff --git a/provision/go/internal/environment/scripted_input.go b/provision/go/internal/environment/scripted_input.go new file mode 100644 index 00000000..76a2b37a --- /dev/null +++ b/provision/go/internal/environment/scripted_input.go @@ -0,0 +1,34 @@ +package environment + +type ScriptedInput struct { + queued []string +} + +func NewScriptedInput(lines []string) *ScriptedInput { + queued := make([]string, len(lines)) + copy(queued, lines) + + return &ScriptedInput{queued: queued} +} + +func (input *ScriptedInput) ReadKey() (string, bool, error) { + if len(input.queued) == 0 { + return "", false, nil + } + + value := input.queued[0] + input.queued = input.queued[1:] + + return value, true, nil +} + +func (input *ScriptedInput) ReadLine(_ string) (string, error) { + if len(input.queued) == 0 { + return "", nil + } + + value := input.queued[0] + input.queued = input.queued[1:] + + return value, nil +} diff --git a/provision/go/internal/environment/stdio.go b/provision/go/internal/environment/stdio.go new file mode 100644 index 00000000..bc23b4c6 --- /dev/null +++ b/provision/go/internal/environment/stdio.go @@ -0,0 +1,89 @@ +package environment + +import ( + "bufio" + "errors" + "io" + stdstrings "strings" +) + +type StdioInput struct { + prompt io.Writer + reader *bufio.Reader +} + +type WriterOutput struct { + writer io.Writer +} + +func NewStdioInput(reader io.Reader, prompt io.Writer) *StdioInput { + return &StdioInput{prompt: prompt, reader: bufio.NewReader(reader)} +} + +func NewWriterOutput(writer io.Writer) WriterOutput { + return WriterOutput{writer: writer} +} + +func (input *StdioInput) ReadKey() (string, bool, error) { + value, err := input.reader.ReadByte() + if errors.Is(err, io.EOF) { + return "", false, nil + } + if err != nil { + return "", false, err + } + + if value == '\r' { + return "\n", true, nil + } + + if value == '\x1b' { + return input.readEscapeSequence(value), true, nil + } + + return string(value), true, nil +} + +func (input *StdioInput) ReadLine(message string) (string, error) { + if input.prompt != nil && message != "" { + if _, err := io.WriteString(input.prompt, message); err != nil { + return "", err + } + } + + value, err := input.reader.ReadString('\n') + if err != nil && !errors.Is(err, io.EOF) { + return "", err + } + + return stdstrings.TrimRight(value, "\r\n"), nil +} + +func (input *StdioInput) readEscapeSequence(first byte) string { + sequence := []byte{first} + + for input.reader.Buffered() > 0 { + next, err := input.reader.ReadByte() + if err != nil { + break + } + sequence = append(sequence, next) + if isEscapeSequenceFinal(next) { + break + } + } + + return string(sequence) +} + +func isEscapeSequenceFinal(value byte) bool { + return (value >= 'A' && value <= 'Z') || (value >= 'a' && value <= 'z') || value == '~' +} + +func (output WriterOutput) Write(content string) { + if output.writer == nil || content == "" { + return + } + + _, _ = io.WriteString(output.writer, content) +} diff --git a/provision/go/internal/environment/stdio_test.go b/provision/go/internal/environment/stdio_test.go new file mode 100644 index 00000000..15a007b4 --- /dev/null +++ b/provision/go/internal/environment/stdio_test.go @@ -0,0 +1,43 @@ +package environment + +import ( + "bytes" + "testing" +) + +func TestStdioInputReadsKeysLinesAndEscapeSequences(t *testing.T) { + var prompt bytes.Buffer + input := NewStdioInput(bytes.NewBufferString("A\r\x1b[Ahello\n"), &prompt) + + pressed, ok, err := input.ReadKey() + if err != nil || !ok || pressed != "A" { + t.Fatalf("first key = %q ok=%v err=%v", pressed, ok, err) + } + + pressed, ok, err = input.ReadKey() + if err != nil || !ok || pressed != "\n" { + t.Fatalf("enter key = %q ok=%v err=%v", pressed, ok, err) + } + + pressed, ok, err = input.ReadKey() + if err != nil || !ok || pressed != "\x1b[A" { + t.Fatalf("escape key = %q ok=%v err=%v", pressed, ok, err) + } + + line, err := input.ReadLine("Name: ") + if err != nil || line != "hello" { + t.Fatalf("line = %q err=%v", line, err) + } + if prompt.String() != "Name: " { + t.Fatalf("prompt = %q", prompt.String()) + } +} + +func TestWriterOutputWritesToWriter(t *testing.T) { + var output bytes.Buffer + NewWriterOutput(&output).Write("hello") + + if output.String() != "hello" { + t.Fatalf("output = %q", output.String()) + } +} diff --git a/provision/go/internal/form/builder.go b/provision/go/internal/form/builder.go new file mode 100644 index 00000000..a2e58169 --- /dev/null +++ b/provision/go/internal/form/builder.go @@ -0,0 +1,148 @@ +package form + +import "errors" + +type StepFunc func(responses *Responses, previous any, name string) (any, error) +type ConditionFunc func(responses *Responses) (bool, error) + +type Step struct { + Condition ConditionFunc + IgnoreWhenReverting bool + Name string + Run StepFunc +} + +type Builder struct { + responses *Responses + steps []Step +} + +func New() *Builder { + return &Builder{responses: NewResponses()} +} + +func (builder *Builder) Add(run StepFunc, name ...string) *Builder { + return builder.addStep(trueCondition, run, firstName(name), false) +} + +func (builder *Builder) AddSideEffect(run func() error, name ...string) *Builder { + return builder.addStep(trueCondition, SideEffect(run), firstName(name), true) +} + +func (builder *Builder) AddIf(condition ConditionFunc, run StepFunc, name ...string) *Builder { + return builder.addStep(condition, run, firstName(name), false) +} + +func (builder *Builder) AddSideEffectIf(condition ConditionFunc, run func() error, name ...string) *Builder { + return builder.addStep(condition, SideEffect(run), firstName(name), true) +} + +func (builder *Builder) Submit() (*Responses, error) { + index := 0 + wasReverted := false + + for index < len(builder.steps) { + step := builder.steps[index] + + if wasReverted && index > 0 { + ignore, err := shouldIgnoreWhenReverting(step, builder.responses) + if err != nil { + return nil, err + } + if ignore { + index-- + continue + } + } + + wasReverted = false + shouldRun, err := shouldRun(step, builder.responses) + if err != nil { + return nil, err + } + if !shouldRun { + builder.responses.set(index, step.Name, nil) + index++ + continue + } + + previous := builder.responses.previous(index, step.Name) + value, err := runWithRevert(index > 0, func() (any, error) { + return step.Run(builder.responses, previous, step.Name) + }) + if err != nil { + if !errors.Is(err, ErrReverted) { + return nil, err + } + wasReverted = true + } else { + builder.responses.set(index, step.Name, value) + } + + if wasReverted { + index-- + } else { + index++ + } + } + + return builder.responses, nil +} + +func (builder *Builder) addStep(condition ConditionFunc, run StepFunc, name string, ignoreWhenReverting bool) *Builder { + if condition == nil { + condition = trueCondition + } + builder.steps = append(builder.steps, Step{ + Condition: condition, + IgnoreWhenReverting: ignoreWhenReverting, + Name: name, + Run: run, + }) + return builder +} + +func SideEffect(callback func() error) StepFunc { + return func(*Responses, any, string) (any, error) { + if callback == nil { + return nil, nil + } + + return nil, callback() + } +} + +func Bool(condition bool) ConditionFunc { + return func(*Responses) (bool, error) { + return condition, nil + } +} + +func trueCondition(*Responses) (bool, error) { + return true, nil +} + +func shouldRun(step Step, responses *Responses) (bool, error) { + if step.Condition == nil { + return true, nil + } + + return step.Condition(responses) +} + +func shouldIgnoreWhenReverting(step Step, responses *Responses) (bool, error) { + run, err := shouldRun(step, responses) + if err != nil || !run { + return true, err + } + + return step.IgnoreWhenReverting, nil +} + +func firstName(values []string) string { + if len(values) == 0 { + return "" + } + + return values[0] +} diff --git a/provision/go/internal/form/form_test.go b/provision/go/internal/form/form_test.go new file mode 100644 index 00000000..2fa96e86 --- /dev/null +++ b/provision/go/internal/form/form_test.go @@ -0,0 +1,214 @@ +package form + +import ( + "reflect" + "strings" + "testing" + + "github.com/oullin/tui/internal/environment" + "github.com/oullin/tui/internal/key" + "github.com/oullin/tui/internal/output" + "github.com/oullin/tui/internal/prompts" +) + +func withFormEnvironment(keys []string, callback func(*environment.MemoryOutput) error) error { + memoryOutput := environment.NewMemoryOutput() + interactive := true + return environment.With( + environment.Patch{ + Input: environment.NewScriptedInput(keys), + Output: memoryOutput, + ErrorOutput: memoryOutput, + Interactive: &interactive, + }, + func() error { + return callback(memoryOutput) + }, + ) +} + +func TestBuilderRunsChainedStepsAndReturnsPositionalResponses(t *testing.T) { + err := withFormEnvironment([]string{"A", "d", "a", key.First(key.Enter), key.First(key.Enter), key.First(key.Enter)}, func(*environment.MemoryOutput) error { + responses, err := New(). + Text(prompts.TextOptions{Message: "Name"}). + Select(prompts.ChoiceOptions{Message: "Runtime", Choices: prompts.NewStringChoices("TS", "JS")}). + Confirm(prompts.ConfirmOptions{Message: "Active"}). + Submit() + if err != nil { + return err + } + + if responses.At(0) != "Ada" || responses.At(1) != "TS" || responses.At(2) != true { + t.Fatalf("responses = %#v", responses.Values()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestBuilderKeysNamedResponsesAndPassesPreviousAndName(t *testing.T) { + err := withFormEnvironment([]string{"A", "d", "a", key.First(key.Enter)}, func(*environment.MemoryOutput) error { + responses, err := New(). + Text(prompts.TextOptions{Message: "Name"}, "name"). + Add(func(values *Responses, previous any, name string) (any, error) { + if previous == nil { + previous = "new" + } + return name + ":" + previous.(string) + ":Hello " + values.Named("name").(string), nil + }, "greeting"). + Submit() + if err != nil { + return err + } + + if responses.Named("name") != "Ada" || responses.Named("greeting") != "greeting:new:Hello Ada" { + t.Fatalf("named responses = %#v", responses.Names()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestBuilderStoresNilForSkippedConditionalSteps(t *testing.T) { + err := withFormEnvironment([]string{"n"}, func(*environment.MemoryOutput) error { + responses, err := New(). + Confirm(prompts.ConfirmOptions{Message: "Include details?", Default: false, HasDefault: true}, "include"). + AddIf(func(values *Responses) (bool, error) { + return values.Named("include") == true, nil + }, func(*Responses, any, string) (any, error) { + return "details", nil + }, "details"). + Submit() + if err != nil { + return err + } + + if responses.Named("include") != false || responses.Named("details") != nil { + t.Fatalf("named responses = %#v", responses.Names()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestBuilderRevertsToPreviousStepAndReusesPriorResponse(t *testing.T) { + err := withFormEnvironment([]string{ + "A", key.First(key.Enter), + "B", key.First(key.CtrlU), + key.First(key.Backspace), "C", key.First(key.Enter), + "D", key.First(key.Enter), + }, func(*environment.MemoryOutput) error { + responses, err := New(). + Text(prompts.TextOptions{Message: "First"}). + Text(prompts.TextOptions{Message: "Second"}). + Submit() + if err != nil { + return err + } + + if responses.At(0) != "C" || responses.At(1) != "D" { + t.Fatalf("responses = %#v", responses.Values()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestBuilderReusesPreviousPromptDefaultsByType(t *testing.T) { + err := withFormEnvironment([]string{ + "4", key.First(key.Enter), + key.First(key.Space), key.First(key.Enter), + key.First(key.CtrlU), + key.First(key.Enter), + "D", key.First(key.Enter), + }, func(*environment.MemoryOutput) error { + responses, err := New(). + Number(prompts.NumberOptions{Message: "Count"}). + Multiselect(prompts.MultiChoiceOptions{Message: "Flags", Choices: prompts.NewStringChoices("alpha", "beta")}). + Text(prompts.TextOptions{Message: "Done"}). + Submit() + if err != nil { + return err + } + + if responses.At(0) != 4 || !reflect.DeepEqual(responses.At(1), []any{"alpha"}) || responses.At(2) != "D" { + t.Fatalf("responses = %#v", responses.Values()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestBuilderRevertsPastIgnoredSideEffectSteps(t *testing.T) { + err := withFormEnvironment([]string{ + "A", key.First(key.Enter), + "B", key.First(key.CtrlU), + key.First(key.Backspace), "C", key.First(key.Enter), + "D", key.First(key.Enter), + }, func(*environment.MemoryOutput) error { + responses, err := New(). + Text(prompts.TextOptions{Message: "First"}). + Note("Skipping back over this", output.NoteType(""), "note"). + Text(prompts.TextOptions{Message: "Second"}). + Submit() + if err != nil { + return err + } + + if responses.At(0) != "C" || responses.Named("note") != nil || responses.At(2) != "D" { + t.Fatalf("positional=%#v named=%#v", responses.Values(), responses.Names()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestBuilderDoesNotRevertTheFirstStep(t *testing.T) { + err := withFormEnvironment([]string{key.First(key.CtrlU), "A", key.First(key.Enter)}, func(output *environment.MemoryOutput) error { + responses, err := New().Text(prompts.TextOptions{Message: "First"}).Submit() + if err != nil { + return err + } + + if responses.At(0) != "A" || !strings.Contains(output.Text(), "This cannot be reverted.") { + t.Fatalf("response=%#v output=%q", responses.Values(), output.Text()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestBuilderRunsOutputAndStatusAdapters(t *testing.T) { + err := withFormEnvironment(nil, func(memoryOutput *environment.MemoryOutput) error { + responses, err := New(). + Info("Preparing", "info"). + Spin("Working", func() (any, error) { return "done", nil }, "spin"). + Submit() + if err != nil { + return err + } + + rendered := memoryOutput.Text() + if responses.Named("info") != nil || responses.Named("spin") != "done" || !strings.Contains(rendered, "Preparing") || !strings.Contains(rendered, "Working") { + t.Fatalf("named=%#v output=%q", responses.Names(), rendered) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} diff --git a/provision/go/internal/form/output.go b/provision/go/internal/form/output.go new file mode 100644 index 00000000..d3cf86b5 --- /dev/null +++ b/provision/go/internal/form/output.go @@ -0,0 +1,87 @@ +package form + +import "github.com/oullin/tui/internal/output" + +func (builder *Builder) Alert(message string, name ...string) *Builder { + return builder.AddSideEffect(func() error { + output.Alert(message) + return nil + }, name...) +} + +func (builder *Builder) Clear(name ...string) *Builder { + return builder.AddSideEffect(func() error { + output.Clear() + return nil + }, name...) +} + +func (builder *Builder) Error(message string, name ...string) *Builder { + return builder.AddSideEffect(func() error { + output.Error(message) + return nil + }, name...) +} + +func (builder *Builder) Grid(items []any, maxWidth int, name ...string) *Builder { + return builder.AddSideEffect(func() error { + output.Grid(items, maxWidth) + return nil + }, name...) +} + +func (builder *Builder) Info(message string, name ...string) *Builder { + return builder.AddSideEffect(func() error { + output.Info(message) + return nil + }, name...) +} + +func (builder *Builder) Intro(message string, name ...string) *Builder { + return builder.AddSideEffect(func() error { + output.Intro(message) + return nil + }, name...) +} + +func (builder *Builder) Note(message string, noteType output.NoteType, name ...string) *Builder { + return builder.AddSideEffect(func() error { + output.Note(message, noteType) + return nil + }, name...) +} + +func (builder *Builder) Outro(message string, name ...string) *Builder { + return builder.AddSideEffect(func() error { + output.Outro(message) + return nil + }, name...) +} + +func (builder *Builder) Table(headers []string, rows [][]any, name ...string) *Builder { + return builder.AddSideEffect(func() error { + output.Table(headers, rows) + return nil + }, name...) +} + +func (builder *Builder) TableObjects(headers []string, rows []output.TableRow, name ...string) *Builder { + return builder.AddSideEffect(func() error { + output.TableObjects(headers, rows) + return nil + }, name...) +} + +func (builder *Builder) Title(value string, name ...string) *Builder { + return builder.AddSideEffect(func() error { + output.Title(value) + return nil + }, name...) +} + +func (builder *Builder) Warning(message string, name ...string) *Builder { + return builder.AddSideEffect(func() error { + output.Warning(message) + return nil + }, name...) +} diff --git a/provision/go/internal/form/previous.go b/provision/go/internal/form/previous.go new file mode 100644 index 00000000..a448fea0 --- /dev/null +++ b/provision/go/internal/form/previous.go @@ -0,0 +1,55 @@ +package form + +import "fmt" + +func PreviousString(previous any, defaultValue string) string { + value, ok := previous.(string) + if !ok { + return defaultValue + } + + return value +} + +func PreviousNumber(previous any, defaultValue string) string { + switch value := previous.(type) { + case nil: + return defaultValue + case string: + return value + case int: + return fmt.Sprint(value) + case int64: + return fmt.Sprint(value) + case float64: + return fmt.Sprint(value) + default: + return defaultValue + } +} + +func PreviousBool(previous any, defaultValue bool) bool { + value, ok := previous.(bool) + if !ok { + return defaultValue + } + + return value +} + +func PreviousSlice(previous any, defaultValue []any) []any { + value, ok := previous.([]any) + if !ok { + return append([]any{}, defaultValue...) + } + + return append([]any{}, value...) +} + +func PreviousValue(previous any, defaultValue any) any { + if previous == nil { + return defaultValue + } + + return previous +} diff --git a/provision/go/internal/form/prompts.go b/provision/go/internal/form/prompts.go new file mode 100644 index 00000000..3dfc719e --- /dev/null +++ b/provision/go/internal/form/prompts.go @@ -0,0 +1,111 @@ +package form + +import ( + "github.com/oullin/tui/internal/output" + "github.com/oullin/tui/internal/prompt" + "github.com/oullin/tui/internal/prompts" +) + +func (builder *Builder) Text(options prompts.TextOptions, name ...string) *Builder { + return builder.Add(func(_ *Responses, previous any, _ string) (any, error) { + options.Default = PreviousString(previous, options.Default) + return prompts.Text(options) + }, name...) +} + +func (builder *Builder) Textarea(options prompts.TextOptions, name ...string) *Builder { + return builder.Add(func(_ *Responses, previous any, _ string) (any, error) { + options.Default = PreviousString(previous, options.Default) + return prompts.Textarea(options) + }, name...) +} + +func (builder *Builder) Password(options prompts.TextOptions, name ...string) *Builder { + return builder.Add(func(_ *Responses, previous any, _ string) (any, error) { + options.Default = PreviousString(previous, options.Default) + return prompts.Password(options) + }, name...) +} + +func (builder *Builder) Number(options prompts.NumberOptions, name ...string) *Builder { + return builder.Add(func(_ *Responses, previous any, _ string) (any, error) { + previousValue := PreviousNumber(previous, options.Default) + options.Default = previousValue + options.HasDefault = options.HasDefault || previousValue != "" + return prompts.Number(options) + }, name...) +} + +func (builder *Builder) Pause(message string, name ...string) *Builder { + return builder.Add(func(*Responses, any, string) (any, error) { + return prompts.Pause(message) + }, name...) +} + +func (builder *Builder) Confirm(options prompts.ConfirmOptions, name ...string) *Builder { + return builder.Add(func(_ *Responses, previous any, _ string) (any, error) { + if previous != nil { + options.Default = PreviousBool(previous, options.Default) + } else if !options.HasDefault { + options.Default = true + } + options.HasDefault = true + return prompts.Confirm(options) + }, name...) +} + +func (builder *Builder) Select(options prompts.ChoiceOptions, name ...string) *Builder { + return builder.Add(func(_ *Responses, previous any, _ string) (any, error) { + options.Default = PreviousValue(previous, options.Default) + options.HasDefault = options.HasDefault || previous != nil + return prompts.Select(options) + }, name...) +} + +func (builder *Builder) Multiselect(options prompts.MultiChoiceOptions, name ...string) *Builder { + return builder.Add(func(_ *Responses, previous any, _ string) (any, error) { + options.Defaults = PreviousSlice(previous, options.Defaults) + return prompts.Multiselect(options) + }, name...) +} + +func (builder *Builder) Search(options prompts.SearchOptions, name ...string) *Builder { + return builder.Add(func(_ *Responses, previous any, _ string) (any, error) { + options.Default = PreviousValue(previous, options.Default) + options.HasDefault = options.HasDefault || previous != nil + return prompts.Search(options) + }, name...) +} + +func (builder *Builder) Multisearch(options prompts.MultiSearchOptions, name ...string) *Builder { + return builder.Add(func(_ *Responses, previous any, _ string) (any, error) { + options.Defaults = PreviousSlice(previous, options.Defaults) + return prompts.MultiSearch(options) + }, name...) +} + +func (builder *Builder) Suggest(options prompts.SuggestOptions, name ...string) *Builder { + return builder.Add(func(_ *Responses, previous any, _ string) (any, error) { + options.Default = PreviousString(previous, options.Default) + return prompts.Suggest(options) + }, name...) +} + +func (builder *Builder) Autocomplete(options prompts.SuggestOptions, name ...string) *Builder { + return builder.Add(func(_ *Responses, previous any, _ string) (any, error) { + options.Default = PreviousString(previous, options.Default) + return prompts.Autocomplete(options) + }, name...) +} + +func (builder *Builder) DataTable(options output.DataTableOptions, name ...string) *Builder { + return builder.Add(func(_ *Responses, previous any, _ string) (any, error) { + options.Default = PreviousValue(previous, options.Default) + options.HasDefault = options.HasDefault || previous != nil + return output.DataTable(options) + }, name...) +} + +func Required(value bool) prompt.Required { + return prompt.Required{Enabled: value} +} diff --git a/provision/go/internal/form/responses.go b/provision/go/internal/form/responses.go new file mode 100644 index 00000000..dbb4c73f --- /dev/null +++ b/provision/go/internal/form/responses.go @@ -0,0 +1,70 @@ +package form + +type Responses struct { + named map[string]any + positional []any +} + +func NewResponses() *Responses { + return &Responses{named: map[string]any{}} +} + +func (responses *Responses) At(index int) any { + if responses == nil || index < 0 || index >= len(responses.positional) { + return nil + } + + return responses.positional[index] +} + +func (responses *Responses) Named(name string) any { + if responses == nil || name == "" { + return nil + } + + return responses.named[name] +} + +func (responses *Responses) Values() []any { + if responses == nil { + return nil + } + + values := make([]any, len(responses.positional)) + copy(values, responses.positional) + + return values +} + +func (responses *Responses) Names() map[string]any { + if responses == nil { + return nil + } + + values := make(map[string]any, len(responses.named)) + for key, value := range responses.named { + values[key] = value + } + + return values +} + +func (responses *Responses) previous(index int, name string) any { + if name != "" { + return responses.Named(name) + } + + return responses.At(index) +} + +func (responses *Responses) set(index int, name string, value any) { + if name != "" { + responses.named[name] = value + return + } + + for len(responses.positional) <= index { + responses.positional = append(responses.positional, nil) + } + responses.positional[index] = value +} diff --git a/provision/go/internal/form/revert.go b/provision/go/internal/form/revert.go new file mode 100644 index 00000000..7a21ab4c --- /dev/null +++ b/provision/go/internal/form/revert.go @@ -0,0 +1,58 @@ +package form + +import ( + "errors" + + "github.com/oullin/tui/internal/environment" + "github.com/oullin/tui/internal/key" + "github.com/oullin/tui/internal/theme" +) + +var ErrReverted = errors.New("form reverted") + +type revertInput struct { + canRevert bool + previous environment.Input +} + +func runWithRevert(canRevert bool, callback func() (any, error)) (any, error) { + env := environment.Current() + if env.Input == nil { + return callback() + } + + return callbackWithInput(revertInput{canRevert: canRevert, previous: env.Input}, callback) +} + +func callbackWithInput(input environment.Input, callback func() (any, error)) (any, error) { + returnValue := any(nil) + err := environment.With(environment.Patch{Input: input}, func() error { + value, callbackErr := callback() + returnValue = value + return callbackErr + }) + + return returnValue, err +} + +func (input revertInput) ReadKey() (string, bool, error) { + pressed, ok, err := input.previous.ReadKey() + if err != nil || !ok { + return pressed, ok, err + } + + if pressed != key.First(key.CtrlU) { + return pressed, ok, nil + } + + if input.canRevert { + return "", false, ErrReverted + } + + environment.Current().ErrorOutput.Write(theme.Red(" ⚠ This cannot be reverted.") + "\n") + return input.ReadKey() +} + +func (input revertInput) ReadLine(message string) (string, error) { + return input.previous.ReadLine(message) +} diff --git a/provision/go/internal/form/status.go b/provision/go/internal/form/status.go new file mode 100644 index 00000000..38c6d7bb --- /dev/null +++ b/provision/go/internal/form/status.go @@ -0,0 +1,30 @@ +package form + +import "github.com/oullin/tui/internal/status" + +func (builder *Builder) Progress(total int, message string, name ...string) *Builder { + return builder.AddSideEffect(func() error { + bar := status.NewProgress(total, message, "") + bar.Start() + bar.Finish() + return nil + }, name...) +} + +func (builder *Builder) Spin(message string, callback func() (any, error), name ...string) *Builder { + return builder.Add(func(*Responses, any, string) (any, error) { + return status.Spin(message, callback) + }, name...) +} + +func (builder *Builder) Stream(name ...string) *Builder { + return builder.Add(func(*Responses, any, string) (any, error) { + return status.NewStream(), nil + }, name...) +} + +func (builder *Builder) Task(label string, callback func(*status.Logger) (any, error), limit int, keepSummary bool, subLabel string, name ...string) *Builder { + return builder.Add(func(*Responses, any, string) (any, error) { + return status.Task(label, callback, limit, keepSummary, subLabel) + }, name...) +} diff --git a/provision/go/internal/key/key.go b/provision/go/internal/key/key.go new file mode 100644 index 00000000..775aca55 --- /dev/null +++ b/provision/go/internal/key/key.go @@ -0,0 +1,175 @@ +package key + +type Value []string + +type Event struct { + Name string + Sequence string + Ctrl bool + Meta bool + Shift bool +} + +var ( + Up = Value{"\x1b[A"} + ShiftUp = Value{"\x1b[1;2A"} + PageUp = Value{"\x1b[5~"} + Down = Value{"\x1b[B"} + ShiftDown = Value{"\x1b[1;2B"} + PageDown = Value{"\x1b[6~"} + Right = Value{"\x1b[C"} + Left = Value{"\x1b[D"} + UpArrow = Value{"\x1bOA"} + DownArrow = Value{"\x1bOB"} + RightArrow = Value{"\x1bOC"} + LeftArrow = Value{"\x1bOD"} + Escape = Value{"\x1b"} + Delete = Value{"\x1b[3~"} + Backspace = Value{"\x7f"} + Enter = Value{"\n"} + Space = Value{" "} + Tab = Value{"\t"} + ShiftTab = Value{"\x1b[Z"} + Home = Value{"\x1b[1~", "\x1bOH", "\x1b[H", "\x1b[7~"} + End = Value{"\x1b[4~", "\x1bOF", "\x1b[F", "\x1b[8~"} + CtrlC = Value{"\x03"} + CtrlP = Value{"\x10"} + CtrlN = Value{"\x0e"} + CtrlF = Value{"\x06"} + CtrlB = Value{"\x02"} + CtrlH = Value{"\x08"} + CtrlA = Value{"\x01"} + CtrlD = Value{"\x04"} + CtrlE = Value{"\x05"} + CtrlU = Value{"\x15"} + OptionBackspace = Value{"\x1b\x7f"} +) + +var named = map[string]Value{ + "up": Up, + "shiftUp": ShiftUp, + "pageUp": PageUp, + "down": Down, + "shiftDown": ShiftDown, + "pageDown": PageDown, + "right": Right, + "left": Left, + "upArrow": UpArrow, + "downArrow": DownArrow, + "rightArrow": RightArrow, + "leftArrow": LeftArrow, + "escape": Escape, + "delete": Delete, + "backspace": Backspace, + "enter": Enter, + "space": Space, + "tab": Tab, + "shiftTab": ShiftTab, + "home": Home, + "end": End, + "ctrlC": CtrlC, + "ctrlP": CtrlP, + "ctrlN": CtrlN, + "ctrlF": CtrlF, + "ctrlB": CtrlB, + "ctrlH": CtrlH, + "ctrlA": CtrlA, + "ctrlD": CtrlD, + "ctrlE": CtrlE, + "ctrlU": CtrlU, + "optionBackspace": OptionBackspace, +} + +var ctrlKeys = map[string]Value{ + "a": CtrlA, + "b": CtrlB, + "c": CtrlC, + "d": CtrlD, + "e": CtrlE, + "f": CtrlF, + "h": CtrlH, + "n": CtrlN, + "p": CtrlP, + "u": CtrlU, +} + +var aliases = map[string]Value{ + "pagedown": PageDown, + "pageup": PageUp, + "return": Enter, +} + +func First(value Value) string { + if len(value) == 0 { + return "" + } + + return value[0] +} + +func FromEvent(event Event) string { + name := lowerASCII(event.Name) + + if event.Ctrl && name != "" { + if value, ok := ctrlKeys[name]; ok { + return First(value) + } + } + + if event.Meta && event.Name == "backspace" { + return First(OptionBackspace) + } + + if event.Shift && name == "tab" { + return First(ShiftTab) + } + + if name != "" { + if value, ok := aliases[name]; ok { + return First(value) + } + } + + if event.Name != "" { + if value, ok := named[event.Name]; ok { + return First(value) + } + } + + if event.Sequence == " " { + return First(Space) + } + + if event.Sequence == "\r" || event.Sequence == "\n" { + return First(Enter) + } + + if event.Name != "" { + return event.Name + } + + return event.Sequence +} + +func OneOf(keys []Value, match string) (string, bool) { + for _, value := range keys { + for _, candidate := range value { + if candidate == match { + return match, true + } + } + } + + return "", false +} + +func lowerASCII(value string) string { + bytes := []byte(value) + for index, char := range bytes { + if char >= 'A' && char <= 'Z' { + bytes[index] = char + ('a' - 'A') + } + } + + return string(bytes) +} diff --git a/provision/go/internal/key/key_test.go b/provision/go/internal/key/key_test.go new file mode 100644 index 00000000..713ec8a3 --- /dev/null +++ b/provision/go/internal/key/key_test.go @@ -0,0 +1,61 @@ +package key + +import "testing" + +func TestFromEventMapsNamedKeys(t *testing.T) { + if got := FromEvent(Event{Name: "enter"}); got != First(Enter) { + t.Fatalf("FromEvent() = %q, want %q", got, First(Enter)) + } +} + +func TestFromEventMapsAliases(t *testing.T) { + tests := []struct { + name string + want string + }{ + {name: "return", want: First(Enter)}, + {name: "pageup", want: First(PageUp)}, + {name: "pagedown", want: First(PageDown)}, + } + + for _, test := range tests { + if got := FromEvent(Event{Name: test.name}); got != test.want { + t.Fatalf("FromEvent(%q) = %q, want %q", test.name, got, test.want) + } + } +} + +func TestFromEventMapsSequencesAndModifiers(t *testing.T) { + tests := []struct { + event Event + want string + }{ + {event: Event{Sequence: " "}, want: First(Space)}, + {event: Event{Sequence: "\n"}, want: First(Enter)}, + {event: Event{Ctrl: true, Name: "c"}, want: First(CtrlC)}, + {event: Event{Ctrl: true, Name: "n"}, want: First(CtrlN)}, + {event: Event{Ctrl: true, Name: "U"}, want: First(CtrlU)}, + {event: Event{Meta: true, Name: "backspace"}, want: First(OptionBackspace)}, + {event: Event{Shift: true, Name: "tab"}, want: First(ShiftTab)}, + } + + for _, test := range tests { + if got := FromEvent(test.event); got != test.want { + t.Fatalf("FromEvent(%+v) = %q, want %q", test.event, got, test.want) + } + } +} + +func TestOneOfMatchesGroupedKeys(t *testing.T) { + if got, ok := OneOf([]Value{Home, End}, Home[2]); !ok || got != Home[2] { + t.Fatalf("OneOf(Home[2]) = %q, %v", got, ok) + } + + if got, ok := OneOf([]Value{Home, End}, End[3]); !ok || got != End[3] { + t.Fatalf("OneOf(End[3]) = %q, %v", got, ok) + } + + if _, ok := OneOf([]Value{Home, End}, First(Enter)); ok { + t.Fatalf("OneOf(Enter) matched unexpectedly") + } +} diff --git a/provision/go/internal/output/datatable.go b/provision/go/internal/output/datatable.go new file mode 100644 index 00000000..0369d3a4 --- /dev/null +++ b/provision/go/internal/output/datatable.go @@ -0,0 +1,300 @@ +package output + +import ( + "errors" + "fmt" + stdstrings "strings" + + "github.com/oullin/tui/internal/environment" + "github.com/oullin/tui/internal/key" + "github.com/oullin/tui/internal/prompt" + "github.com/oullin/tui/internal/theme" + "github.com/oullin/tui/internal/typedvalue" +) + +type DataTableRow struct { + Cells map[string]any + Value any +} + +type DataTableOptions struct { + Message string + Headers []string + Rows []DataTableRow + Default any + HasDefault bool + Required prompt.Required + Validate prompt.Validator[any] + Transform func(any) (any, error) + Filter func(string, DataTableRow) bool + Scroll int +} + +func DataTable(options DataTableOptions) (any, error) { + if options.Scroll == 0 { + options.Scroll = 10 + } + if len(options.Headers) == 0 { + options.Headers = deriveHeaders(options.Rows) + } + defaultValue := options.Default + if options.HasDefault && options.Transform != nil { + transformed, err := options.Transform(defaultValue) + if err == nil { + defaultValue = transformed + } + } + var submitted *dataTableSelection + return prompt.UntilValid( + prompt.Options[any]{Message: options.Message, Default: defaultValue, Required: options.Required, Validate: options.Validate}, + func(_ int) (any, error) { + selection, err := readDataTableSelection(options) + if err != nil { + return nil, err + } + value := selection.Value + if options.Transform != nil { + value, err = options.Transform(value) + if err != nil { + return nil, err + } + } + if selection.Submitted && !selection.Cancelled { + submitted = &selection + } + return value, nil + }, + func(any) error { + if submitted != nil { + renderSubmittedDataTableFrame(options.Message, options.Headers, submitted.Row) + } + return nil + }, + ) +} + +type dataTableSelection struct { + Cancelled bool + Submitted bool + Row DataTableRow + Value any +} + +func readDataTableSelection(options DataTableOptions) (dataTableSelection, error) { + env := environment.Current() + rows := visibleRows(options, "") + selected := defaultDataTableIndex(rows, options.Default, options.HasDefault) + query := "" + mode := "browse" + renderDataTableFrame(options, rows, selected, mode, query) + + for { + pressed, ok, err := env.Input.ReadKey() + if err != nil { + if errors.Is(err, environment.ErrKeyInputUnsupported) { + answer, lineErr := env.Input.ReadLine(theme.RenderQuestion(options.Message, "")) + if lineErr != nil { + return dataTableSelection{}, lineErr + } + rows = visibleRows(options, answer) + if len(rows) == 0 { + return dataTableSelection{}, prompt.ValidationError("Please select a valid option.") + } + return dataTableSelection{Row: rows[0], Value: dataTableRowValue(rows[0], 0)}, nil + } + return dataTableSelection{}, err + } + if !ok || pressed == key.First(key.Enter) { + if len(rows) == 0 { + return dataTableSelection{}, prompt.ValidationError("Please select a valid option.") + } + row := rows[selected] + return dataTableSelection{Submitted: true, Row: row, Value: dataTableRowValue(row, selected)}, nil + } + if pressed == key.First(key.CtrlC) { + if len(rows) == 0 { + return dataTableSelection{}, prompt.ValidationError("Please select a valid option.") + } + row := rows[selected] + renderCancelledDataTableFrame(options, rows, selected) + return dataTableSelection{Cancelled: true, Row: row, Value: dataTableRowValue(row, selected)}, nil + } + if pressed == "/" { + mode = "search" + query = "" + renderDataTableFrame(options, rows, selected, mode, query) + continue + } + if mode == "search" { + if pressed == key.First(key.Escape) { + mode = "browse" + query = "" + rows = visibleRows(options, query) + selected = clampSelection(selected, rows) + renderDataTableFrame(options, rows, selected, mode, query) + continue + } + next := typedvalue.ApplyKey(typedvalue.State{Cursor: typedvalue.CharacterLength(query), Value: query}, pressed, false) + if !next.Submitted && !next.Cancelled { + query = next.Value + rows = visibleRows(options, query) + selected = clampSelection(0, rows) + renderDataTableFrame(options, rows, selected, mode, query) + continue + } + } + if direction, ok := dataTableNavigation(pressed); ok { + selected = clampSelection(selected+direction, rows) + renderDataTableFrame(options, rows, selected, mode, query) + continue + } + } +} + +func renderDataTableFrame(options DataTableOptions, rows []DataTableRow, selected int, mode string, query string) { + title := options.Message + if mode == "search" || query != "" { + title += " " + query + } + if len(rows) == 0 { + environment.Current().Output.Write(title + "\n" + theme.RenderTable(nil, [][]string{{"No results found."}}) + "\n") + return + } + windowStart := 0 + windowEnd := len(rows) + if options.Scroll > 0 && options.Scroll < len(rows) { + windowStart = selected - (options.Scroll-1)/2 + if windowStart < 0 { + windowStart = 0 + } + if windowStart+options.Scroll > len(rows) { + windowStart = len(rows) - options.Scroll + } + windowEnd = windowStart + options.Scroll + } + rendered := make([][]string, windowEnd-windowStart) + for index := windowStart; index < windowEnd; index++ { + pointer := " " + if index == selected { + pointer = "›" + } + rendered[index-windowStart] = append([]string{pointer}, dataTableRowCells(options.Headers, rows[index])...) + } + headers := append([]string{""}, options.Headers...) + environment.Current().Output.Write(title + "\n" + theme.RenderTable(headers, rendered) + "\n") +} + +func renderSubmittedDataTableFrame(message string, headers []string, row DataTableRow) { + environment.Current().Output.Write(message + "\n" + stdstrings.Join(dataTableRowCells(headers, row), ", ") + "\n") +} + +func renderCancelledDataTableFrame(options DataTableOptions, rows []DataTableRow, selected int) { + rendered := make([][]string, len(rows)) + for index, row := range rows { + pointer := " " + if index == selected { + pointer = "›" + } + cells := dataTableRowCells(options.Headers, row) + for cellIndex, cell := range cells { + cells[cellIndex] = theme.Strikethrough(theme.Dim(cell)) + } + rendered[index] = append([]string{pointer}, cells...) + } + env := environment.Current() + env.Output.Write(options.Message + "\n") + env.Output.Write(theme.Dim("/ Search") + "\n") + env.Output.Write(theme.RenderTable(append([]string{""}, options.Headers...), rendered) + "\n") + env.ErrorOutput.Write(theme.Red(" ⚠ Cancelled.") + "\n") +} + +func visibleRows(options DataTableOptions, query string) []DataTableRow { + rows := make([]DataTableRow, 0, len(options.Rows)) + for _, row := range options.Rows { + if query == "" { + rows = append(rows, row) + continue + } + if options.Filter != nil { + if options.Filter(query, row) { + rows = append(rows, row) + } + continue + } + if stdstrings.Contains(stdstrings.ToLower(stdstrings.Join(dataTableRowCells(options.Headers, row), " ")), stdstrings.ToLower(query)) { + rows = append(rows, row) + } + } + return rows +} + +func deriveHeaders(rows []DataTableRow) []string { + if len(rows) == 0 { + return nil + } + headers := make([]string, 0, len(rows[0].Cells)) + for key := range rows[0].Cells { + if key != "value" { + headers = append(headers, key) + } + } + return headers +} + +func dataTableRowCells(headers []string, row DataTableRow) []string { + if len(headers) == 0 { + cells := make([]string, 0, len(row.Cells)) + for _, value := range row.Cells { + cells = append(cells, stringifyCell(value)) + } + return cells + } + cells := make([]string, len(headers)) + for index, header := range headers { + cells[index] = stringifyCell(row.Cells[header]) + } + return cells +} + +func dataTableRowValue(row DataTableRow, index int) any { + if row.Value != nil { + return row.Value + } + return index +} + +func defaultDataTableIndex(rows []DataTableRow, value any, hasDefault bool) int { + if !hasDefault { + return 0 + } + for index, row := range rows { + if fmt.Sprint(dataTableRowValue(row, index)) == fmt.Sprint(value) { + return index + } + } + return 0 +} + +func dataTableNavigation(pressed string) (int, bool) { + switch pressed { + case key.First(key.Down), key.First(key.DownArrow), key.First(key.CtrlN): + return 1, true + case key.First(key.Up), key.First(key.UpArrow), key.First(key.CtrlP): + return -1, true + default: + return 0, false + } +} + +func clampSelection(selected int, rows []DataTableRow) int { + if len(rows) == 0 { + return 0 + } + if selected < 0 { + return 0 + } + if selected >= len(rows) { + return len(rows) - 1 + } + return selected +} diff --git a/provision/go/internal/output/notes.go b/provision/go/internal/output/notes.go new file mode 100644 index 00000000..df75486e --- /dev/null +++ b/provision/go/internal/output/notes.go @@ -0,0 +1,96 @@ +package output + +import ( + stdstrings "strings" + + "github.com/oullin/tui/internal/environment" + "github.com/oullin/tui/internal/strings" + "github.com/oullin/tui/internal/theme" +) + +type NoteType string + +const ( + NoteAlert NoteType = "alert" + NoteError NoteType = "error" + NoteInfo NoteType = "info" + NoteIntro NoteType = "intro" + NoteOutro NoteType = "outro" + NoteWarning NoteType = "warning" +) + +func Note(message string, noteType NoteType) { + environment.Current().Output.Write(stdstrings.Join(renderNoteLines(message, noteType), "\n") + "\n") +} + +func Error(message string) { + Note(message, NoteError) +} + +func Warning(message string) { + Note(message, NoteWarning) +} + +func Alert(message string) { + Note(message, NoteAlert) +} + +func Info(message string) { + Note(message, NoteInfo) +} + +func Intro(message string) { + Note(message, NoteIntro) +} + +func Outro(message string) { + Note(message, NoteOutro) +} + +func renderNoteLines(message string, noteType NoteType) []string { + lines := stdstrings.Split(message, "\n") + if noteType == NoteIntro || noteType == NoteOutro { + lines = paddedIntroLines(lines) + } + + for index, line := range lines { + lines[index] = renderNoteLine(line, noteType) + } + + return lines +} + +func paddedIntroLines(lines []string) []string { + padded := make([]string, len(lines)) + longest := 0 + for index, line := range lines { + padded[index] = " " + line + " " + if width := strings.VisibleWidth(padded[index]); width > longest { + longest = width + } + } + for index, line := range padded { + padding := longest - strings.VisibleWidth(line) + if padding > 0 { + padded[index] = line + stdstrings.Repeat(" ", padding) + } + } + return padded +} + +func renderNoteLine(line string, noteType NoteType) string { + switch noteType { + case NoteIntro, NoteOutro: + return " " + theme.BackgroundCyan(theme.Black(line)) + case NoteWarning: + return theme.Yellow(" " + line) + case NoteError: + return theme.Red(" " + line) + case NoteAlert: + return " " + theme.BackgroundRed(theme.White(" "+line+" ")) + case NoteInfo: + return theme.Green(" " + line) + default: + return " " + line + } +} diff --git a/provision/go/internal/output/notify.go b/provision/go/internal/output/notify.go new file mode 100644 index 00000000..600c4819 --- /dev/null +++ b/provision/go/internal/output/notify.go @@ -0,0 +1,90 @@ +package output + +import "fmt" + +type NotificationCommand struct { + Bin string + Args []string +} + +type NotificationRuntime struct { + CommandExists func(string) bool + Execute func(NotificationCommand) bool +} + +func NotificationCommandFor(platform string, title string, body string, subtitle string, sound string, icon string) *NotificationCommand { + commands := NotificationCommands(platform, title, body, subtitle, sound, icon) + if len(commands) == 0 { + return nil + } + return &commands[0] +} + +func NotificationCommands(platform string, title string, body string, subtitle string, sound string, icon string) []NotificationCommand { + switch platform { + case "darwin": + script := fmt.Sprintf("display notification %q with title %q", body, title) + if subtitle != "" { + script += fmt.Sprintf(" subtitle %q", subtitle) + } + if sound != "" { + script += fmt.Sprintf(" sound name %q", sound) + } + return []NotificationCommand{{Bin: "osascript", Args: []string{"-e", script}}} + case "linux": + args := []string{} + if icon != "" { + args = append(args, "--icon", icon) + } + args = append(args, title) + if body != "" { + args = append(args, body) + } + message := title + if body != "" { + message += ": " + body + } + return []NotificationCommand{ + {Bin: "notify-send", Args: args}, + {Bin: "kdialog", Args: []string{"--passivepopup", message, "5", "--title", title}}, + } + default: + return nil + } +} + +func NotifyForPlatform(platform string, title string, body string, subtitle string, sound string, icon string, runtime NotificationRuntime) bool { + commands := NotificationCommands(platform, title, body, subtitle, sound, icon) + command := availableNotificationCommand(platform, commands, runtime.CommandExists) + if command != nil { + if runtime.Execute == nil { + return false + } + return runtime.Execute(*command) + } + + if body != "" { + Note(title+": "+body, NoteInfo) + } else { + Note(title, NoteInfo) + } + return false +} + +func availableNotificationCommand(platform string, commands []NotificationCommand, exists func(string) bool) *NotificationCommand { + if len(commands) == 0 { + return nil + } + if platform != "linux" { + return &commands[0] + } + if exists == nil { + return nil + } + for index, command := range commands { + if exists(command.Bin) { + return &commands[index] + } + } + return nil +} diff --git a/provision/go/internal/output/output_test.go b/provision/go/internal/output/output_test.go new file mode 100644 index 00000000..53f3aca4 --- /dev/null +++ b/provision/go/internal/output/output_test.go @@ -0,0 +1,178 @@ +package output + +import ( + "reflect" + "strings" + "testing" + + "github.com/oullin/tui/internal/environment" + "github.com/oullin/tui/internal/key" + textutil "github.com/oullin/tui/internal/strings" +) + +func withOutput(callback func(output *environment.MemoryOutput) error) error { + output := environment.NewMemoryOutput() + return environment.With(environment.Patch{Output: output, ErrorOutput: output}, func() error { + return callback(output) + }) +} + +func TestNoteHelpersRenderStyledLines(t *testing.T) { + err := withOutput(func(output *environment.MemoryOutput) error { + Note("Hello\nWorld", "") + Warning("Careful") + Error("Nope") + Alert("Heads up") + Info("Facts") + Intro("東京\nA") + Outro("Done") + + rendered := output.Text() + if !strings.Contains(rendered, "Hello") || !strings.Contains(rendered, "\x1b[33m Careful\x1b[39m") || !strings.Contains(rendered, "\x1b[31m Nope\x1b[39m") { + t.Fatalf("note output missing styled lines:\n%s", rendered) + } + if !strings.Contains(textutil.ParseAnsiText(rendered), " 東京 ") || !strings.Contains(textutil.ParseAnsiText(rendered), " A ") { + t.Fatalf("intro output missing visible-width padding:\n%s", rendered) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestTableGridAndTerminalHelpers(t *testing.T) { + err := withOutput(func(output *environment.MemoryOutput) error { + Table([]string{"Name", "Runtime"}, [][]any{{"Ollin", "OpenTUI"}}) + Grid([]any{"A", "B"}, 80) + Title("Prompt") + Clear() + + rendered := output.Text() + for _, want := range []string{"Name", "Runtime", "Ollin", "OpenTUI", "┌───┬───┐", "│ A │ B │", "\x1b]0;Prompt\x07", "\x1b[H\x1b[J"} { + if !strings.Contains(rendered, want) { + t.Fatalf("output missing %q:\n%s", want, rendered) + } + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestRenderGridBalancedRows(t *testing.T) { + got := RenderGrid([]any{"component-alpha", "api-client", "theme-box", "status-task", "prompt-input", "stream-log"}, 50) + want := strings.Join([]string{ + " ┌─────────────────┬─────────────┐", + " │ component-alpha │ api-client │", + " ├─────────────────┼─────────────┤", + " │ theme-box │ status-task │", + " ├─────────────────┼─────────────┤", + " │ prompt-input │ stream-log │", + " └─────────────────┴─────────────┘", + }, "\n") + if got != want { + t.Fatalf("RenderGrid() =\n%s\nwant\n%s", got, want) + } +} + +func TestNotificationCommandsAndFallback(t *testing.T) { + command := NotificationCommandFor("darwin", "Deploy", "Done", "Preview", "Glass", "") + if command == nil || command.Bin != "osascript" || !strings.Contains(command.Args[1], `display notification "Done"`) || !strings.Contains(command.Args[1], `sound name "Glass"`) { + t.Fatalf("darwin command = %#v", command) + } + + commands := NotificationCommands("linux", "Deploy", "Done", "", "", "/tmp/icon.png") + wantFirst := NotificationCommand{Bin: "notify-send", Args: []string{"--icon", "/tmp/icon.png", "Deploy", "Done"}} + if !reflect.DeepEqual(commands[0], wantFirst) { + t.Fatalf("linux command = %#v, want %#v", commands[0], wantFirst) + } + + executed := []NotificationCommand{} + ok := NotifyForPlatform("linux", "Deploy", "Done", "", "", "", NotificationRuntime{ + CommandExists: func(bin string) bool { return bin == "kdialog" }, + Execute: func(command NotificationCommand) bool { + executed = append(executed, command) + return true + }, + }) + if !ok || len(executed) != 1 || executed[0].Bin != "kdialog" { + t.Fatalf("NotifyForPlatform linux ok=%v executed=%#v", ok, executed) + } + + err := withOutput(func(output *environment.MemoryOutput) error { + if NotifyForPlatform("freebsd", "Deploy", "Done", "", "", "", NotificationRuntime{}) { + t.Fatalf("unsupported notification returned true") + } + if !strings.Contains(output.Text(), "Deploy: Done") { + t.Fatalf("fallback note missing:\n%s", output.Text()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestDataTableSelectsFiltersAndCancels(t *testing.T) { + rows := []DataTableRow{ + {Cells: map[string]any{"Name": "Alpha", "Runtime": "Node"}, Value: "alpha"}, + {Cells: map[string]any{"Name": "Beta", "Runtime": "OpenTUI"}, Value: "beta"}, + } + err := withDataTableEnvironment(environment.NewScriptedInput([]string{key.First(key.Down), key.First(key.Enter)}), func(output *environment.MemoryOutput) error { + result, err := DataTable(DataTableOptions{Message: "Pick project", Headers: []string{"Name", "Runtime"}, Rows: rows}) + if err != nil { + return err + } + if result != "beta" { + t.Fatalf("DataTable() = %#v", result) + } + if !strings.Contains(output.Text(), "Pick project") || !strings.Contains(output.Text(), "›") || !strings.Contains(output.Text(), "Bob, Developer") && !strings.Contains(output.Text(), "Beta, OpenTUI") { + t.Fatalf("datatable output missing expected frame/summary:\n%s", output.Text()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } + + err = withDataTableEnvironment(environment.NewScriptedInput([]string{"/", "B", key.First(key.Enter)}), func(output *environment.MemoryOutput) error { + result, err := DataTable(DataTableOptions{Message: "Pick project", Headers: []string{"Name", "Runtime"}, Rows: rows}) + if err != nil { + return err + } + if result != "beta" { + t.Fatalf("filtered DataTable() = %#v", result) + } + if !strings.Contains(output.Text(), "Pick project B") { + t.Fatalf("datatable output missing search query:\n%s", output.Text()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } + + err = withDataTableEnvironment(environment.NewScriptedInput([]string{key.First(key.Down), key.First(key.CtrlC)}), func(output *environment.MemoryOutput) error { + result, err := DataTable(DataTableOptions{Message: "Pick project", Headers: []string{"Name", "Runtime"}, Rows: rows}) + if err != nil { + return err + } + if result != "beta" || !strings.Contains(output.Text(), "Cancelled.") || !strings.Contains(output.Text(), "/ Search") { + t.Fatalf("cancelled DataTable() = %#v output:\n%s", result, output.Text()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func withDataTableEnvironment(input environment.Input, callback func(output *environment.MemoryOutput) error) error { + output := environment.NewMemoryOutput() + interactive := true + return environment.With(environment.Patch{Input: input, Output: output, ErrorOutput: output, Interactive: &interactive}, func() error { + return callback(output) + }) +} diff --git a/provision/go/internal/output/table.go b/provision/go/internal/output/table.go new file mode 100644 index 00000000..2089ccf3 --- /dev/null +++ b/provision/go/internal/output/table.go @@ -0,0 +1,152 @@ +package output + +import ( + "fmt" + "math" + stdstrings "strings" + + "github.com/oullin/tui/internal/environment" + text "github.com/oullin/tui/internal/strings" + "github.com/oullin/tui/internal/theme" +) + +type TableRow map[string]any + +func Table(headers []string, rows [][]any) { + renderedRows := make([][]string, len(rows)) + for rowIndex, row := range rows { + renderedRows[rowIndex] = make([]string, len(row)) + for cellIndex, cell := range row { + renderedRows[rowIndex][cellIndex] = stringifyCell(cell) + } + } + + environment.Current().Output.Write(theme.RenderTable(headers, renderedRows) + "\n") +} + +func TableObjects(headers []string, rows []TableRow) { + if len(headers) == 0 && len(rows) > 0 { + for key := range rows[0] { + headers = append(headers, key) + } + } + + renderedRows := make([][]any, len(rows)) + for rowIndex, row := range rows { + renderedRows[rowIndex] = make([]any, len(headers)) + for headerIndex, header := range headers { + renderedRows[rowIndex][headerIndex] = row[header] + } + } + Table(headers, renderedRows) +} + +func Grid(items []any, maxWidth int) { + rendered := RenderGrid(items, maxWidth) + if rendered == "" { + return + } + + environment.Current().Output.Write(rendered + "\n") +} + +func RenderGrid(items []any, maxWidth int) string { + if len(items) == 0 { + return "" + } + + values := make([]string, len(items)) + for index, item := range items { + values[index] = fmt.Sprint(item) + } + + width := maxWidth + if width <= 0 { + width = 1 + } + availableWidth := width - 2 + cellWidth := 0 + for _, value := range values { + if valueWidth := text.VisibleWidth(value) + 4; valueWidth > cellWidth { + cellWidth = valueWidth + } + } + maxColumns := int(math.Max(1, math.Floor(float64((availableWidth-1)/(cellWidth+1))))) + columnCount := max(1, balancedColumnCount(len(values), maxColumns)) + rows := chunkItems(values, columnCount) + widths := make([]int, columnCount) + for column := range columnCount { + for _, row := range rows { + if width := text.VisibleWidth(row[column]); width > widths[column] { + widths[column] = width + } + } + } + + renderedRows := []string{horizontal("┌", "┬", "┐", widths)} + for index, row := range rows { + if index > 0 { + renderedRows = append(renderedRows, horizontal("├", "┼", "┤", widths)) + } + cells := make([]string, len(widths)) + for column, width := range widths { + cells[column] = padVisible(row[column], width) + } + renderedRows = append(renderedRows, "│ "+stdstrings.Join(cells, " │ ")+" │") + } + renderedRows = append(renderedRows, horizontal("└", "┴", "┘", widths)) + + for index, line := range renderedRows { + renderedRows[index] = " " + line + } + + return stdstrings.Join(renderedRows, "\n") +} + +func stringifyCell(value any) string { + if value == nil { + return "" + } + return fmt.Sprint(value) +} + +func padVisible(value string, width int) string { + padding := width - text.VisibleWidth(value) + if padding < 0 { + padding = 0 + } + return value + stdstrings.Repeat(" ", padding) +} + +func balancedColumnCount(itemCount int, maxColumns int) int { + if itemCount <= maxColumns { + return itemCount + } + for columns := maxColumns; columns >= 1; columns-- { + remainder := itemCount % columns + if remainder == 0 || remainder >= int(math.Ceil(float64(columns)/2)) { + return columns + } + } + return maxColumns +} + +func chunkItems(items []string, columnCount int) [][]string { + rows := make([][]string, 0) + for index := 0; index < len(items); index += columnCount { + row := append([]string{}, items[index:min(index+columnCount, len(items))]...) + for len(row) < columnCount { + row = append(row, "") + } + rows = append(rows, row) + } + return rows +} + +func horizontal(left string, middle string, right string, widths []int) string { + parts := make([]string, len(widths)) + for index, width := range widths { + parts[index] = stdstrings.Repeat("─", width+2) + } + return left + stdstrings.Join(parts, middle) + right +} diff --git a/provision/go/internal/output/terminal.go b/provision/go/internal/output/terminal.go new file mode 100644 index 00000000..ad42c6bd --- /dev/null +++ b/provision/go/internal/output/terminal.go @@ -0,0 +1,14 @@ +package output + +import ( + "github.com/oullin/tui/internal/environment" + "github.com/oullin/tui/internal/terminal" +) + +func Clear() { + environment.Current().Output.Write(terminal.ClearSequence()) +} + +func Title(value string) { + environment.Current().Output.Write(terminal.TitleSequence(value)) +} diff --git a/provision/go/internal/prompt/lifecycle.go b/provision/go/internal/prompt/lifecycle.go new file mode 100644 index 00000000..73c6c215 --- /dev/null +++ b/provision/go/internal/prompt/lifecycle.go @@ -0,0 +1,71 @@ +package prompt + +import ( + "errors" + + "github.com/oullin/tui/internal/environment" + "github.com/oullin/tui/internal/theme" +) + +type Options[T any] struct { + Message string + Default T + Required Required + Validate Validator[T] +} + +type Reader[T any] func(attempt int) (T, error) +type OnValid[T any] func(T) error + +func UntilValid[T any](options Options[T], read Reader[T], onValid OnValid[T]) (T, error) { + env := environment.Current() + + if !env.Interactive { + value := options.Default + if err := EnsureValid(value, options.Required, options.Validate); err != nil { + return value, err + } + + return value, nil + } + + attempt := 0 + for { + value, err := read(attempt) + if err != nil { + var validation environment.PromptValidationError + if errors.As(err, &validation) { + env.ErrorOutput.Write(theme.RenderError(validation.Message)) + attempt++ + continue + } + + return value, err + } + + if err := EnsureValid(value, options.Required, options.Validate); err != nil { + var validation environment.PromptValidationError + if errors.As(err, &validation) { + env.ErrorOutput.Write(theme.RenderError(validation.Message)) + options.Default = value + attempt++ + continue + } + + return value, err + } + + if onValid != nil { + if err := onValid(value); err != nil { + return value, err + } + } + + return value, nil + } +} + +func Ask(message string, hint string) (string, error) { + env := environment.Current() + return env.Input.ReadLine(theme.RenderQuestion(message, hint)) +} diff --git a/provision/go/internal/prompt/validation.go b/provision/go/internal/prompt/validation.go new file mode 100644 index 00000000..ecdb1cc7 --- /dev/null +++ b/provision/go/internal/prompt/validation.go @@ -0,0 +1,90 @@ +package prompt + +import ( + "errors" + "reflect" + + "github.com/oullin/tui/internal/environment" +) + +type Required struct { + Enabled bool + Message string +} + +type Validator[T any] func(T) (string, error) + +func RequiredMessage(value any, required Required) string { + if !required.Enabled || !isInvalidRequiredValue(value) { + return "" + } + + if required.Message != "" { + return required.Message + } + + return "Required." +} + +func ValidationMessage[T any](value T, validator Validator[T]) (string, error) { + if validator == nil { + return "", nil + } + + message, err := validator(value) + if err != nil { + return "", err + } + + return message, nil +} + +func EnsureValid[T any](value T, required Required, validator Validator[T]) error { + if message := RequiredMessage(value, required); message != "" { + return environment.PromptValidationError{Message: message} + } + + message, err := ValidationMessage(value, validator) + if err != nil { + return err + } + if message != "" { + return environment.PromptValidationError{Message: message} + } + + return nil +} + +func isInvalidRequiredValue(value any) bool { + if value == nil { + return true + } + + switch typed := value.(type) { + case string: + return typed == "" + case bool: + return !typed + } + + reflected := reflect.ValueOf(value) + switch reflected.Kind() { + case reflect.Slice, reflect.Array: + return reflected.Len() == 0 + default: + return false + } +} + +func ValidationError(message string) error { + return environment.PromptValidationError{Message: message} +} + +func IsValidationError(err error) (string, bool) { + var validation environment.PromptValidationError + if ok := errors.As(err, &validation); ok { + return validation.Message, true + } + + return "", false +} diff --git a/provision/go/internal/prompts/basic_test.go b/provision/go/internal/prompts/basic_test.go new file mode 100644 index 00000000..536f5a5a --- /dev/null +++ b/provision/go/internal/prompts/basic_test.go @@ -0,0 +1,260 @@ +package prompts + +import ( + "errors" + "strings" + "testing" + + "github.com/oullin/tui/internal/environment" + "github.com/oullin/tui/internal/key" + "github.com/oullin/tui/internal/prompt" + textutil "github.com/oullin/tui/internal/strings" +) + +type lineOnlyInput struct { + asked string + line string +} + +func (input *lineOnlyInput) ReadKey() (string, bool, error) { + return "", false, environment.ErrKeyInputUnsupported +} + +func (input *lineOnlyInput) ReadLine(message string) (string, error) { + input.asked = message + return input.line, nil +} + +func withTestEnvironment(input environment.Input, callback func(output *environment.MemoryOutput) error) error { + output := environment.NewMemoryOutput() + interactive := true + return environment.With(environment.Patch{Input: input, Output: output, ErrorOutput: output, Interactive: &interactive}, func() error { + return callback(output) + }) +} + +func TestTextPromptReadsAndRendersTypedValues(t *testing.T) { + err := withTestEnvironment(environment.NewScriptedInput([]string{"A", "d", "a", key.First(key.Enter)}), func(output *environment.MemoryOutput) error { + result, err := Text(TextOptions{Message: "Name"}) + if err != nil { + return err + } + if result != "Ada" { + t.Fatalf("Text() = %q", result) + } + if !strings.Contains(output.Text(), "\x1b[36mName\x1b[39m") || !strings.Contains(textutil.ParseAnsiText(output.Text()), "Ada") { + t.Fatalf("text output missing rendered prompt/value:\n%s", output.Text()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestTextRetriesValidationAndRendersSubmittedOnce(t *testing.T) { + err := withTestEnvironment(environment.NewScriptedInput([]string{"J", "e", "s", key.First(key.Enter), "s", key.First(key.Enter)}), func(output *environment.MemoryOutput) error { + result, err := Text(TextOptions{ + Message: "Name", + Validate: func(value string) (string, error) { + if value != "Jess" { + return "Invalid name.", nil + } + return "", nil + }, + }) + if err != nil { + return err + } + if result != "Jess" { + t.Fatalf("Text() = %q", result) + } + if !strings.Contains(output.Text(), "Invalid name.") { + t.Fatalf("output missing validation error:\n%s", output.Text()) + } + if count := strings.Count(output.Text(), "┌ \x1b[2mName\x1b[22m"); count != 1 { + t.Fatalf("submitted frame count = %d", count) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestTextNonInteractiveDefaultTransformAndValidation(t *testing.T) { + output := environment.NewMemoryOutput() + interactive := false + err := environment.With(environment.Patch{Output: output, ErrorOutput: output, Interactive: &interactive}, func() error { + result, err := Text(TextOptions{ + Message: "Name", + Default: " Ada ", + Transform: func(value string) (string, error) { + return strings.TrimSpace(value), nil + }, + Validate: func(value string) (string, error) { + if value != "Ada" { + return "Unexpected value.", nil + } + return "", nil + }, + }) + if err != nil { + return err + } + if result != "Ada" { + t.Fatalf("Text() = %q", result) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestTextareaPromptAcceptsMultilineInput(t *testing.T) { + err := withTestEnvironment(environment.NewScriptedInput([]string{"A", key.First(key.Enter), "B", key.First(key.CtrlD)}), func(output *environment.MemoryOutput) error { + result, err := Textarea(TextOptions{Message: "Description"}) + if err != nil { + return err + } + if result != "A\nB" { + t.Fatalf("Textarea() = %q", result) + } + if !strings.Contains(output.Text(), "Ctrl+D to submit") || !strings.Contains(textutil.ParseAnsiText(output.Text()), "│ A") || !strings.Contains(textutil.ParseAnsiText(output.Text()), "│ B") { + t.Fatalf("textarea output missing expected frame:\n%s", output.Text()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestPasswordPromptMasksValues(t *testing.T) { + err := withTestEnvironment(environment.NewScriptedInput([]string{"s", "e", "c", "r", "e", "t", key.First(key.Enter)}), func(output *environment.MemoryOutput) error { + result, err := Password(TextOptions{Message: "Password"}) + if err != nil { + return err + } + if result != "secret" { + t.Fatalf("Password() = %q", result) + } + if strings.Contains(output.Text(), "secret") || !strings.Contains(output.Text(), "••••••") { + t.Fatalf("password output leaked value or missed mask:\n%s", output.Text()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestNumberPromptParsesRetriesAndSteps(t *testing.T) { + err := withTestEnvironment(environment.NewScriptedInput([]string{"1", "2", "a", key.First(key.Enter), "4", "2", key.First(key.Enter)}), func(output *environment.MemoryOutput) error { + result, err := Number(NumberOptions{Message: "Amount"}) + if err != nil { + return err + } + if result != 42 { + t.Fatalf("Number() = %#v", result) + } + if !strings.Contains(output.Text(), "Must be a number") { + t.Fatalf("number output missing validation error:\n%s", output.Text()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } + + err = withTestEnvironment(environment.NewScriptedInput([]string{key.First(key.Up), key.First(key.Up), key.First(key.Down), key.First(key.Enter)}), func(_ *environment.MemoryOutput) error { + result, err := Number(NumberOptions{Message: "Count", Step: 2, HasStep: true}) + if err != nil { + return err + } + if result != 1 { + t.Fatalf("stepped Number() = %#v", result) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestPausePromptWaitsForEnterAndFallsBackToLineInput(t *testing.T) { + err := withTestEnvironment(environment.NewScriptedInput([]string{"x", key.First(key.Enter)}), func(output *environment.MemoryOutput) error { + result, err := Pause("Continue") + if err != nil { + return err + } + if !result { + t.Fatalf("Pause() = false") + } + if !strings.Contains(output.Text(), "\x1b[36mContinue\x1b[39m") || !strings.HasSuffix(output.Text(), "\n") { + t.Fatalf("pause output missing frame/newline:\n%s", output.Text()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } + + input := &lineOnlyInput{} + err = withTestEnvironment(input, func(_ *environment.MemoryOutput) error { + result, err := Pause("Continue") + if err != nil { + return err + } + if !result || !strings.Contains(input.asked, "Continue") { + t.Fatalf("line fallback result=%v asked=%q", result, input.asked) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestCancellationUsesCustomHandlerAfterRendering(t *testing.T) { + environment.CancelUsing(func(string) (string, error) { + return "Manual", nil + }) + defer environment.CancelUsing(nil) + + err := withTestEnvironment(environment.NewScriptedInput([]string{key.First(key.CtrlC)}), func(output *environment.MemoryOutput) error { + result, err := Text(TextOptions{Message: "Name"}) + if err != nil { + return err + } + if result != "Manual" { + t.Fatalf("Text(cancelled) = %q", result) + } + if !strings.Contains(output.Text(), "Cancelled.") { + t.Fatalf("cancelled output missing message:\n%s", output.Text()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestPromptValidationHelpers(t *testing.T) { + if got := prompt.RequiredMessage("", prompt.Required{Enabled: true}); got != "Required." { + t.Fatalf("RequiredMessage(empty) = %q", got) + } + if got := prompt.RequiredMessage("", prompt.Required{Enabled: true, Message: "Choose something."}); got != "Choose something." { + t.Fatalf("RequiredMessage(custom) = %q", got) + } + if got := prompt.RequiredMessage("value", prompt.Required{Enabled: true}); got != "" { + t.Fatalf("RequiredMessage(value) = %q", got) + } + + _, err := Text(TextOptions{Message: "Name", Required: prompt.Required{Enabled: true}}) + var validation environment.PromptValidationError + if err != nil && !errors.As(err, &validation) { + t.Fatalf("expected validation error, got %v", err) + } +} diff --git a/provision/go/internal/prompts/choices.go b/provision/go/internal/prompts/choices.go new file mode 100644 index 00000000..cb0a7faf --- /dev/null +++ b/provision/go/internal/prompts/choices.go @@ -0,0 +1,689 @@ +package prompts + +import ( + "errors" + "fmt" + "reflect" + "sort" + "strconv" + stdstrings "strings" + + "github.com/oullin/tui/internal/environment" + "github.com/oullin/tui/internal/key" + "github.com/oullin/tui/internal/prompt" + "github.com/oullin/tui/internal/theme" + "github.com/oullin/tui/internal/typedvalue" +) + +type Choice struct { + Label string + Value any + Disabled bool + DisabledReason string + Hint string +} + +type ChoiceOptions struct { + Message string + Choices []Choice + Default any + HasDefault bool + Required prompt.Required + Validate prompt.Validator[any] + Hint string + Scroll int + Transform func(any) (any, error) +} + +type MultiChoiceOptions struct { + Message string + Choices []Choice + Defaults []any + Required prompt.Required + Validate prompt.Validator[[]any] + Hint string + Scroll int + Transform func([]any) ([]any, error) +} + +type ConfirmOptions struct { + Message string + Default bool + HasDefault bool + Yes string + No string + Required prompt.Required + Validate prompt.Validator[bool] + Hint string + Transform func(bool) (bool, error) +} + +func NewStringChoices(values ...string) []Choice { + choices := make([]Choice, len(values)) + for index, value := range values { + choices[index] = Choice{Label: value, Value: value} + } + + return choices +} + +func Select(options ChoiceOptions) (any, error) { + if options.Scroll == 0 { + options.Scroll = 5 + } + validate := preserveInvalidChoiceDefault(&options) + defaultValue := transformedChoiceDefault(options) + + return prompt.UntilValid( + prompt.Options[any]{Message: options.Message, Default: defaultValue, Required: requiredDefault(options.Required, true), Validate: validate}, + func(_ int) (any, error) { + value, label, submitted, err := readSelectedChoice(options) + if err != nil { + return nil, err + } + value, err = transformChoice(options, value) + if err != nil { + return nil, err + } + if submitted { + renderSubmittedChoice(options.Message, label) + } + return value, nil + }, + nil, + ) +} + +func Multiselect(options MultiChoiceOptions) ([]any, error) { + if options.Scroll == 0 { + options.Scroll = 5 + } + + defaultValue := options.Defaults + if options.Transform != nil { + transformed, err := options.Transform(defaultValue) + if err == nil { + defaultValue = transformed + } + } + + return prompt.UntilValid( + prompt.Options[[]any]{Message: options.Message, Default: defaultValue, Required: options.Required, Validate: options.Validate}, + func(_ int) ([]any, error) { + value, labels, submitted, err := readMultipleChoices(options) + if err != nil { + return nil, err + } + if options.Transform != nil { + value, err = options.Transform(value) + if err != nil { + return nil, err + } + } + if submitted { + renderSubmittedChoices(options.Message, labels) + } + return value, nil + }, + nil, + ) +} + +func Confirm(options ConfirmOptions) (bool, error) { + if options.Yes == "" { + options.Yes = "Yes" + } + if options.No == "" { + options.No = "No" + } + defaultValue := true + if options.HasDefault { + defaultValue = options.Default + } + if options.Transform != nil { + transformed, err := options.Transform(defaultValue) + if err == nil { + defaultValue = transformed + } + } + + return prompt.UntilValid( + prompt.Options[bool]{Message: options.Message, Default: defaultValue, Required: options.Required, Validate: options.Validate}, + func(_ int) (bool, error) { + value, submitted, err := readConfirm(options) + if err != nil { + return false, err + } + if options.Transform != nil { + value, err = options.Transform(value) + if err != nil { + return false, err + } + } + if submitted { + renderSubmittedConfirm(options, value) + } + return value, nil + }, + nil, + ) +} + +func readSelectedChoice(options ChoiceOptions) (any, string, bool, error) { + env := environment.Current() + selected := defaultChoiceIndex(options.Choices, options.Default, options.HasDefault) + + renderSelectedChoice(options.Message, options.Choices, selected, options.Scroll) + for { + pressed, ok, err := env.Input.ReadKey() + if err != nil { + if errors.Is(err, environment.ErrKeyInputUnsupported) { + answer, lineErr := env.Input.ReadLine(choiceLineQuestion(options.Message, options.Choices, options.Hint)) + if lineErr != nil { + return nil, "", false, lineErr + } + if stdstrings.TrimSpace(answer) == "" && options.HasDefault { + choice := choiceByValue(options.Choices, options.Default) + if choice != nil { + return choice.Value, choice.Label, false, nil + } + } + choice := findChoice(options.Choices, answer) + if choice == nil || choice.Disabled { + return nil, "", false, prompt.ValidationError("Please select a valid option.") + } + return choice.Value, choice.Label, false, nil + } + return nil, "", false, err + } + if !ok { + if options.HasDefault { + choice := choiceByValue(options.Choices, options.Default) + if choice != nil { + return choice.Value, choice.Label, false, nil + } + } + return nil, "", false, prompt.ValidationError("Please select a valid option.") + } + + if pressed == key.First(key.CtrlC) { + renderCancelledChoice(options.Message, options.Choices, selected, options.Scroll) + choice := options.Choices[selected] + value, cancelErr := environment.CancelPrompt(fmt.Sprint(choice.Value)) + if cancelErr != nil { + return nil, "", false, cancelErr + } + return valueOrOriginal(choice.Value, value), choice.Label, false, nil + } + + if numeric, ok := choiceIndex(pressed); ok && numeric >= 0 && numeric < len(options.Choices) && !options.Choices[numeric].Disabled { + choice := options.Choices[numeric] + return choice.Value, choice.Label, true, nil + } + + if action, ok := navigationDirection(pressed); ok { + selected = moveChoiceHighlight(options.Choices, selected, action) + renderSelectedChoice(options.Message, options.Choices, selected, options.Scroll) + continue + } + + if pressed == key.First(key.Enter) { + choice := options.Choices[selected] + if choice.Disabled { + return nil, "", false, prompt.ValidationError("Please select a valid option.") + } + return choice.Value, choice.Label, true, nil + } + } +} + +func readMultipleChoices(options MultiChoiceOptions) ([]any, []string, bool, error) { + env := environment.Current() + selected := firstEnabledIndex(options.Choices) + marked := markedChoiceIndexes(options.Choices, options.Defaults) + + renderMultipleChoices(options.Message, options.Choices, selected, marked, options.Scroll) + for { + pressed, ok, err := env.Input.ReadKey() + if err != nil { + if errors.Is(err, environment.ErrKeyInputUnsupported) { + answer, lineErr := env.Input.ReadLine(choiceLineQuestion(options.Message, options.Choices, options.Hint)) + if lineErr != nil { + return nil, nil, false, lineErr + } + if stdstrings.TrimSpace(answer) == "" { + return options.Defaults, nil, false, nil + } + return choicesFromCommaSeparated(options.Choices, answer), nil, false, nil + } + return nil, nil, false, err + } + if !ok || pressed == key.First(key.Enter) { + return markedChoiceValues(options.Choices, marked), markedChoiceLabels(options.Choices, marked), true, nil + } + if pressed == key.First(key.CtrlC) { + renderCancelledChoices(options.Message, options.Choices, selected, marked, options.Scroll) + return markedChoiceValues(options.Choices, marked), markedChoiceLabels(options.Choices, marked), false, nil + } + if stdstrings.Contains(pressed, ",") { + return choicesFromCommaSeparated(options.Choices, pressed), nil, false, nil + } + if numeric, ok := choiceIndex(pressed); ok && numeric >= 0 && numeric < len(options.Choices) && !options.Choices[numeric].Disabled { + toggleMarked(marked, numeric) + renderMultipleChoices(options.Message, options.Choices, selected, marked, options.Scroll) + continue + } + if action, ok := navigationDirection(pressed); ok { + selected = moveChoiceHighlight(options.Choices, selected, action) + renderMultipleChoices(options.Message, options.Choices, selected, marked, options.Scroll) + continue + } + if pressed == key.First(key.CtrlA) { + marked = toggleAllEnabledChoices(options.Choices, marked) + renderMultipleChoices(options.Message, options.Choices, selected, marked, options.Scroll) + continue + } + if pressed == key.First(key.Space) { + if !options.Choices[selected].Disabled { + toggleMarked(marked, selected) + } + renderMultipleChoices(options.Message, options.Choices, selected, marked, options.Scroll) + continue + } + } +} + +func readConfirm(options ConfirmOptions) (bool, bool, error) { + env := environment.Current() + confirmed := true + if options.HasDefault { + confirmed = options.Default + } + renderActiveConfirm(options, confirmed) + + for { + pressed, ok, err := env.Input.ReadKey() + if err != nil { + if errors.Is(err, environment.ErrKeyInputUnsupported) { + suffix := " [Y/n]" + if options.HasDefault && !options.Default { + suffix = " [y/N]" + } + answer, lineErr := env.Input.ReadLine(theme.RenderQuestion(options.Message+suffix, options.Hint)) + if lineErr != nil { + return false, false, lineErr + } + normalized := stdstrings.ToLower(stdstrings.TrimSpace(answer)) + if normalized == "" && options.HasDefault { + return options.Default, false, nil + } + return normalized == "y" || normalized == "yes" || normalized == stdstrings.ToLower(options.Yes), false, nil + } + return false, false, err + } + if !ok { + return confirmed, true, nil + } + switch stdstrings.ToLower(pressed) { + case "y": + confirmed = true + renderActiveConfirm(options, confirmed) + case "n": + confirmed = false + renderActiveConfirm(options, confirmed) + default: + if pressed == key.First(key.Enter) { + return confirmed, true, nil + } + if pressed == key.First(key.CtrlC) { + renderCancelledConfirm(options, confirmed) + return confirmed, false, nil + } + if isConfirmToggleKey(pressed) { + confirmed = !confirmed + renderActiveConfirm(options, confirmed) + } + } + } +} + +func renderSelectedChoice(message string, choices []Choice, selected int, scroll int) { + rows := choiceRows(choices, selected, nil, false) + environment.Current().Output.Write(theme.RenderBox(theme.BoxOptions{Body: windowedRows(rows, selected, scroll), BorderStyle: theme.Cyan, Title: theme.Cyan(message)}) + "\n") +} + +func renderMultipleChoices(message string, choices []Choice, selected int, marked map[int]bool, scroll int) { + rows := choiceRows(choices, selected, marked, true) + environment.Current().Output.Write(theme.RenderBox(theme.BoxOptions{Body: windowedRows(rows, selected, scroll), BorderStyle: theme.Cyan, Info: "Use the space bar to select options.", Title: theme.Cyan(message)}) + "\n") +} + +func renderCancelledChoice(message string, choices []Choice, selected int, scroll int) { + rows := choiceRows(choices, selected, nil, false) + env := environment.Current() + env.Output.Write(theme.RenderBox(theme.BoxOptions{Body: windowedRows(rows, selected, scroll), BorderStyle: theme.Red, Title: message}) + "\n") + env.ErrorOutput.Write(theme.Red(" ⚠ Cancelled.") + "\n") +} + +func renderCancelledChoices(message string, choices []Choice, selected int, marked map[int]bool, scroll int) { + rows := choiceRows(choices, selected, marked, true) + env := environment.Current() + env.Output.Write(theme.RenderBox(theme.BoxOptions{Body: windowedRows(rows, selected, scroll), BorderStyle: theme.Red, Title: message}) + "\n") + env.ErrorOutput.Write(theme.Red(" ⚠ Cancelled.") + "\n") +} + +func renderSubmittedChoice(message string, label string) { + environment.Current().Output.Write(theme.RenderBox(theme.BoxOptions{Body: label, Title: theme.Dim(message)}) + "\n") +} + +func renderSubmittedChoices(message string, labels []string) { + environment.Current().Output.Write(theme.RenderBox(theme.BoxOptions{Body: stdstrings.Join(labels, "\n"), Title: theme.Dim(message)}) + "\n") +} + +func renderActiveConfirm(options ConfirmOptions, confirmed bool) { + yes := "○ " + options.Yes + no := "○ " + options.No + if confirmed { + yes = theme.Green("●") + " " + options.Yes + } else { + no = theme.Green("●") + " " + options.No + } + environment.Current().Output.Write(theme.RenderBox(theme.BoxOptions{Body: yes + " / " + no, BorderStyle: theme.Cyan, Info: options.Hint, Title: theme.Cyan(options.Message)}) + "\n") +} + +func renderCancelledConfirm(options ConfirmOptions, confirmed bool) { + yes := "○ " + options.Yes + no := "○ " + options.No + if confirmed { + yes = "● " + options.Yes + } else { + no = "● " + options.No + } + env := environment.Current() + env.Output.Write(theme.RenderBox(theme.BoxOptions{Body: yes + " / " + no, BorderStyle: theme.Red, Title: options.Message}) + "\n") + env.ErrorOutput.Write(theme.Red(" ⚠ Cancelled.") + "\n") +} + +func renderSubmittedConfirm(options ConfirmOptions, value bool) { + label := options.No + if value { + label = options.Yes + } + environment.Current().Output.Write(theme.RenderBox(theme.BoxOptions{Body: label, Title: theme.Dim(options.Message)}) + "\n") +} + +func choiceRows(choices []Choice, selected int, marked map[int]bool, multiple bool) []string { + rows := make([]string, len(choices)) + for index, choice := range choices { + pointer := " " + if index == selected { + pointer = "›" + } + marker := "○" + if multiple { + if marked[index] { + marker = "◼" + } else { + marker = "◻" + } + } else if index == selected { + marker = "●" + } + label := choice.Label + if choice.Hint != "" { + label += " " + choice.Hint + } + if choice.Disabled { + reason := "disabled" + if choice.DisabledReason != "" { + reason = choice.DisabledReason + } + label += " (" + reason + ")" + } + if index == selected { + rows[index] = theme.Cyan(pointer+" "+marker) + " " + label + } else { + rows[index] = " " + marker + " " + label + } + } + return rows +} + +func windowedRows(rows []string, selected int, scroll int) string { + if scroll <= 0 || scroll >= len(rows) { + return stdstrings.Join(rows, "\n") + } + start := selected - (scroll-1)/2 + if start < 0 { + start = 0 + } + if start+scroll > len(rows) { + start = len(rows) - scroll + } + window := append([]string{}, rows[start:start+scroll]...) + return stdstrings.Join(renderScrollbarRows(window, start, scroll, len(rows)), "\n") +} + +func choiceLineQuestion(message string, choices []Choice, hint string) string { + rendered := make([]string, len(choices)) + for index, choice := range choices { + rendered[index] = fmt.Sprintf(" %d. %s", index+1, choice.Label) + } + return theme.RenderQuestion(message+"\n"+stdstrings.Join(rendered, "\n")+"\n", hint) +} + +func firstEnabledIndex(choices []Choice) int { + for index, choice := range choices { + if !choice.Disabled { + return index + } + } + return 0 +} + +func defaultChoiceIndex(choices []Choice, value any, hasDefault bool) int { + if !hasDefault { + return firstEnabledIndex(choices) + } + for index, choice := range choices { + if !choice.Disabled && reflect.DeepEqual(choice.Value, value) { + return index + } + } + return firstEnabledIndex(choices) +} + +func choiceByValue(choices []Choice, value any) *Choice { + for index, choice := range choices { + if !choice.Disabled && reflect.DeepEqual(choice.Value, value) { + return &choices[index] + } + } + return nil +} + +func findChoice(choices []Choice, answer string) *Choice { + normalized := stdstrings.TrimSpace(answer) + if numeric, err := strconv.Atoi(normalized); err == nil { + index := numeric - 1 + if index >= 0 && index < len(choices) { + return &choices[index] + } + } + for index, choice := range choices { + if choice.Label == normalized || fmt.Sprint(choice.Value) == normalized { + return &choices[index] + } + } + return nil +} + +func moveChoiceHighlight(choices []Choice, current int, direction int) int { + if len(choices) == 0 { + return 0 + } + index := current + for range choices { + index = (index + direction + len(choices)) % len(choices) + if !choices[index].Disabled { + return index + } + } + return current +} + +func navigationDirection(pressed string) (int, bool) { + switch pressed { + case key.First(key.Down), key.First(key.DownArrow), key.First(key.Tab), "j", "l", key.First(key.Right), key.First(key.RightArrow), key.First(key.CtrlN), key.First(key.CtrlF): + return 1, true + case key.First(key.Up), key.First(key.UpArrow), key.First(key.ShiftTab), "k", "h", key.First(key.Left), key.First(key.LeftArrow), key.First(key.CtrlP), key.First(key.CtrlB): + return -1, true + default: + return 0, false + } +} + +func choiceIndex(value string) (int, bool) { + numeric, err := strconv.Atoi(value) + if err != nil { + return 0, false + } + return numeric - 1, true +} + +func markedChoiceIndexes(choices []Choice, defaults []any) map[int]bool { + marked := map[int]bool{} + for index, choice := range choices { + for _, value := range defaults { + if reflect.DeepEqual(choice.Value, value) { + marked[index] = true + } + } + } + return marked +} + +func markedChoiceValues(choices []Choice, marked map[int]bool) []any { + indexes := sortedMarkedIndexes(marked) + values := make([]any, 0, len(indexes)) + for _, index := range indexes { + values = append(values, choices[index].Value) + } + return values +} + +func markedChoiceLabels(choices []Choice, marked map[int]bool) []string { + indexes := sortedMarkedIndexes(marked) + labels := make([]string, 0, len(indexes)) + for _, index := range indexes { + labels = append(labels, choices[index].Label) + } + return labels +} + +func sortedMarkedIndexes(marked map[int]bool) []int { + indexes := make([]int, 0, len(marked)) + for index, enabled := range marked { + if enabled { + indexes = append(indexes, index) + } + } + sort.Ints(indexes) + return indexes +} + +func toggleMarked(marked map[int]bool, index int) { + if marked[index] { + delete(marked, index) + } else { + marked[index] = true + } +} + +func toggleAllEnabledChoices(choices []Choice, marked map[int]bool) map[int]bool { + allMarked := true + for index, choice := range choices { + if !choice.Disabled && !marked[index] { + allMarked = false + break + } + } + next := map[int]bool{} + if !allMarked { + for index, choice := range choices { + if !choice.Disabled { + next[index] = true + } + } + } + return next +} + +func choicesFromCommaSeparated(choices []Choice, answer string) []any { + parts := stdstrings.Split(answer, ",") + values := make([]any, 0, len(parts)) + for _, part := range parts { + choice := findChoice(choices, part) + if choice != nil && !choice.Disabled { + values = append(values, choice.Value) + } + } + return values +} + +func isConfirmToggleKey(pressed string) bool { + _, ok := navigationDirection(pressed) + return ok || pressed == key.First(key.PageDown) || pressed == key.First(key.PageUp) +} + +func valueOrOriginal(original any, value string) any { + if value == fmt.Sprint(original) { + return original + } + return value +} + +func transformChoice(options ChoiceOptions, value any) (any, error) { + if options.Transform == nil { + return value, nil + } + return options.Transform(value) +} + +func transformedChoiceDefault(options ChoiceOptions) any { + if !options.HasDefault { + return nil + } + value, err := transformChoice(options, options.Default) + if err != nil { + return options.Default + } + return value +} + +func preserveInvalidChoiceDefault(options *ChoiceOptions) prompt.Validator[any] { + return func(value any) (string, error) { + if options.Validate == nil { + return "", nil + } + message, err := options.Validate(value) + if err != nil { + return "", err + } + if message != "" { + options.Default = value + options.HasDefault = true + } + return message, nil + } +} + +func requiredDefault(required prompt.Required, enabled bool) prompt.Required { + if required.Enabled || required.Message != "" { + return required + } + return prompt.Required{Enabled: enabled} +} + +func currentInputState(value string) typedvalue.State { + return typedvalue.State{Cursor: typedvalue.CharacterLength(value), Value: value} +} diff --git a/provision/go/internal/prompts/choices_test.go b/provision/go/internal/prompts/choices_test.go new file mode 100644 index 00000000..362569a5 --- /dev/null +++ b/provision/go/internal/prompts/choices_test.go @@ -0,0 +1,249 @@ +package prompts + +import ( + "reflect" + "strings" + "testing" + + "github.com/oullin/tui/internal/environment" + "github.com/oullin/tui/internal/key" + "github.com/oullin/tui/internal/prompt" + textutil "github.com/oullin/tui/internal/strings" +) + +func TestConfirmSelectsAndToggles(t *testing.T) { + err := withTestEnvironment(environment.NewScriptedInput([]string{"y", key.First(key.Enter)}), func(_ *environment.MemoryOutput) error { + result, err := Confirm(ConfirmOptions{Message: "Continue?", Default: false, HasDefault: true}) + if err != nil { + return err + } + if !result { + t.Fatalf("Confirm() = false") + } + return nil + }) + if err != nil { + t.Fatal(err) + } + + err = withTestEnvironment(environment.NewScriptedInput([]string{key.First(key.Left), "l", key.First(key.Enter)}), func(output *environment.MemoryOutput) error { + result, err := Confirm(ConfirmOptions{Message: "Continue?"}) + if err != nil { + return err + } + if !result { + t.Fatalf("Confirm(toggle) = false") + } + if !strings.Contains(output.Text(), "\x1b[36mContinue?\x1b[39m") || !strings.Contains(output.Text(), "\x1b[32m●\x1b[39m Yes") { + t.Fatalf("confirm output missing active frame:\n%s", output.Text()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestSelectNavigatesDefaultsAndTransforms(t *testing.T) { + choices := NewStringChoices("first", "second", "third") + err := withTestEnvironment(environment.NewScriptedInput([]string{key.First(key.Down), key.First(key.Enter)}), func(output *environment.MemoryOutput) error { + result, err := Select(ChoiceOptions{Message: "Pick one", Choices: choices}) + if err != nil { + return err + } + if result != "second" { + t.Fatalf("Select() = %#v", result) + } + if !strings.Contains(output.Text(), "Pick one") || !strings.Contains(output.Text(), "second") { + t.Fatalf("select output missing expected frame:\n%s", output.Text()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } + + interactive := false + err = environment.With(environment.Patch{Output: environment.NewMemoryOutput(), ErrorOutput: environment.NewMemoryOutput(), Interactive: &interactive}, func() error { + result, err := Select(ChoiceOptions{ + Message: "Pick one", + Choices: choices, + Default: "second", + HasDefault: true, + Transform: func(value any) (any, error) { + return strings.ToUpper(value.(string)), nil + }, + Validate: func(value any) (string, error) { + if value == "SECOND" { + return "", nil + } + return "Unexpected value.", nil + }, + }) + if err != nil { + return err + } + if result != "SECOND" { + t.Fatalf("noninteractive Select() = %#v", result) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestMultiselectTogglesChoices(t *testing.T) { + err := withTestEnvironment(environment.NewScriptedInput([]string{key.First(key.Space), key.First(key.Down), key.First(key.Space), key.First(key.Enter)}), func(output *environment.MemoryOutput) error { + result, err := Multiselect(MultiChoiceOptions{Message: "Pick many", Choices: NewStringChoices("first", "second")}) + if err != nil { + return err + } + if !reflect.DeepEqual(result, []any{"first", "second"}) { + t.Fatalf("Multiselect() = %#v", result) + } + if !strings.Contains(output.Text(), "Use the space bar") { + t.Fatalf("multiselect output missing hint:\n%s", output.Text()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestSearchFiltersAndValidatesSelections(t *testing.T) { + colors := func(query string) []Choice { + all := []Choice{{Label: "Red", Value: "red"}, {Label: "Green", Value: "green"}, {Label: "Blue", Value: "blue"}} + if query == "" { + return all + } + filtered := make([]Choice, 0) + for _, choice := range all { + if strings.Contains(strings.ToLower(choice.Label), strings.ToLower(query)) { + filtered = append(filtered, choice) + } + } + return filtered + } + + err := withTestEnvironment(environment.NewScriptedInput([]string{"u", "e", key.First(key.Down), key.First(key.Enter)}), func(output *environment.MemoryOutput) error { + result, err := Search(SearchOptions{Message: "Favorite color?", Source: colors}) + if err != nil { + return err + } + if result != "blue" { + t.Fatalf("Search() = %#v", result) + } + if !strings.Contains(textutil.ParseAnsiText(output.Text()), "ue") || !strings.Contains(output.Text(), "Blue") { + t.Fatalf("search output missing query/result:\n%s", output.Text()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } + + err = withTestEnvironment(environment.NewScriptedInput([]string{key.First(key.Down), key.First(key.Enter), key.First(key.Down), key.First(key.Enter)}), func(output *environment.MemoryOutput) error { + result, err := Search(SearchOptions{ + Message: "Favorite color?", + Source: colors, + Validate: func(value any) (string, error) { + if value == "red" { + return "Please choose green.", nil + } + return "", nil + }, + }) + if err != nil { + return err + } + if result != "green" { + t.Fatalf("validated Search() = %#v", result) + } + if !strings.Contains(output.Text(), "Please choose green.") { + t.Fatalf("search output missing validation error:\n%s", output.Text()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestMultiSearchMarksHighlightedChoices(t *testing.T) { + err := withTestEnvironment(environment.NewScriptedInput([]string{key.First(key.Down), key.First(key.Space), key.First(key.Down), key.First(key.Space), key.First(key.Enter)}), func(_ *environment.MemoryOutput) error { + result, err := MultiSearch(MultiSearchOptions{Message: "Pick colors", Choices: NewStringChoices("red", "green", "blue")}) + if err != nil { + return err + } + if len(result) != 2 || result[0] == result[1] { + t.Fatalf("MultiSearch() = %#v", result) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestSuggestAndAutocomplete(t *testing.T) { + err := withTestEnvironment(environment.NewScriptedInput([]string{"b", key.First(key.Tab), key.First(key.Enter)}), func(output *environment.MemoryOutput) error { + result, err := Suggest(SuggestOptions{Message: "Favorite color?", Options: []string{"Red", "Green", "Blue"}}) + if err != nil { + return err + } + if result != "Blue" { + t.Fatalf("Suggest() = %q", result) + } + if !strings.Contains(output.Text(), "Blue") { + t.Fatalf("suggest output missing match:\n%s", output.Text()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } + + err = withTestEnvironment(environment.NewScriptedInput([]string{"b", key.First(key.Tab), key.First(key.Enter)}), func(output *environment.MemoryOutput) error { + result, err := Autocomplete(SuggestOptions{Message: "Favorite color?", Options: []string{"Red", "Green", "Blue"}}) + if err != nil { + return err + } + if result != "Blue" { + t.Fatalf("Autocomplete() = %q", result) + } + if !strings.Contains(textutil.ParseAnsiText(output.Text()), "Blue") { + t.Fatalf("autocomplete output missing completion:\n%s", output.Text()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestChoiceRequiredValidation(t *testing.T) { + interactive := false + err := environment.With(environment.Patch{Output: environment.NewMemoryOutput(), ErrorOutput: environment.NewMemoryOutput(), Interactive: &interactive}, func() error { + _, err := Select(ChoiceOptions{Message: "Pick one", Choices: NewStringChoices("first", "second")}) + var validation environment.PromptValidationError + if err == nil || !errorsAs(err, &validation) || validation.Message != "Required." { + t.Fatalf("expected required validation error, got %v", err) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func errorsAs(err error, target *environment.PromptValidationError) bool { + if validation, ok := err.(environment.PromptValidationError); ok { + *target = validation + return true + } + return false +} + +var _ = prompt.Required{} diff --git a/provision/go/internal/prompts/number.go b/provision/go/internal/prompts/number.go new file mode 100644 index 00000000..3ec2c5a3 --- /dev/null +++ b/provision/go/internal/prompts/number.go @@ -0,0 +1,230 @@ +package prompts + +import ( + "errors" + "fmt" + "math" + "strconv" + stdstrings "strings" + + "github.com/oullin/tui/internal/environment" + "github.com/oullin/tui/internal/key" + "github.com/oullin/tui/internal/prompt" + "github.com/oullin/tui/internal/theme" + "github.com/oullin/tui/internal/typedvalue" +) + +type NumberOptions struct { + Message string + Placeholder string + Default string + HasDefault bool + Required prompt.Required + Validate prompt.Validator[any] + Hint string + Min float64 + HasMin bool + Max float64 + HasMax bool + Step float64 + HasStep bool + Transform func(any) (any, error) +} + +func Number(options NumberOptions) (any, error) { + shouldRenderSubmittedFrame := false + defaultValue, defaultErr := numberDefault(options) + if defaultErr != nil { + return nil, defaultErr + } + + return prompt.UntilValid( + prompt.Options[any]{Message: options.Message, Default: defaultValue, Required: options.Required, Validate: options.Validate}, + func(_ int) (any, error) { + answer, err := readNumberValue(options.Message, options) + if err != nil { + return nil, err + } + + shouldRenderSubmittedFrame = !answer.Cancelled + if answer.Value == "" && options.HasDefault { + return numberDefault(options) + } + + parsed, err := ParseNumberInput(answer.Value, options) + if err != nil { + return nil, err + } + + return transformNumber(options, parsed) + }, + func(value any) error { + if shouldRenderSubmittedFrame { + renderSubmittedNumberValue(options.Message, value) + } + return nil + }, + ) +} + +func numberDefault(options NumberOptions) (any, error) { + if !options.HasDefault { + return "", nil + } + + parsed, err := ParseNumberInput(options.Default, options) + if err != nil { + return nil, err + } + + return transformNumber(options, parsed) +} + +func transformNumber(options NumberOptions, value any) (any, error) { + if options.Transform == nil { + return value, nil + } + + return options.Transform(value) +} + +func readNumberValue(message string, options NumberOptions) (typedReadResult, error) { + env := environment.Current() + state := typedvalue.State{} + if options.HasDefault { + state = typedvalue.InitialState(options.Default) + } + + renderNumberValue(message, state.Value, options) + + for { + pressed, ok, err := env.Input.ReadKey() + if err != nil { + if errors.Is(err, environment.ErrKeyInputUnsupported) { + answer, lineErr := env.Input.ReadLine(theme.RenderQuestion(message, options.Hint)) + if lineErr != nil { + return typedReadResult{}, lineErr + } + if answer == "" && options.HasDefault { + answer = options.Default + } + return typedReadResult{Value: answer}, nil + } + return typedReadResult{}, err + } + if !ok { + return typedReadResult{Value: state.Value}, nil + } + + if pressed == key.First(key.Up) || pressed == key.First(key.UpArrow) { + state.Value = steppedNumberValue(state.Value, 1, options) + state.Cursor = typedvalue.CharacterLength(state.Value) + renderNumberValue(message, state.Value, options) + continue + } + + if pressed == key.First(key.Down) || pressed == key.First(key.DownArrow) { + state.Value = steppedNumberValue(state.Value, -1, options) + state.Cursor = typedvalue.CharacterLength(state.Value) + renderNumberValue(message, state.Value, options) + continue + } + + next := typedvalue.ApplyKey(state, pressed, false) + if next.Cancelled { + renderCancelledNumberValue(message, state.Value, options) + value, cancelErr := environment.CancelPrompt(state.Value) + return typedReadResult{Cancelled: true, Value: value}, cancelErr + } + + state = typedvalue.State{Cursor: next.Cursor, Value: next.Value} + if next.Submitted { + return typedReadResult{Value: state.Value}, nil + } + + renderNumberValue(message, state.Value, options) + } +} + +func ParseNumberInput(input string, options NumberOptions) (any, error) { + normalized := stdstrings.TrimSpace(input) + if normalized == "" { + return "", nil + } + + numeric, number := parseFloat(normalized) + if !numeric { + return nil, prompt.ValidationError("Must be a number") + } + + if options.HasMin && number < options.Min { + return nil, prompt.ValidationError(fmt.Sprintf("Must be at least %s", formatNumber(options.Min))) + } + + if options.HasMax && number > options.Max { + return nil, prompt.ValidationError(fmt.Sprintf("Must be less than %s", formatNumber(options.Max))) + } + + return int(math.Trunc(number)), nil +} + +func steppedNumberValue(value string, direction int, options NumberOptions) string { + step := numberStep(options) + if value == "" { + if direction == 1 { + if options.HasMin { + return formatNumber(options.Min) + } + return "1" + } + if options.HasMax { + return formatNumber(options.Max) + } + return "0" + } + + numeric, number := parseFloat(value) + if !numeric { + return value + } + + next := math.Trunc(number) + float64(step*direction) + if options.HasMin && next < options.Min { + next = options.Min + } + if options.HasMax && next > options.Max { + next = options.Max + } + + return formatNumber(next) +} + +func numberStep(options NumberOptions) int { + if options.HasStep && options.Step > 0 { + truncated := int(math.Trunc(options.Step)) + if truncated > 1 { + return truncated + } + } + + return 1 +} + +func parseFloat(value string) (bool, float64) { + if stdstrings.TrimSpace(value) == "" { + return false, 0 + } + number, err := strconv.ParseFloat(value, 64) + if err != nil || math.IsInf(number, 0) || math.IsNaN(number) { + return false, 0 + } + return true, number +} + +func formatNumber(value float64) string { + if math.Trunc(value) == value { + return strconv.FormatInt(int64(value), 10) + } + + return strconv.FormatFloat(value, 'f', -1, 64) +} diff --git a/provision/go/internal/prompts/render.go b/provision/go/internal/prompts/render.go new file mode 100644 index 00000000..3ace43de --- /dev/null +++ b/provision/go/internal/prompts/render.go @@ -0,0 +1,218 @@ +package prompts + +import ( + "fmt" + stdstrings "strings" + + "github.com/oullin/tui/internal/environment" + "github.com/oullin/tui/internal/strings" + "github.com/oullin/tui/internal/theme" + "github.com/oullin/tui/internal/typedvalue" +) + +const textareaContentWidth = 60 + +func renderTypedValue(message string, state typedvalue.State, options TextOptions) { + env := environment.Current() + if options.AllowNewLine { + env.Output.Write(renderTextareaFrame(message, state, options) + "\n") + if options.Hint != "" { + env.Output.Write(theme.Dim(options.Hint) + "\n") + } + return + } + + display := theme.Dim(options.Placeholder) + if state.Value != "" { + display = typedvalue.VisibleLines(state.Value, state.Cursor, options.Rows) + } + + env.Output.Write(theme.RenderBox(theme.BoxOptions{Body: display, BorderStyle: theme.Cyan, Info: options.Hint, Title: theme.Cyan(message)}) + "\n") +} + +func renderSubmittedTypedValue(message string, value string) { + environment.Current().Output.Write(theme.RenderBox(theme.BoxOptions{Body: value, Title: theme.Dim(message)}) + "\n") +} + +func renderCancelledTypedValue(message string, value string, options TextOptions) { + env := environment.Current() + display := value + if display == "" { + display = options.Placeholder + } + env.Output.Write(theme.RenderBox(theme.BoxOptions{Body: theme.Strikethrough(theme.Dim(display)), BorderStyle: theme.Red, Title: message}) + "\n") + env.ErrorOutput.Write(theme.Red(" ⚠ Cancelled.") + "\n") +} + +func renderTextareaFrame(message string, state typedvalue.State, options TextOptions) string { + return theme.RenderBox(theme.BoxOptions{Body: textareaBody(state, options), BorderStyle: theme.Cyan, Info: "Ctrl+D to submit", Title: theme.Cyan(message)}) +} + +func renderSubmittedTextareaFrame(message string, value string) { + environment.Current().Output.Write(theme.RenderBox(theme.BoxOptions{Body: value, Title: theme.Dim(message)}) + "\n") +} + +func renderCancelledTextareaFrame(message string, value string, options TextOptions) { + env := environment.Current() + body := textareaCancelledBody(value, options) + env.Output.Write(theme.RenderBox(theme.BoxOptions{Body: body, BorderStyle: theme.Red, Title: message}) + "\n") + env.ErrorOutput.Write(theme.Red(" ⚠ Cancelled.") + "\n") +} + +func textareaBody(state typedvalue.State, options TextOptions) string { + rows := options.Rows + if rows <= 0 { + rows = 0 + } + + if state.Value == "" { + return placeholderBody(options, rows) + } + + window := typedvalue.VisibleLineWindowFor(state.Value, state.Cursor, rows, textareaContentWidth) + if rows <= 0 { + return stdstrings.Join(window.Lines, "\n") + } + + padded := append([]string{}, window.Lines...) + for len(padded) < rows { + padded = append(padded, "") + } + if len(padded) > rows { + padded = padded[:rows] + } + + return stdstrings.Join(renderScrollbarRows(padded, window.Start, rows, window.Total), "\n") +} + +func placeholderBody(options TextOptions, rows int) string { + lines := []string{theme.Dim(options.Placeholder)} + for rows > 0 && len(lines) < rows { + lines = append(lines, "") + } + + return stdstrings.Join(lines, "\n") +} + +func textareaCancelledBody(value string, options TextOptions) string { + text := value + if text == "" { + text = options.Placeholder + } + + lines := stdstrings.Split(text, "\n") + for index, line := range lines { + lines[index] = theme.Strikethrough(theme.Dim(line)) + } + + return stdstrings.Join(lines, "\n") +} + +func renderPasswordValue(message string, value string, options TextOptions) { + environment.Current().Output.Write(theme.RenderBox(theme.BoxOptions{Body: passwordDisplay(value, options), BorderStyle: theme.Cyan, Info: options.Hint, Title: theme.Cyan(message)}) + "\n") +} + +func renderSubmittedPasswordValue(message string, value string) { + environment.Current().Output.Write(theme.RenderBox(theme.BoxOptions{Body: maskPassword(value), Title: theme.Dim(message)}) + "\n") +} + +func renderCancelledPasswordValue(message string, value string, options TextOptions) { + env := environment.Current() + display := maskPassword(value) + if display == "" { + display = options.Placeholder + } + env.Output.Write(theme.RenderBox(theme.BoxOptions{Body: theme.Strikethrough(theme.Dim(display)), BorderStyle: theme.Red, Title: message}) + "\n") + env.ErrorOutput.Write(theme.Red(" ⚠ Cancelled.") + "\n") +} + +func maskPassword(value string) string { + return stdstrings.Repeat("•", typedvalue.CharacterLength(value)) +} + +func passwordDisplay(value string, options TextOptions) string { + if value != "" { + return maskPassword(value) + } + + return theme.Dim(options.Placeholder) +} + +func renderNumberValue(message string, value string, options NumberOptions) { + body := renderNumberBody(value, options, func(text string) string { return text }) + environment.Current().Output.Write(theme.RenderBox(theme.BoxOptions{Body: body, BorderStyle: theme.Cyan, Info: options.Hint, Title: theme.Cyan(message)}) + "\n") +} + +func renderSubmittedNumberValue(message string, value any) { + environment.Current().Output.Write(theme.RenderBox(theme.BoxOptions{Body: fmt.Sprint(value), Title: theme.Dim(message)}) + "\n") +} + +func renderCancelledNumberValue(message string, value string, options NumberOptions) { + env := environment.Current() + display := value + if display == "" { + display = options.Placeholder + } + env.Output.Write(theme.RenderBox(theme.BoxOptions{Body: theme.Strikethrough(theme.Dim(display)), BorderStyle: theme.Red, Title: message}) + "\n") + env.ErrorOutput.Write(theme.Red(" ⚠ Cancelled.") + "\n") +} + +func renderNumberBody(value string, options NumberOptions, style func(string) string) string { + display := value + if display == "" { + display = theme.Dim(options.Placeholder) + } + + return display + " " + renderNumberArrows(value, options, style) +} + +func renderNumberArrows(value string, options NumberOptions, style func(string) string) string { + numeric, number := parseFloat(value) + up := style("▲") + down := style("▼") + if numeric && options.HasMax && number >= options.Max { + up = theme.Dim("▲") + } + if numeric && options.HasMin && number <= options.Min { + down = theme.Dim("▼") + } + if value != "" && !numeric { + return theme.Dim("▲") + theme.Dim("▼") + } + + return up + down +} + +func renderPause(message string) string { + return theme.RenderBox(theme.BoxOptions{Body: "", BorderStyle: theme.Cyan, Title: theme.Cyan(message)}) +} + +func renderScrollbarRows(lines []string, start int, rows int, total int) []string { + if rows <= 0 || total <= rows { + return lines + } + + result := make([]string, len(lines)) + thumbStart := start * rows / total + thumbSize := rows * rows / total + if thumbSize < 1 { + thumbSize = 1 + } + if thumbStart+thumbSize > rows { + thumbStart = rows - thumbSize + } + + for index, line := range lines { + bar := theme.Dim("│") + if index >= thumbStart && index < thumbStart+thumbSize { + bar = theme.Cyan("┃") + } + padding := textareaContentWidth - strings.VisibleWidth(line) + if padding < 0 { + padding = 0 + } + result[index] = line + stdstrings.Repeat(" ", padding) + " " + bar + } + + return result +} diff --git a/provision/go/internal/prompts/search_suggest.go b/provision/go/internal/prompts/search_suggest.go new file mode 100644 index 00000000..ea591b65 --- /dev/null +++ b/provision/go/internal/prompts/search_suggest.go @@ -0,0 +1,728 @@ +package prompts + +import ( + "errors" + "reflect" + stdstrings "strings" + + "github.com/oullin/tui/internal/environment" + "github.com/oullin/tui/internal/key" + "github.com/oullin/tui/internal/prompt" + "github.com/oullin/tui/internal/theme" + "github.com/oullin/tui/internal/typedvalue" +) + +type SearchSource func(query string) []Choice + +type SearchOptions struct { + Message string + Choices []Choice + Source SearchSource + Default any + HasDefault bool + Required prompt.Required + Validate prompt.Validator[any] + Placeholder string + Scroll int + Transform func(any) (any, error) +} + +type MultiSearchOptions struct { + Message string + Choices []Choice + Source SearchSource + Defaults []any + Required prompt.Required + Validate prompt.Validator[[]any] + Placeholder string + Scroll int + Transform func([]any) ([]any, error) +} + +type SuggestSource func(query string) []string + +type SuggestOptions struct { + Message string + Options []string + Source SuggestSource + Placeholder string + Default string + Required prompt.Required + Validate prompt.Validator[string] + Hint string + Scroll int + Transform func(string) (string, error) +} + +func Search(options SearchOptions) (any, error) { + if options.Scroll == 0 { + options.Scroll = 5 + } + validate := preserveInvalidSearchDefault(&options) + defaultValue := options.Default + if options.HasDefault && options.Transform != nil { + transformed, err := options.Transform(defaultValue) + if err == nil { + defaultValue = transformed + } + } + + return prompt.UntilValid( + prompt.Options[any]{Message: options.Message, Default: defaultValue, Required: requiredDefault(options.Required, true), Validate: validate}, + func(attempt int) (any, error) { + value, label, submitted, err := readSearchChoice(options, attempt) + if err != nil { + return nil, err + } + if value == nil { + return nil, prompt.ValidationError("Please select a valid option.") + } + if options.Transform != nil { + value, err = options.Transform(value) + if err != nil { + return nil, err + } + } + if submitted { + renderSubmittedChoice(options.Message, label) + } + return value, nil + }, + nil, + ) +} + +func MultiSearch(options MultiSearchOptions) ([]any, error) { + if options.Scroll == 0 { + options.Scroll = 5 + } + return prompt.UntilValid( + prompt.Options[[]any]{Message: options.Message, Default: options.Defaults, Required: options.Required, Validate: options.Validate}, + func(_ int) ([]any, error) { + value, labels, submitted, err := readMultiSearchChoices(options) + if err != nil { + return nil, err + } + if options.Transform != nil { + value, err = options.Transform(value) + if err != nil { + return nil, err + } + } + if submitted { + renderSubmittedChoices(options.Message, labels) + } + return value, nil + }, + nil, + ) +} + +func Suggest(options SuggestOptions) (string, error) { + shouldRenderSubmittedFrame := false + validate := preserveInvalidSuggestDefault(&options) + + return prompt.UntilValid( + prompt.Options[string]{Message: options.Message, Default: transformedSuggestDefault(options), Required: options.Required, Validate: validate}, + func(_ int) (string, error) { + value, rendered, err := readSuggestionValue(options) + if err != nil { + return "", err + } + if value == "" { + value = options.Default + } + shouldRenderSubmittedFrame = rendered + if options.Transform != nil { + return options.Transform(value) + } + return value, nil + }, + func(value string) error { + if shouldRenderSubmittedFrame { + renderSubmittedTypedValue(options.Message, value) + } + return nil + }, + ) +} + +func Autocomplete(options SuggestOptions) (string, error) { + shouldRenderSubmittedFrame := false + validate := preserveInvalidSuggestDefault(&options) + + return prompt.UntilValid( + prompt.Options[string]{Message: options.Message, Default: transformedSuggestDefault(options), Required: options.Required, Validate: validate}, + func(_ int) (string, error) { + value, rendered, err := readAutocompleteValue(options) + if err != nil { + return "", err + } + if value == "" { + value = options.Default + } + shouldRenderSubmittedFrame = rendered + if options.Transform != nil { + return options.Transform(value) + } + return value, nil + }, + func(value string) error { + if shouldRenderSubmittedFrame { + renderSubmittedTypedValue(options.Message, value) + } + return nil + }, + ) +} + +func readSearchChoice(options SearchOptions, attempt int) (any, string, bool, error) { + env := environment.Current() + state := typedvalue.State{} + choices := resolveSearchChoices(options, "") + highlighted := -1 + if attempt > 0 && len(choices) > 0 { + highlighted = 0 + } + renderSearchChoices(options.Message, state.Value, choices, highlighted, nil, false, options.Placeholder, options.Scroll) + + for { + pressed, ok, err := env.Input.ReadKey() + if err != nil { + if errors.Is(err, environment.ErrKeyInputUnsupported) { + answer, lineErr := env.Input.ReadLine(theme.RenderQuestion(options.Message, "")) + if lineErr != nil { + return nil, "", false, lineErr + } + if stdstrings.TrimSpace(answer) == "" && options.HasDefault { + return options.Default, "", false, nil + } + choice := findChoice(resolveSearchChoices(options, answer), answer) + if choice == nil { + return nil, "", false, nil + } + return choice.Value, choice.Label, false, nil + } + return nil, "", false, err + } + if !ok { + return options.Default, "", false, nil + } + if pressed == key.First(key.CtrlC) { + renderCancelledSearch(options.Message, state.Value, options.Placeholder) + if highlighted >= 0 && highlighted < len(choices) && !choices[highlighted].Disabled { + return choices[highlighted].Value, choices[highlighted].Label, false, nil + } + return options.Default, "", false, nil + } + if action, ok := searchNavigationDirection(pressed); ok && (highlighted >= 0 || action > 0 || attempt > 0) { + choices = resolveSearchChoices(options, state.Value) + highlighted = moveSearchHighlight(choices, highlighted, action, options.Scroll) + renderSearchChoices(options.Message, state.Value, choices, highlighted, nil, false, options.Placeholder, options.Scroll) + continue + } + if clearsHighlight(pressed) && highlighted >= 0 { + highlighted = -1 + renderSearchChoices(options.Message, state.Value, choices, highlighted, nil, false, options.Placeholder, options.Scroll) + continue + } + if pressed == key.First(key.Enter) { + choices = resolveSearchChoices(options, state.Value) + if highlighted >= 0 && highlighted < len(choices) { + choice := choices[highlighted] + return choice.Value, choice.Label, !choice.Disabled, nil + } + if state.Value == "" && options.HasDefault { + choice := choiceByValue(choices, options.Default) + if choice != nil { + return choice.Value, choice.Label, true, nil + } + return options.Default, "", false, nil + } + highlighted = -1 + renderSearchChoices(options.Message, state.Value, choices, highlighted, nil, false, options.Placeholder, options.Scroll) + continue + } + next := typedvalue.ApplyKey(state, pressed, false) + if next.Cancelled { + renderCancelledSearch(options.Message, state.Value, options.Placeholder) + return options.Default, "", false, nil + } + state = typedvalue.State{Cursor: next.Cursor, Value: next.Value} + highlighted = -1 + choices = resolveSearchChoices(options, state.Value) + renderSearchChoices(options.Message, state.Value, choices, highlighted, nil, false, options.Placeholder, options.Scroll) + } +} + +func readMultiSearchChoices(options MultiSearchOptions) ([]any, []string, bool, error) { + env := environment.Current() + state := typedvalue.State{} + choices := resolveMultiSearchChoices(options, "") + highlighted := -1 + selected := map[any]string{} + for _, value := range options.Defaults { + if choice := choiceByValue(choices, value); choice != nil { + selected[choice.Value] = choice.Label + } + } + renderSearchChoices(options.Message, state.Value, choices, highlighted, searchMarked(choices, selected), true, options.Placeholder, options.Scroll) + + for { + pressed, ok, err := env.Input.ReadKey() + if err != nil { + if errors.Is(err, environment.ErrKeyInputUnsupported) { + answer, lineErr := env.Input.ReadLine(theme.RenderQuestion(options.Message, "")) + if lineErr != nil { + return nil, nil, false, lineErr + } + if stdstrings.TrimSpace(answer) == "" { + return options.Defaults, nil, false, nil + } + return choicesFromCommaSeparated(resolveMultiSearchChoices(options, answer), answer), nil, false, nil + } + return nil, nil, false, err + } + if !ok || pressed == key.First(key.Enter) { + values, labels := selectedSearchValues(selected) + return values, labels, true, nil + } + if pressed == key.First(key.CtrlC) { + renderCancelledSearch(options.Message, state.Value, options.Placeholder) + values, labels := selectedSearchValues(selected) + return values, labels, false, nil + } + if action, ok := searchNavigationDirection(pressed); ok && (highlighted >= 0 || action > 0) { + choices = resolveMultiSearchChoices(options, state.Value) + highlighted = moveSearchHighlight(choices, highlighted, action, options.Scroll) + renderSearchChoices(options.Message, state.Value, choices, highlighted, searchMarked(choices, selected), true, options.Placeholder, options.Scroll) + continue + } + if pressed == key.First(key.Space) && highlighted >= 0 && highlighted < len(choices) { + toggleSearchSelection(selected, choices[highlighted]) + renderSearchChoices(options.Message, state.Value, choices, highlighted, searchMarked(choices, selected), true, options.Placeholder, options.Scroll) + continue + } + next := typedvalue.ApplyKey(state, pressed, false) + if next.Cancelled { + renderCancelledSearch(options.Message, state.Value, options.Placeholder) + return options.Defaults, nil, false, nil + } + state = typedvalue.State{Cursor: next.Cursor, Value: next.Value} + highlighted = -1 + choices = resolveMultiSearchChoices(options, state.Value) + renderSearchChoices(options.Message, state.Value, choices, highlighted, searchMarked(choices, selected), true, options.Placeholder, options.Scroll) + } +} + +func readSuggestionValue(options SuggestOptions) (string, bool, error) { + env := environment.Current() + state := typedvalue.InitialState(options.Default) + highlighted := -1 + matches := resolveSuggestions(options, state.Value) + renderSuggestions(options.Message, state.Value, matches, highlighted, options.Placeholder, options.Scroll) + + for { + pressed, ok, err := env.Input.ReadKey() + if err != nil { + if errors.Is(err, environment.ErrKeyInputUnsupported) { + answer, lineErr := env.Input.ReadLine(theme.RenderQuestion(options.Message, options.Hint)) + return answer, false, lineErr + } + return "", false, err + } + if !ok { + return state.Value, true, nil + } + if action, ok := suggestionNavigationDirection(pressed); ok && (highlighted >= 0 || action > 0) { + matches = resolveSuggestions(options, state.Value) + highlighted = moveSuggestionHighlight(matches, highlighted, action, options.Scroll) + renderSuggestions(options.Message, state.Value, matches, highlighted, options.Placeholder, options.Scroll) + continue + } + if clearsHighlight(pressed) && highlighted >= 0 { + highlighted = -1 + renderSuggestions(options.Message, state.Value, matches, highlighted, options.Placeholder, options.Scroll) + continue + } + if pressed == key.First(key.Tab) && highlighted < 0 { + matches = resolveSuggestions(options, state.Value) + highlighted = moveSuggestionHighlight(matches, highlighted, 1, options.Scroll) + renderSuggestions(options.Message, state.Value, matches, highlighted, options.Placeholder, options.Scroll) + continue + } + if pressed == key.First(key.Enter) { + if highlighted >= 0 && highlighted < len(matches) { + return matches[highlighted], true, nil + } + return state.Value, true, nil + } + next := typedvalue.ApplyKey(state, pressed, false) + if next.Cancelled { + renderCancelledSuggestion(options.Message, state.Value, options.Placeholder) + value, cancelErr := environment.CancelPrompt(state.Value) + return value, true, cancelErr + } + state = typedvalue.State{Cursor: next.Cursor, Value: next.Value} + highlighted = -1 + matches = resolveSuggestions(options, state.Value) + renderSuggestions(options.Message, state.Value, matches, highlighted, options.Placeholder, options.Scroll) + } +} + +func readAutocompleteValue(options SuggestOptions) (string, bool, error) { + env := environment.Current() + state := typedvalue.InitialState(options.Default) + highlighted := 0 + matches := resolveSuggestions(options, state.Value) + renderAutocomplete(options.Message, state, matches, highlighted, options.Placeholder) + + for { + pressed, ok, err := env.Input.ReadKey() + if err != nil { + if errors.Is(err, environment.ErrKeyInputUnsupported) { + answer, lineErr := env.Input.ReadLine(theme.RenderQuestion(options.Message, options.Hint)) + return answer, false, lineErr + } + return "", false, err + } + if !ok { + return state.Value, true, nil + } + if direction, ok := autocompleteNavigationDirection(pressed); ok { + matches = resolveSuggestions(options, state.Value) + highlighted = moveSuggestionHighlight(matches, highlighted, direction, 0) + renderAutocomplete(options.Message, state, matches, highlighted, options.Placeholder) + continue + } + if pressed == key.First(key.Tab) && canAcceptAutocomplete(state) { + matches = resolveSuggestions(options, state.Value) + if highlighted >= 0 && highlighted < len(matches) && typedvalue.CharacterLength(matches[highlighted]) > typedvalue.CharacterLength(state.Value) { + state = typedvalue.InitialState(matches[highlighted]) + matches = resolveSuggestions(options, state.Value) + } else { + highlighted = 0 + } + renderAutocomplete(options.Message, state, matches, highlighted, options.Placeholder) + continue + } + if (pressed == key.First(key.Right) || pressed == key.First(key.RightArrow)) && canAcceptAutocomplete(state) { + matches = resolveSuggestions(options, state.Value) + if highlighted >= 0 && highlighted < len(matches) { + state = typedvalue.InitialState(matches[highlighted]) + } + renderAutocomplete(options.Message, state, matches, highlighted, options.Placeholder) + continue + } + next := typedvalue.ApplyKey(state, pressed, false) + if next.Submitted { + return state.Value, true, nil + } + if next.Cancelled { + renderCancelledSuggestion(options.Message, state.Value, options.Placeholder) + value, cancelErr := environment.CancelPrompt(state.Value) + return value, true, cancelErr + } + state = typedvalue.State{Cursor: next.Cursor, Value: next.Value} + highlighted = 0 + matches = resolveSuggestions(options, state.Value) + renderAutocomplete(options.Message, state, matches, highlighted, options.Placeholder) + } +} + +func renderSearchChoices(message string, query string, choices []Choice, highlighted int, marked map[int]bool, multi bool, placeholder string, scroll int) { + bodyLines := []string{queryOrPlaceholder(query, placeholder)} + if len(choices) == 0 { + bodyLines = append(bodyLines, theme.Dim(" No results.")) + } else { + rows := make([]string, len(choices)) + for index, choice := range choices { + pointer := " " + if index == highlighted { + pointer = "›" + } + prefix := pointer + " " + if multi { + if marked[index] { + prefix += "◼ " + } else { + prefix += "◻ " + } + } + label := choice.Label + if choice.Disabled { + label = theme.Dim(label) + } + if index == highlighted { + rows[index] = theme.Cyan(prefix) + label + } else { + rows[index] = " " + prefix + label + } + } + bodyLines = append(bodyLines, windowedRows(rows, max(highlighted, 0), scroll)) + } + environment.Current().Output.Write(theme.RenderBox(theme.BoxOptions{Body: stdstrings.Join(bodyLines, "\n"), BorderStyle: theme.Cyan, Title: theme.Cyan(message)}) + "\n") +} + +func renderCancelledSearch(message string, query string, placeholder string) { + env := environment.Current() + env.Output.Write(theme.RenderBox(theme.BoxOptions{Body: theme.Strikethrough(theme.Dim(queryOrPlaceholder(query, placeholder))), BorderStyle: theme.Red, Title: theme.Dim(message)}) + "\n") + env.ErrorOutput.Write(theme.Red(" ⚠ Cancelled.") + "\n") +} + +func renderSuggestions(message string, query string, matches []string, highlighted int, placeholder string, scroll int) { + bodyLines := []string{queryOrPlaceholder(query, placeholder)} + rows := make([]string, len(matches)) + for index, match := range matches { + pointer := " " + if index == highlighted { + pointer = "›" + rows[index] = theme.Cyan(pointer) + " " + match + } else { + rows[index] = " " + theme.Dim(match) + } + } + if len(rows) > 0 { + bodyLines = append(bodyLines, windowedRows(rows, max(highlighted, 0), scroll)) + } + environment.Current().Output.Write(theme.RenderBox(theme.BoxOptions{Body: stdstrings.Join(bodyLines, "\n"), BorderStyle: theme.Cyan, Title: theme.Cyan(message)}) + "\n") +} + +func renderAutocomplete(message string, state typedvalue.State, matches []string, highlighted int, placeholder string) { + body := queryOrPlaceholder(state.Value, placeholder) + if canAcceptAutocomplete(state) && highlighted >= 0 && highlighted < len(matches) && stdstrings.HasPrefix(stdstrings.ToLower(matches[highlighted]), stdstrings.ToLower(state.Value)) { + match := matches[highlighted] + if len(match) > len(state.Value) { + body = state.Value + theme.Inverse(match[len(state.Value):len(state.Value)+1]) + theme.Dim(match[len(state.Value)+1:]) + } else { + body = match + } + } + environment.Current().Output.Write(theme.RenderBox(theme.BoxOptions{Body: body, BorderStyle: theme.Cyan, Title: theme.Cyan(message)}) + "\n") +} + +func renderCancelledSuggestion(message string, value string, placeholder string) { + env := environment.Current() + env.Output.Write(theme.RenderBox(theme.BoxOptions{Body: theme.Strikethrough(theme.Dim(queryOrPlaceholder(value, placeholder))), BorderStyle: theme.Red, Title: message}) + "\n") + env.ErrorOutput.Write(theme.Red(" ⚠ Cancelled.") + "\n") +} + +func resolveSearchChoices(options SearchOptions, query string) []Choice { + if options.Source != nil { + return options.Source(query) + } + return options.Choices +} + +func resolveMultiSearchChoices(options MultiSearchOptions, query string) []Choice { + if options.Source != nil { + return options.Source(query) + } + return options.Choices +} + +func resolveSuggestions(options SuggestOptions, query string) []string { + values := options.Options + if options.Source != nil { + values = options.Source(query) + } + if query == "" { + return values + } + filtered := make([]string, 0, len(values)) + lower := stdstrings.ToLower(query) + for _, value := range values { + if stdstrings.Contains(stdstrings.ToLower(value), lower) { + filtered = append(filtered, value) + } + } + return filtered +} + +func queryOrPlaceholder(query string, placeholder string) string { + if query != "" { + return query + } + return theme.Dim(placeholder) +} + +func searchNavigationDirection(pressed string) (int, bool) { + switch pressed { + case key.First(key.Down), key.First(key.DownArrow), key.First(key.CtrlN): + return 1, true + case key.First(key.Up), key.First(key.UpArrow), key.First(key.CtrlP): + return -1, true + case key.First(key.PageDown), key.First(key.End): + return 5, true + case key.First(key.PageUp), key.Home[0]: + return -5, true + default: + return 0, false + } +} + +func suggestionNavigationDirection(pressed string) (int, bool) { + if direction, ok := searchNavigationDirection(pressed); ok { + return direction, true + } + switch pressed { + case key.First(key.Tab): + return 1, true + case key.First(key.ShiftTab): + return -1, true + default: + return 0, false + } +} + +func autocompleteNavigationDirection(pressed string) (int, bool) { + switch pressed { + case key.First(key.Down), key.First(key.DownArrow): + return 1, true + case key.First(key.Up), key.First(key.UpArrow): + return -1, true + default: + return 0, false + } +} + +func moveSearchHighlight(choices []Choice, highlighted int, direction int, scroll int) int { + if len(choices) == 0 { + return -1 + } + index := highlighted + if index < 0 { + if direction < 0 { + index = len(choices) + } else { + index = -1 + } + } + step := 1 + if direction < 0 { + step = -1 + } + attempts := abs(direction) + if attempts == 0 { + attempts = 1 + } + for range attempts { + index = (index + step + len(choices)) % len(choices) + for choices[index].Disabled { + index = (index + step + len(choices)) % len(choices) + } + } + return index +} + +func moveSuggestionHighlight(matches []string, highlighted int, direction int, _ int) int { + if len(matches) == 0 { + return -1 + } + if highlighted < 0 { + if direction < 0 { + highlighted = len(matches) + } else { + highlighted = -1 + } + } + next := (highlighted + direction) % len(matches) + if next < 0 { + next += len(matches) + } + return next +} + +func clearsHighlight(pressed string) bool { + return pressed == key.First(key.Left) || pressed == key.First(key.LeftArrow) || pressed == key.First(key.Right) || pressed == key.First(key.RightArrow) || pressed == key.First(key.CtrlB) || pressed == key.First(key.CtrlF) +} + +func canAcceptAutocomplete(state typedvalue.State) bool { + return state.Cursor >= typedvalue.CharacterLength(state.Value) +} + +func preserveInvalidSearchDefault(options *SearchOptions) prompt.Validator[any] { + return func(value any) (string, error) { + if options.Validate == nil { + return "", nil + } + message, err := options.Validate(value) + if err != nil { + return "", err + } + if message != "" { + options.Default = value + options.HasDefault = true + } + return message, nil + } +} + +func preserveInvalidSuggestDefault(options *SuggestOptions) prompt.Validator[string] { + return func(value string) (string, error) { + if options.Validate == nil { + return "", nil + } + message, err := options.Validate(value) + if err != nil { + return "", err + } + if message != "" { + options.Default = value + } + return message, nil + } +} + +func transformedSuggestDefault(options SuggestOptions) string { + if options.Transform == nil { + return options.Default + } + value, err := options.Transform(options.Default) + if err != nil { + return options.Default + } + return value +} + +func searchMarked(choices []Choice, selected map[any]string) map[int]bool { + marked := map[int]bool{} + for index, choice := range choices { + if _, ok := selected[choice.Value]; ok { + marked[index] = true + } + } + return marked +} + +func toggleSearchSelection(selected map[any]string, choice Choice) { + if choice.Disabled { + return + } + if _, ok := selected[choice.Value]; ok { + delete(selected, choice.Value) + return + } + selected[choice.Value] = choice.Label +} + +func selectedSearchValues(selected map[any]string) ([]any, []string) { + values := make([]any, 0, len(selected)) + labels := make([]string, 0, len(selected)) + for value, label := range selected { + values = append(values, value) + labels = append(labels, label) + } + return values, labels +} + +func abs(value int) int { + if value < 0 { + return -value + } + return value +} + +func choiceEqual(left any, right any) bool { + return reflect.DeepEqual(left, right) +} diff --git a/provision/go/internal/prompts/text.go b/provision/go/internal/prompts/text.go new file mode 100644 index 00000000..95cc43ee --- /dev/null +++ b/provision/go/internal/prompts/text.go @@ -0,0 +1,252 @@ +package prompts + +import ( + "errors" + + "github.com/oullin/tui/internal/environment" + "github.com/oullin/tui/internal/key" + "github.com/oullin/tui/internal/prompt" + "github.com/oullin/tui/internal/theme" + "github.com/oullin/tui/internal/typedvalue" +) + +type TextOptions struct { + Message string + Placeholder string + Default string + Required prompt.Required + Validate prompt.Validator[string] + Hint string + Transform func(string) (string, error) + AllowNewLine bool + Rows int +} + +type typedReadResult struct { + Cancelled bool + Value string +} + +func Text(options TextOptions) (string, error) { + options.AllowNewLine = false + shouldRenderSubmittedFrame := false + validate := preserveInvalidTextDefault(&options) + + return prompt.UntilValid( + prompt.Options[string]{Message: options.Message, Default: transformedDefault(options), Required: options.Required, Validate: validate}, + func(_ int) (string, error) { + answer, err := readTypedValue(options.Message, options) + if err != nil { + return "", err + } + + value := answer.Value + if value == "" { + value = options.Default + } + shouldRenderSubmittedFrame = !answer.Cancelled + return transformText(options, value) + }, + func(value string) error { + if shouldRenderSubmittedFrame { + renderSubmittedTypedValue(options.Message, value) + } + return nil + }, + ) +} + +func Textarea(options TextOptions) (string, error) { + options.AllowNewLine = true + if options.Rows == 0 { + options.Rows = 5 + } + shouldRenderSubmittedFrame := false + validate := preserveInvalidTextDefault(&options) + + return prompt.UntilValid( + prompt.Options[string]{Message: options.Message, Default: transformedDefault(options), Required: options.Required, Validate: validate}, + func(_ int) (string, error) { + answer, err := readTypedValue(options.Message, options) + if err != nil { + return "", err + } + + value := answer.Value + if value == "" { + value = options.Default + } + shouldRenderSubmittedFrame = !answer.Cancelled + return transformText(options, value) + }, + func(value string) error { + if shouldRenderSubmittedFrame { + renderSubmittedTextareaFrame(options.Message, value) + } + return nil + }, + ) +} + +func Password(options TextOptions) (string, error) { + shouldRenderSubmittedFrame := false + validate := preserveInvalidTextDefault(&options) + + return prompt.UntilValid( + prompt.Options[string]{Message: options.Message, Default: transformedDefault(options), Required: options.Required, Validate: validate}, + func(_ int) (string, error) { + answer, err := readPasswordValue(options.Message, options) + if err != nil { + return "", err + } + shouldRenderSubmittedFrame = !answer.Cancelled + return transformText(options, answer.Value) + }, + func(value string) error { + if shouldRenderSubmittedFrame { + renderSubmittedPasswordValue(options.Message, value) + } + return nil + }, + ) +} + +func readTypedValue(message string, options TextOptions) (typedReadResult, error) { + env := environment.Current() + state := typedvalue.InitialState(options.Default) + + renderTypedValue(message, state, options) + + for { + pressed, ok, err := env.Input.ReadKey() + if err != nil { + if errors.Is(err, environment.ErrKeyInputUnsupported) { + answer, lineErr := env.Input.ReadLine(theme.RenderQuestion(message, options.Hint)) + if lineErr != nil { + return typedReadResult{}, lineErr + } + if answer == "" { + answer = options.Default + } + return typedReadResult{Value: answer}, nil + } + return typedReadResult{}, err + } + if !ok { + return typedReadResult{Value: state.Value}, nil + } + + next := typedvalue.ApplyKey(state, pressed, options.AllowNewLine, textareaContentWidth) + if next.Cancelled { + if options.AllowNewLine { + renderCancelledTextareaFrame(message, state.Value, options) + } else { + renderCancelledTypedValue(message, state.Value, options) + } + value, cancelErr := environment.CancelPrompt(state.Value) + return typedReadResult{Cancelled: true, Value: value}, cancelErr + } + + state = typedvalue.State{Cursor: next.Cursor, Value: next.Value} + if next.Submitted { + return typedReadResult{Value: state.Value}, nil + } + + renderTypedValue(message, state, options) + } +} + +func readPasswordValue(message string, options TextOptions) (typedReadResult, error) { + env := environment.Current() + state := typedvalue.InitialState(options.Default) + + renderPasswordValue(message, state.Value, options) + + for { + pressed, ok, err := env.Input.ReadKey() + if err != nil { + if errors.Is(err, environment.ErrKeyInputUnsupported) { + answer, lineErr := env.Input.ReadLine(theme.RenderQuestion(message, options.Hint)) + if lineErr != nil { + return typedReadResult{}, lineErr + } + return typedReadResult{Value: answer}, nil + } + return typedReadResult{}, err + } + if !ok { + return typedReadResult{Value: state.Value}, nil + } + + next := typedvalue.ApplyKey(state, pressed, false) + if next.Cancelled { + renderCancelledPasswordValue(message, state.Value, options) + value, cancelErr := environment.CancelPrompt(state.Value) + return typedReadResult{Cancelled: true, Value: value}, cancelErr + } + + state = typedvalue.State{Cursor: next.Cursor, Value: next.Value} + if next.Submitted { + return typedReadResult{Value: state.Value}, nil + } + + renderPasswordValue(message, state.Value, options) + } +} + +func Pause(message string) (bool, error) { + env := environment.Current() + if !env.Interactive { + return false, nil + } + + env.Output.Write(renderPause(message)) + for { + pressed, ok, err := env.Input.ReadKey() + if err != nil { + if errors.Is(err, environment.ErrKeyInputUnsupported) { + _, lineErr := prompt.Ask(message, "") + return lineErr == nil, lineErr + } + return false, err + } + if !ok || pressed == key.First(key.Enter) { + env.Output.Write("\n") + return true, nil + } + } +} + +func transformedDefault(options TextOptions) string { + value, err := transformText(options, options.Default) + if err != nil { + return options.Default + } + return value +} + +func transformText(options TextOptions, value string) (string, error) { + if options.Transform == nil { + return value, nil + } + + return options.Transform(value) +} + +func preserveInvalidTextDefault(options *TextOptions) prompt.Validator[string] { + return func(value string) (string, error) { + if options.Validate == nil { + return "", nil + } + + message, err := options.Validate(value) + if err != nil { + return "", err + } + if message != "" { + options.Default = value + } + + return message, nil + } +} diff --git a/provision/go/internal/status/frame.go b/provision/go/internal/status/frame.go new file mode 100644 index 00000000..91c96708 --- /dev/null +++ b/provision/go/internal/status/frame.go @@ -0,0 +1,21 @@ +package status + +import ( + stdstrings "strings" + + "github.com/oullin/tui/internal/environment" + "github.com/oullin/tui/internal/terminal" +) + +func RenderedFrameLineCount(frame string) int { + trimmed := stdstrings.TrimSuffix(frame, "\n") + if trimmed == "" { + return 0 + } + return len(stdstrings.Split(trimmed, "\n")) +} + +func EraseRenderedFrame(frame string) { + count := RenderedFrameLineCount(frame) + environment.Current().Output.Write(terminal.ErasePreviousLinesSequence(count)) +} diff --git a/provision/go/internal/status/progress.go b/provision/go/internal/status/progress.go new file mode 100644 index 00000000..8f1a6991 --- /dev/null +++ b/provision/go/internal/status/progress.go @@ -0,0 +1,154 @@ +package status + +import ( + "fmt" + "math" + + "github.com/oullin/tui/internal/environment" + "github.com/oullin/tui/internal/terminal" + "github.com/oullin/tui/internal/theme" +) + +type Progress struct { + current int + label string + hint string + renderedFrame string + cursorHidden bool + Total int +} + +func NewProgress(total int, label string, hint string) *Progress { + if total < 1 { + total = 1 + } + return &Progress{Total: total, label: label, hint: hint} +} + +func (progress *Progress) Start() { + progress.render("active") +} + +func (progress *Progress) Advance(step int) { + progress.current += step + if progress.current < 0 { + progress.current = 0 + } + if progress.current > progress.Total { + progress.current = progress.Total + } + progress.render("active") +} + +func (progress *Progress) Finish() { + progress.render("submit") + progress.restore() +} + +func (progress *Progress) Fail() { + progress.render("error") + progress.restore() +} + +func (progress *Progress) Label(value string) *Progress { + progress.label = value + return progress +} + +func (progress *Progress) Hint(value string) *Progress { + progress.hint = value + return progress +} + +func (progress *Progress) Current() int { + return progress.current +} + +func (progress *Progress) Percentage() float64 { + return float64(progress.current) / float64(progress.Total) +} + +func (progress *Progress) Value() bool { + return true +} + +func (progress *Progress) render(state string) { + output := environment.Current().Output + if !progress.cursorHidden { + output.Write(terminal.HideCursorSequence()) + progress.cursorHidden = true + } + if progress.renderedFrame != "" { + EraseRenderedFrame(progress.renderedFrame) + } + frame := RenderProgressFrame(progress.current, progress.Total, progress.label, progress.hint, state, 60) + output.Write(frame) + progress.renderedFrame = frame +} + +func (progress *Progress) restore() { + if progress.renderedFrame != "" { + EraseRenderedFrame(progress.renderedFrame) + progress.renderedFrame = "" + } + if progress.cursorHidden { + environment.Current().Output.Write(terminal.ShowCursorSequence()) + progress.cursorHidden = false + } +} + +func RunProgress[T any, R any](label string, steps []T, callback func(T, *Progress) (R, error)) ([]R, error) { + bar := NewProgress(len(steps), label, "") + results := make([]R, 0, len(steps)) + bar.Start() + for _, step := range steps { + result, err := callback(step, bar) + if err != nil { + bar.Fail() + return results, err + } + results = append(results, result) + bar.Advance(1) + } + bar.Finish() + return results, nil +} + +func RenderProgressFrame(current int, total int, label string, hint string, state string, width int) string { + if total < 1 { + total = 1 + } + filled := int(math.Ceil(float64(width) * float64(current) / float64(total))) + body := "" + for range filled { + body += "█" + } + lines := []string{theme.RenderBox(theme.BoxOptions{Body: body, Info: FormatProgressFraction(current, total), Title: label, Width: width})} + if state == "active" { + if hint != "" { + lines = append(lines, " "+hint) + } else { + lines = append(lines, "") + } + } + return joinLines(lines) + "\n" +} + +func FormatProgressFraction(current int, total int) string { + return fmt.Sprintf("%s / %s", formatProgressNumber(current), formatProgressNumber(total)) +} + +func formatProgressNumber(value int) string { + text := fmt.Sprintf("%d", value) + if len(text) <= 3 { + return text + } + out := "" + for index, char := range text { + if index > 0 && (len(text)-index)%3 == 0 { + out += "," + } + out += string(char) + } + return out +} diff --git a/provision/go/internal/status/spinner.go b/provision/go/internal/status/spinner.go new file mode 100644 index 00000000..b59de372 --- /dev/null +++ b/provision/go/internal/status/spinner.go @@ -0,0 +1,52 @@ +package status + +import ( + "github.com/oullin/tui/internal/environment" + "github.com/oullin/tui/internal/terminal" +) + +var SpinnerFrames = []string{"⠂", "⠒", "⠐", "⠰", "⠠", "⠤", "⠄", "⠆"} + +const StaticSpinnerFrame = "⠶" + +func SpinnerFrame(count int) string { + index := count % len(SpinnerFrames) + if index < 0 { + index += len(SpinnerFrames) + } + return SpinnerFrames[index] +} + +func RenderSpinnerFrame(message string, count ...int) string { + frame := StaticSpinnerFrame + if len(count) > 0 { + frame = SpinnerFrame(count[0]) + } + return " " + frame + " " + message + "\n" +} + +func Spin[T any](message string, callback func() (T, error)) (T, error) { + var zero T + if callback == nil { + return zero, errMissingCallback("spinner") + } + + output := environment.Current().Output + frame := RenderSpinnerFrame(message) + output.Write(terminal.HideCursorSequence()) + output.Write(frame) + defer output.Write(terminal.ShowCursorSequence()) + defer EraseRenderedFrame(frame) + + return callback() +} + +type missingCallbackError string + +func (err missingCallbackError) Error() string { + return string(err) +} + +func errMissingCallback(kind string) error { + return missingCallbackError("a " + kind + " callback is required") +} diff --git a/provision/go/internal/status/status_test.go b/provision/go/internal/status/status_test.go new file mode 100644 index 00000000..40fc718b --- /dev/null +++ b/provision/go/internal/status/status_test.go @@ -0,0 +1,194 @@ +package status + +import ( + "errors" + "strings" + "testing" + + "github.com/oullin/tui/internal/environment" + textutil "github.com/oullin/tui/internal/strings" +) + +func withStatusOutput(callback func(output *environment.MemoryOutput) error) error { + output := environment.NewMemoryOutput() + return environment.With(environment.Patch{Output: output, ErrorOutput: output}, func() error { + return callback(output) + }) +} + +func TestSpinnerFramesAndCleanup(t *testing.T) { + if got := SpinnerFrame(3); got != "⠰" { + t.Fatalf("SpinnerFrame() = %q", got) + } + if got := RenderSpinnerFrame("Working", 3); got != " ⠰ Working\n" { + t.Fatalf("RenderSpinnerFrame() = %q", got) + } + + err := withStatusOutput(func(output *environment.MemoryOutput) error { + result, err := Spin("Working", func() (string, error) { return "done", nil }) + if err != nil { + return err + } + if result != "done" { + t.Fatalf("Spin() = %q", result) + } + rendered := output.Text() + for _, want := range []string{"\x1b[?25l", " ⠶ Working", "\x1b[1A\x1b[2K", "\x1b[?25h"} { + if !strings.Contains(rendered, want) { + t.Fatalf("spinner output missing %q:\n%s", want, rendered) + } + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestProgressManualAndRunner(t *testing.T) { + err := withStatusOutput(func(output *environment.MemoryOutput) error { + bar := NewProgress(2, "Adding States", "") + bar.Start() + bar.Label("ALABAMA").Hint("alabama").Advance(1) + if bar.Current() != 1 || !bar.Value() { + t.Fatalf("progress current/value = %d/%v", bar.Current(), bar.Value()) + } + bar.Finish() + if !strings.Contains(output.Text(), "ALABAMA") || !strings.Contains(output.Text(), "1 / 2") || !strings.Contains(output.Text(), "\x1b[?25h") { + t.Fatalf("progress output missing expected frame:\n%s", output.Text()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } + + err = withStatusOutput(func(output *environment.MemoryOutput) error { + result, err := RunProgress("Uppercasing States", []string{"Alabama", "Alaska"}, func(step string, bar *Progress) (string, error) { + return strings.ToUpper(step), nil + }) + if err != nil { + return err + } + if strings.Join(result, ",") != "ALABAMA,ALASKA" { + t.Fatalf("RunProgress() = %#v", result) + } + if !strings.Contains(output.Text(), "2 / 2") { + t.Fatalf("progress runner output missing completion:\n%s", output.Text()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestStreamAppendsClosesAndRenders(t *testing.T) { + err := withStatusOutput(func(output *environment.MemoryOutput) error { + stream := NewStream() + if err := stream.Append("hello"); err != nil { + return err + } + if err := stream.Write(" world"); err != nil { + return err + } + stream.Close() + if stream.Value() != "hello world" || !stream.Closed() { + t.Fatalf("stream value/closed = %q/%v", stream.Value(), stream.Closed()) + } + if err := stream.Append(" later"); err == nil { + t.Fatalf("expected append after close to fail") + } + if !strings.Contains(output.Text(), "\x1b[?25l") || !strings.Contains(output.Text(), "\x1b[?25h") || !strings.Contains(textutil.ParseAnsiText(output.Text()), " hello world") { + t.Fatalf("stream output missing expected frame:\n%s", output.Text()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestTaskLoggerAndFrames(t *testing.T) { + err := withStatusOutput(func(output *environment.MemoryOutput) error { + result, err := Task("Running...", func(logger *Logger) (string, error) { + logger.Log("line one") + logger.Log("line two") + logger.Log("line three") + return "done", nil + }, 2, false, "") + if err != nil { + return err + } + if result != "done" { + t.Fatalf("Task() = %q", result) + } + rendered := output.Text() + if strings.Contains(rendered, "line one") || !strings.Contains(rendered, "line two") || !strings.Contains(rendered, "line three") || !strings.Contains(rendered, "\x1b[?25h") { + t.Fatalf("task output missing bounded logs:\n%s", rendered) + } + return nil + }) + if err != nil { + t.Fatal(err) + } + + err = withStatusOutput(func(output *environment.MemoryOutput) error { + _, err := Task("Running...", func(logger *Logger) (string, error) { + logger.Success("created") + logger.Warning("check this") + logger.Error("failed optional step") + return "done", nil + }, 10, true, "") + if err != nil { + return err + } + rendered := output.Text() + for _, want := range []string{" • Running...", "✔ created", "⚠ check this", "✘ failed optional step"} { + if !strings.Contains(rendered, want) { + t.Fatalf("task summary missing %q:\n%s", want, rendered) + } + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestTaskFailureRestoresCursor(t *testing.T) { + failure := errors.New("failed") + err := withStatusOutput(func(output *environment.MemoryOutput) error { + _, err := Task("Running...", func(logger *Logger) (string, error) { + logger.Line("before\x1b[1G\x1b[2Kafter") + return "", failure + }, 10, false, "") + if !errors.Is(err, failure) { + t.Fatalf("Task() error = %v", err) + } + if !strings.Contains(output.Text(), "beforeafter") || !strings.Contains(output.Text(), "\x1b[?25h") { + t.Fatalf("failed task output missing cleanup/sanitized log:\n%s", output.Text()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestFrameLineCountingAndErasing(t *testing.T) { + frame := RenderTaskFrame(TaskFrameOptions{Label: "Running...", Limit: 2, Lines: []string{"line one"}}) + if got := RenderedFrameLineCount(frame); got != 4 { + t.Fatalf("RenderedFrameLineCount() = %d", got) + } + err := withStatusOutput(func(output *environment.MemoryOutput) error { + EraseRenderedFrame(frame) + if output.Text() != strings.Repeat("\x1b[1A\x1b[2K", 4) { + t.Fatalf("EraseRenderedFrame() output = %q", output.Text()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} diff --git a/provision/go/internal/status/stream.go b/provision/go/internal/status/stream.go new file mode 100644 index 00000000..90960933 --- /dev/null +++ b/provision/go/internal/status/stream.go @@ -0,0 +1,133 @@ +package status + +import ( + "errors" + stdstrings "strings" + + "github.com/oullin/tui/internal/environment" + text "github.com/oullin/tui/internal/strings" + "github.com/oullin/tui/internal/terminal" + "github.com/oullin/tui/internal/theme" +) + +type Stream struct { + closed bool + buffer *StreamBuffer +} + +func NewStream() *Stream { + environment.Current().Output.Write(terminal.HideCursorSequence()) + return &Stream{buffer: NewStreamBuffer(10)} +} + +func (stream *Stream) Write(content string) error { + return stream.Append(content) +} + +func (stream *Stream) Append(content string) error { + if stream.closed { + return errors.New("stream is closed") + } + stream.buffer.Append(content) + stream.render() + return nil +} + +func (stream *Stream) Close() { + if stream.closed { + return + } + for stream.buffer.FlushNext() { + stream.render() + } + stream.closed = true + environment.Current().Output.Write(terminal.ShowCursorSequence()) +} + +func (stream *Stream) Closed() bool { + return stream.closed +} + +func (stream *Stream) Value() string { + return stream.buffer.Value() +} + +func (stream *Stream) Lines() []string { + return StreamLines(stream.Value(), 60) +} + +func (stream *Stream) render() { + environment.Current().Output.Write(RenderStreamFrame(stream.buffer.StableValue(), stream.buffer.Fading, StreamFadeStyles(false), 60)) +} + +type StreamBuffer struct { + Fading []string + message string + fadeLimit int +} + +func NewStreamBuffer(fadeLimit int) *StreamBuffer { + return &StreamBuffer{fadeLimit: fadeLimit} +} + +func (buffer *StreamBuffer) Append(message string) { + buffer.Fading = append(buffer.Fading, message) + for len(buffer.Fading) > buffer.fadeLimit { + buffer.message += buffer.Fading[0] + buffer.Fading = buffer.Fading[1:] + } +} + +func (buffer *StreamBuffer) FlushNext() bool { + if len(buffer.Fading) == 0 { + return false + } + buffer.message += buffer.Fading[0] + buffer.Fading = buffer.Fading[1:] + return true +} + +func (buffer *StreamBuffer) Value() string { + return buffer.message + stdstrings.Join(buffer.Fading, "") +} + +func (buffer *StreamBuffer) StableValue() string { + return buffer.message +} + +type FadeStyle func(string) string + +func StreamFadeStyles(trueColor bool) []FadeStyle { + if trueColor { + return []FadeStyle{ + func(value string) string { return theme.ForegroundRGB(value, 100, 50, 0) }, + func(value string) string { return theme.ForegroundRGB(value, 50, 25, 0) }, + } + } + return []FadeStyle{func(value string) string { return value }, theme.Dim} +} + +func RenderStreamFrame(value string, fading []string, styles []FadeStyle, width int) string { + rendered := value + for index, chunk := range fading { + style := styles[len(styles)-1] + if index < len(styles) { + style = styles[index] + } + rendered += style(chunk) + } + lines := StreamLines(rendered, width) + for index, line := range lines { + lines[index] = " " + line + } + return stdstrings.Join(lines, "\n") + "\n" +} + +func StreamLines(value string, width int) []string { + parts := stdstrings.Split(value, "\n") + lines := make([]string, 0) + for _, part := range parts { + lines = append(lines, text.Wrap(part, width)...) + } + return lines +} diff --git a/provision/go/internal/status/task.go b/provision/go/internal/status/task.go new file mode 100644 index 00000000..0bd753db --- /dev/null +++ b/provision/go/internal/status/task.go @@ -0,0 +1,205 @@ +package status + +import ( + "regexp" + stdstrings "strings" + + "github.com/oullin/tui/internal/environment" + "github.com/oullin/tui/internal/terminal" +) + +type StableMessage struct { + Type string + Message string +} + +type Logger struct { + LabelValue string + SubLabelValue string + Lines []string + Stable []StableMessage + limit int + stableLimit int + partialBuffer string + partialStart int + hasPartial bool +} + +func NewLogger(limit int, label string, subLabel string) *Logger { + if limit < 0 { + limit = 0 + } + if limit == 0 { + limit = 10 + } + return &Logger{LabelValue: label, SubLabelValue: subLabel, limit: limit, stableLimit: limit} +} + +func (logger *Logger) Limit() int { + return logger.limit +} + +func (logger *Logger) Line(message string) { + logger.writeLines(stdstrings.TrimRight(message, " \t\r\n")) +} + +func (logger *Logger) Log(message string) { + logger.Line(message) +} + +func (logger *Logger) Label(message string) { + logger.LabelValue = message +} + +func (logger *Logger) SubLabel(message string) { + logger.SubLabelValue = message +} + +func (logger *Logger) Partial(chunk string) { + logger.partialBuffer += chunk + if !logger.hasPartial { + logger.partialStart = len(logger.Lines) + logger.hasPartial = true + } + logger.Lines = logger.Lines[:logger.partialStart] + logger.writeLines(logger.partialBuffer) + if logger.partialStart > len(logger.Lines) { + logger.partialStart = len(logger.Lines) + } +} + +func (logger *Logger) CommitPartial() { + logger.partialBuffer = "" + logger.partialStart = 0 + logger.hasPartial = false +} + +func (logger *Logger) Info(message string) { + logger.Line(message) +} + +func (logger *Logger) Success(message string) { + logger.stable("success", message) +} + +func (logger *Logger) Warning(message string) { + logger.stable("warning", message) +} + +func (logger *Logger) Error(message string) { + logger.stable("error", message) +} + +func (logger *Logger) stable(kind string, message string) { + logger.Stable = append(logger.Stable, StableMessage{Type: kind, Message: message}) + logger.Lines = nil + logger.partialBuffer = "" + logger.hasPartial = false + for len(logger.Stable) > logger.stableLimit { + logger.Stable = logger.Stable[1:] + } +} + +func (logger *Logger) writeLines(message string) { + for _, line := range stdstrings.Split(message, "\n") { + if line != "" { + logger.Lines = append(logger.Lines, sanitizeTaskLine(line)) + } + } + for len(logger.Lines) > logger.limit { + logger.Lines = logger.Lines[1:] + } +} + +func Task[T any](label string, callback func(*Logger) (T, error), limit int, keepSummary bool, subLabel string) (T, error) { + var zero T + if callback == nil { + return zero, errMissingCallback("task") + } + logger := NewLogger(limit, label, subLabel) + output := environment.Current().Output + output.Write(terminal.HideCursorSequence()) + defer output.Write(terminal.ShowCursorSequence()) + + frame := RenderTaskFrame(TaskFrameOptions{Label: logger.LabelValue, SubLabel: logger.SubLabelValue, Limit: logger.Limit(), Lines: logger.Lines, StableMessages: logger.Stable}) + output.Write(frame) + + result, err := callback(logger) + EraseRenderedFrame(frame) + if err != nil { + output.Write(RenderTaskFrame(TaskFrameOptions{Label: logger.LabelValue, SubLabel: logger.SubLabelValue, Limit: logger.Limit(), Lines: logger.Lines, StableMessages: logger.Stable})) + return zero, err + } + output.Write(RenderTaskFrame(TaskFrameOptions{Finished: true, KeepSummary: keepSummary, Label: logger.LabelValue, SubLabel: logger.SubLabelValue, Limit: logger.Limit(), Lines: logger.Lines, StableMessages: logger.Stable})) + return result, nil +} + +type TaskFrameOptions struct { + FrameCount int + Finished bool + KeepSummary bool + Label string + Limit int + Lines []string + StableMessages []StableMessage + SubLabel string +} + +func RenderTaskFrame(options TaskFrameOptions) string { + if options.Finished && options.KeepSummary && len(options.StableMessages) > 0 { + lines := []string{" • " + options.Label} + for _, message := range options.StableMessages { + lines = append(lines, renderStableMessage(message)) + } + lines = append(lines, "") + return joinLines(lines) + "\n" + } + + output := []string{stdstrings.TrimRight(RenderSpinnerFrame(options.Label, options.FrameCount), "\n")} + if options.FrameCount == 0 { + output[0] = stdstrings.TrimRight(RenderSpinnerFrame(options.Label), "\n") + } + if options.SubLabel != "" { + output = append(output, " "+options.SubLabel) + } + for _, message := range options.StableMessages { + output = append(output, renderStableMessage(message)) + } + if len(options.StableMessages) > 0 || len(options.Lines) > 0 { + output = append(output, " "+stdstrings.Repeat("─", 60)) + } else { + output = append(output, "") + } + visible := options.Lines + if len(visible) > options.Limit { + visible = visible[len(visible)-options.Limit:] + } + for _, line := range visible { + output = append(output, " "+line) + } + for remaining := options.Limit - len(visible); remaining > 0; remaining-- { + output = append(output, "") + } + return joinLines(output) + "\n" +} + +func renderStableMessage(message StableMessage) string { + symbol := "⚠" + if message.Type == "success" { + symbol = "✔" + } + if message.Type == "error" { + symbol = "✘" + } + return " " + symbol + " " + message.Message +} + +var taskControlPattern = regexp.MustCompile(`\x1b\[1G|\x1b\[2K|\r`) + +func sanitizeTaskLine(line string) string { + return taskControlPattern.ReplaceAllString(line, "") +} + +func joinLines(lines []string) string { + return stdstrings.Join(lines, "\n") +} diff --git a/provision/go/internal/strings/strings.go b/provision/go/internal/strings/strings.go new file mode 100644 index 00000000..4d1bcbb2 --- /dev/null +++ b/provision/go/internal/strings/strings.go @@ -0,0 +1,300 @@ +package strings + +import ( + stdstrings "strings" + "unicode/utf8" + + "github.com/mattn/go-runewidth" +) + +const esc = "\x1b" + +type AnsiSegment struct { + Codes string + Text string +} + +var resetCodes = map[string]bool{ + "0": true, + "22": true, + "23": true, + "24": true, + "27": true, + "29": true, + "39": true, + "49": true, +} + +func VisibleWidth(value string) int { + return runewidth.StringWidth(ParseAnsiText(value)) +} + +func Truncate(value string, width int, marker ...string) string { + suffix := "..." + if len(marker) > 0 { + suffix = marker[0] + } + + if width <= 0 { + return "" + } + + if VisibleWidth(value) <= width { + return value + } + + if width <= VisibleWidth(suffix) { + clipped := "" + for _, char := range suffix { + next := clipped + string(char) + if VisibleWidth(next) > width { + break + } + clipped = next + } + + return clipped + } + + result := "" + activeCodes := "" + markerWidth := VisibleWidth(suffix) + + for _, segment := range ParseAnsiSegments(value) { + if segment.Codes != activeCodes { + if activeCodes != "" { + result += ansiCloseSequence(activeCodes) + } + if segment.Codes != "" { + result += segment.Codes + } + activeCodes = segment.Codes + } + + for _, char := range segment.Text { + if VisibleWidth(result+string(char))+markerWidth > width { + if activeCodes != "" { + result += ansiCloseSequence(activeCodes) + } + + return result + suffix + } + + result += string(char) + } + } + + return result +} + +func ParseAnsiSegments(value string) []AnsiSegment { + segments := make([]AnsiSegment, 0) + currentCodes := "" + currentText := "" + + for index := 0; index < len(value); { + if value[index] == '\x1b' && index+1 < len(value) && value[index+1] == '[' { + if currentText != "" { + segments = append(segments, AnsiSegment{Codes: currentCodes, Text: currentText}) + currentText = "" + } + + start := index + index += 2 + for index < len(value) && value[index] != 'm' { + index++ + } + + if index < len(value) && value[index] == 'm' { + index++ + sequence := value[start:index] + code := sequence[2 : len(sequence)-1] + if resetCodes[code] { + currentCodes = "" + } else { + currentCodes = sequence + } + continue + } + + currentText += value[start:] + break + } + + runeValue, size := utf8.DecodeRuneInString(value[index:]) + currentText += string(runeValue) + index += size + } + + if currentText != "" { + segments = append(segments, AnsiSegment{Codes: currentCodes, Text: currentText}) + } + + return segments +} + +func ParseAnsiText(value string) string { + var builder stdstrings.Builder + + for _, segment := range ParseAnsiSegments(value) { + builder.WriteString(segment.Text) + } + + return builder.String() +} + +func Wrap(value string, width int) []string { + plainLines := plainWrap(ParseAnsiText(value), width) + characters := codedCharacters(value) + result := make([]string, 0, len(plainLines)) + characterIndex := 0 + + for _, plainLine := range plainLines { + line := "" + activeCodes := "" + + for _, plainCharacter := range plainLine { + for characterIndex < len(characters) && characters[characterIndex].Text != string(plainCharacter) { + if characters[characterIndex].Text == " " { + characterIndex++ + continue + } + + break + } + + if characterIndex >= len(characters) { + line += string(plainCharacter) + continue + } + + character := characters[characterIndex] + if character.Codes != activeCodes { + if activeCodes != "" { + line += "\x1b[0m" + } + if character.Codes != "" { + line += character.Codes + } + activeCodes = character.Codes + } + + line += string(plainCharacter) + characterIndex++ + } + + if activeCodes != "" { + line += ansiCloseSequence(activeCodes) + } + + result = append(result, line) + } + + return result +} + +func ansiCloseSequence(codes string) string { + code := codes[2 : len(codes)-1] + + switch code { + case "1", "2": + return esc + "[22m" + case "3": + return esc + "[23m" + case "4": + return esc + "[24m" + case "7": + return esc + "[27m" + case "9": + return esc + "[29m" + default: + return esc + "[0m" + } +} + +func codedCharacters(value string) []AnsiSegment { + characters := make([]AnsiSegment, 0) + + for _, segment := range ParseAnsiSegments(value) { + for _, char := range segment.Text { + characters = append(characters, AnsiSegment{Codes: segment.Codes, Text: string(char)}) + } + } + + return characters +} + +func plainWrap(value string, width int) []string { + if width <= 0 { + return []string{value} + } + + lines := make([]string, 0) + for _, originalLine := range stdstrings.Split(value, "\n") { + line := "" + + for _, word := range splitWhitespace(originalLine) { + if VisibleWidth(line+word) <= width { + line += word + continue + } + + if line != "" { + lines = append(lines, stdstrings.TrimRight(line, " \t\r\n")) + line = "" + } + + if VisibleWidth(word) > width { + chunk := "" + for _, char := range word { + next := chunk + string(char) + if VisibleWidth(next) > width { + if chunk != "" { + lines = append(lines, chunk) + } + chunk = "" + } + chunk += string(char) + } + line = chunk + continue + } + + line = stdstrings.TrimLeft(word, " \t\r\n") + } + + lines = append(lines, stdstrings.TrimRight(line, " \t\r\n")) + } + + return lines +} + +func splitWhitespace(value string) []string { + if value == "" { + return []string{""} + } + + parts := make([]string, 0) + current := "" + currentWhitespace := isWhitespace(rune(value[0])) + + for _, char := range value { + whitespace := isWhitespace(char) + if current != "" && whitespace != currentWhitespace { + parts = append(parts, current) + current = "" + } + current += string(char) + currentWhitespace = whitespace + } + + if current != "" { + parts = append(parts, current) + } + + return parts +} + +func isWhitespace(char rune) bool { + return char == ' ' || char == '\t' || char == '\n' || char == '\r' +} diff --git a/provision/go/internal/strings/strings_test.go b/provision/go/internal/strings/strings_test.go new file mode 100644 index 00000000..920c086f --- /dev/null +++ b/provision/go/internal/strings/strings_test.go @@ -0,0 +1,97 @@ +package strings + +import ( + "reflect" + stdstrings "strings" + "testing" +) + +func TestVisibleWidthIgnoresANSI(t *testing.T) { + if got := VisibleWidth("\x1b[31mPrompts\x1b[39m"); got != 7 { + t.Fatalf("VisibleWidth() = %d, want 7", got) + } +} + +func TestTruncate(t *testing.T) { + if got := Truncate("Ollin Prompts", 10); got != "Ollin P..." { + t.Fatalf("Truncate() = %q", got) + } +} + +func TestTruncateClosesANSIStyles(t *testing.T) { + result := Truncate("\x1b[31mOllin Prompts\x1b[39m", 10) + + if got := ParseAnsiText(result); got != "Ollin P..." { + t.Fatalf("ParseAnsiText(truncated) = %q", got) + } + if !stdstrings.Contains(result, "\x1b[31m") { + t.Fatalf("expected red ANSI open sequence in %q", result) + } + if !stdstrings.Contains(result, "\x1b[0m...") { + t.Fatalf("expected reset before marker in %q", result) + } + if stdstrings.HasSuffix(result, "\x1b[39m") { + t.Fatalf("did not expect foreground reset suffix in %q", result) + } +} + +func TestTruncateClipsMarkers(t *testing.T) { + if got := Truncate("Ollin Prompts", 1, "…"); got != "…" { + t.Fatalf("Truncate(marker) = %q", got) + } + if got := Truncate("Ollin Prompts", 1, "東京"); got != "" { + t.Fatalf("Truncate(wide marker) = %q", got) + } +} + +func TestWrap(t *testing.T) { + if got := Wrap("Ollin Prompts", 7); !reflect.DeepEqual(got, []string{"Ollin", "Prompts"}) { + t.Fatalf("Wrap() = %#v", got) + } + + if got := Wrap("😀", 1); !reflect.DeepEqual(got, []string{"😀"}) { + t.Fatalf("Wrap(wide) = %#v", got) + } +} + +func TestParseAnsi(t *testing.T) { + if got := ParseAnsiText("\x1b[32mDone\x1b[39m"); got != "Done" { + t.Fatalf("ParseAnsiText() = %q", got) + } + + if got := ParseAnsiSegments("Hello, World!"); !reflect.DeepEqual(got, []AnsiSegment{{Text: "Hello, World!", Codes: ""}}) { + t.Fatalf("ParseAnsiSegments(plain) = %#v", got) + } + + want := []AnsiSegment{{Text: "Hello ", Codes: ""}, {Text: "Bold", Codes: "\x1b[1m"}, {Text: " World", Codes: ""}} + if got := ParseAnsiSegments("Hello \x1b[1mBold\x1b[0m World"); !reflect.DeepEqual(got, want) { + t.Fatalf("ParseAnsiSegments(styled) = %#v", got) + } + + want = []AnsiSegment{{Text: "Colored", Codes: "\x1b[38;2;255;100;50m"}} + if got := ParseAnsiSegments("\x1b[38;2;255;100;50mColored\x1b[0m"); !reflect.DeepEqual(got, want) { + t.Fatalf("ParseAnsiSegments(rgb) = %#v", got) + } +} + +func TestWrapPreservesANSI(t *testing.T) { + result := Wrap("\x1b[31mHello World\x1b[0m", 5) + + if len(result) != 2 { + t.Fatalf("Wrap() len = %d, want 2: %#v", len(result), result) + } + for _, want := range []string{"Hello", "World"} { + found := false + for _, line := range result { + found = found || stdstrings.Contains(line, want) + } + if !found { + t.Fatalf("expected wrapped output to contain %q: %#v", want, result) + } + } + for _, line := range result { + if !stdstrings.Contains(line, "\x1b[31m") || !stdstrings.HasSuffix(line, "\x1b[0m") { + t.Fatalf("wrapped line does not preserve ANSI: %q", line) + } + } +} diff --git a/provision/go/internal/terminal/terminal.go b/provision/go/internal/terminal/terminal.go new file mode 100644 index 00000000..b446c297 --- /dev/null +++ b/provision/go/internal/terminal/terminal.go @@ -0,0 +1,93 @@ +package terminal + +import ( + "io" + "math" + "os" +) + +type Size struct { + Columns int + Rows int +} + +type Color [3]int + +func DefaultSize() Size { + return Size{Columns: 80, Rows: 24} +} + +func ClearSequence() string { + return "\x1b[H\x1b[J" +} + +func TitleSequence(title string) string { + return "\x1b]0;" + title + "\x07" +} + +func EraseLineSequence() string { + return "\x1b[2K" +} + +func ErasePreviousLinesSequence(count int) string { + lines := int(math.Max(0, math.Floor(float64(count)))) + result := "" + for range lines { + result += "\x1b[1A\x1b[2K" + } + + return result +} + +func CursorToStartSequence() string { + return "\r" +} + +func HideCursorSequence() string { + return "\x1b[?25l" +} + +func ShowCursorSequence() string { + return "\x1b[?25h" +} + +func WriteClear(writer io.Writer) error { + _, err := io.WriteString(writer, ClearSequence()) + return err +} + +func WriteTitle(writer io.Writer, title string) error { + _, err := io.WriteString(writer, TitleSequence(title)) + return err +} + +func SupportsTrueColor(value ...string) bool { + colorTerm := os.Getenv("COLORTERM") + if len(value) > 0 { + colorTerm = value[0] + } + + return colorTerm == "truecolor" || colorTerm == "24bit" +} + +func DefaultForegroundColor() Color { + return Color{204, 204, 204} +} + +func DefaultBackgroundColor() Color { + return Color{0, 0, 0} +} + +func ParseColor(value []int, fallback Color) Color { + if len(value) != 3 { + return fallback + } + + for _, part := range value { + if part < 0 || part > 255 { + return fallback + } + } + + return Color{value[0], value[1], value[2]} +} diff --git a/provision/go/internal/terminal/terminal_test.go b/provision/go/internal/terminal/terminal_test.go new file mode 100644 index 00000000..c6ccf2a5 --- /dev/null +++ b/provision/go/internal/terminal/terminal_test.go @@ -0,0 +1,39 @@ +package terminal + +import "testing" + +func TestControlSequences(t *testing.T) { + if got := ClearSequence(); got != "\x1b[H\x1b[J" { + t.Fatalf("ClearSequence() = %q", got) + } + if got := TitleSequence("Demo"); got != "\x1b]0;Demo\x07" { + t.Fatalf("TitleSequence() = %q", got) + } + if got := CursorToStartSequence() + EraseLineSequence() + ErasePreviousLinesSequence(2) + ErasePreviousLinesSequence(-1) + HideCursorSequence() + ShowCursorSequence(); got != "\r\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[?25l\x1b[?25h" { + t.Fatalf("combined sequences = %q", got) + } +} + +func TestSupportsTrueColor(t *testing.T) { + if !SupportsTrueColor("truecolor") { + t.Fatalf("expected truecolor to be supported") + } + if !SupportsTrueColor("24bit") { + t.Fatalf("expected 24bit to be supported") + } + if SupportsTrueColor("256color") { + t.Fatalf("expected 256color to be unsupported") + } +} + +func TestParseColor(t *testing.T) { + if got := ParseColor([]int{1, 2, 3}, DefaultForegroundColor()); got != (Color{1, 2, 3}) { + t.Fatalf("ParseColor() = %#v", got) + } + if got := ParseColor([]int{0, 0, 300}, DefaultBackgroundColor()); got != DefaultBackgroundColor() { + t.Fatalf("ParseColor(invalid) = %#v", got) + } + if got := ParseColor([]int{1, 2}, DefaultForegroundColor()); got != DefaultForegroundColor() { + t.Fatalf("ParseColor(short) = %#v", got) + } +} diff --git a/provision/go/internal/theme/box.go b/provision/go/internal/theme/box.go new file mode 100644 index 00000000..f7245da2 --- /dev/null +++ b/provision/go/internal/theme/box.go @@ -0,0 +1,79 @@ +package theme + +import ( + stdstrings "strings" + + text "github.com/oullin/tui/internal/strings" +) + +const defaultBoxWidth = 60 + +type BoxOptions struct { + Body string + BorderStyle func(string) string + Info string + Title string + Width int +} + +func RenderBox(options BoxOptions) string { + borderStyle := options.BorderStyle + if borderStyle == nil { + borderStyle = func(value string) string { return value } + } + + width := options.Width + if width == 0 { + width = defaultBoxWidth + } + + bodyLines := stdstrings.Split(options.Body, "\n") + contentWidth := width + if titleWidth := text.VisibleWidth(options.Title); titleWidth > contentWidth { + contentWidth = titleWidth + } + for _, line := range bodyLines { + if lineWidth := text.VisibleWidth(line); lineWidth > contentWidth { + contentWidth = lineWidth + } + } + + titleWidth := text.VisibleWidth(options.Title) + titleLabel := "" + if titleWidth > 0 { + titleLabel = " " + options.Title + " " + } + + topBorderWidth := contentWidth - titleWidth + if titleWidth == 0 { + topBorderWidth += 2 + } + + renderedInfo := "" + if options.Info != "" { + renderedInfo = text.Truncate(options.Info, contentWidth-1) + } + + bottomBorderWidth := contentWidth + 2 + if renderedInfo != "" { + bottomBorderWidth = contentWidth - text.VisibleWidth(renderedInfo) + } + + lines := []string{ + borderStyle(" ┌") + titleLabel + borderStyle(stdstrings.Repeat("─", topBorderWidth)+"┐"), + } + + for _, line := range bodyLines { + lines = append(lines, borderStyle(" │")+" "+padVisible(line, contentWidth)+" "+borderStyle("│")) + } + + bottom := " └" + stdstrings.Repeat("─", bottomBorderWidth) + if renderedInfo != "" { + bottom += " " + renderedInfo + " " + } + bottom += "┘" + + lines = append(lines, borderStyle(bottom)) + + return stdstrings.Join(lines, "\n") +} diff --git a/provision/go/internal/theme/styles.go b/provision/go/internal/theme/styles.go new file mode 100644 index 00000000..f573253f --- /dev/null +++ b/provision/go/internal/theme/styles.go @@ -0,0 +1,63 @@ +package theme + +import "fmt" + +func ansi(open int, close int, value string) string { + if value == "" { + return "" + } + + return fmt.Sprintf("\x1b[%dm%s\x1b[%dm", open, value, close) +} + +func Dim(value string) string { + return ansi(2, 22, value) +} + +func Cyan(value string) string { + return ansi(36, 39, value) +} + +func Green(value string) string { + return ansi(32, 39, value) +} + +func Inverse(value string) string { + return ansi(7, 27, value) +} + +func Black(value string) string { + return ansi(30, 39, value) +} + +func Red(value string) string { + return ansi(31, 39, value) +} + +func Strikethrough(value string) string { + return ansi(9, 29, value) +} + +func White(value string) string { + return ansi(37, 39, value) +} + +func Yellow(value string) string { + return ansi(33, 39, value) +} + +func BackgroundCyan(value string) string { + return ansi(46, 49, value) +} + +func BackgroundRed(value string) string { + return ansi(41, 49, value) +} + +func ForegroundRGB(value string, red int, green int, blue int) string { + if value == "" { + return "" + } + + return fmt.Sprintf("\x1b[38;2;%d;%d;%dm%s\x1b[0m", red, green, blue, value) +} diff --git a/provision/go/internal/theme/theme.go b/provision/go/internal/theme/theme.go new file mode 100644 index 00000000..d424d367 --- /dev/null +++ b/provision/go/internal/theme/theme.go @@ -0,0 +1,121 @@ +package theme + +import ( + "fmt" + stdstrings "strings" + + text "github.com/oullin/tui/internal/strings" +) + +const ( + SymbolQuestion = "?" + SymbolAnswer = ">" + SymbolError = "!" + SymbolInfo = "i" + SymbolSelected = "●" + SymbolUnselected = "○" + SymbolPointer = "›" + SymbolSuccess = "✓" + SymbolWarning = "!" +) + +func RenderQuestion(message string, hint string) string { + suffix := "" + if hint != "" { + suffix = " " + hint + } + + return fmt.Sprintf("%s %s%s ", SymbolQuestion, message, suffix) +} + +func RenderError(message string) string { + return Red(fmt.Sprintf("%s %s", SymbolError, message)) + "\n" +} + +func RenderTable(headers []string, rows [][]string) string { + columnCount := len(headers) + for _, row := range rows { + if len(row) > columnCount { + columnCount = len(row) + } + } + + if columnCount <= 0 { + return "" + } + + widths := make([]int, columnCount) + for index := range columnCount { + widths[index] = text.VisibleWidth(valueAt(headers, index)) + for _, row := range rows { + width := text.VisibleWidth(valueAt(row, index)) + if width > widths[index] { + widths[index] = width + } + } + } + + renderRow := func(columns []string) string { + parts := make([]string, len(widths)) + for index, width := range widths { + parts[index] = padVisible(valueAt(columns, index), width) + } + + return " │ " + stdstrings.Join(parts, " │ ") + " │" + } + + renderBorder := func(left string, middle string, right string) string { + parts := make([]string, len(widths)) + for index, width := range widths { + parts[index] = stdstrings.Repeat("─", width+2) + } + + return " " + left + stdstrings.Join(parts, middle) + right + } + + top := renderBorder("┌", "┬", "┐") + divider := renderBorder("├", "┼", "┤") + bottom := renderBorder("└", "┴", "┘") + + if len(headers) == 0 { + lines := append([]string{top}, mapRows(rows, renderRow)...) + lines = append(lines, bottom) + return stdstrings.Join(lines, "\n") + } + + dimmedHeaders := make([]string, len(headers)) + for index, header := range headers { + dimmedHeaders[index] = Dim(header) + } + + lines := []string{top, renderRow(dimmedHeaders), divider} + lines = append(lines, mapRows(rows, renderRow)...) + lines = append(lines, bottom) + return stdstrings.Join(lines, "\n") +} + +func valueAt(values []string, index int) string { + if index < 0 || index >= len(values) { + return "" + } + + return values[index] +} + +func padVisible(value string, width int) string { + padding := width - text.VisibleWidth(value) + if padding < 0 { + padding = 0 + } + + return value + stdstrings.Repeat(" ", padding) +} + +func mapRows(rows [][]string, render func([]string) string) []string { + result := make([]string, len(rows)) + for index, row := range rows { + result[index] = render(row) + } + + return result +} diff --git a/provision/go/internal/theme/theme_test.go b/provision/go/internal/theme/theme_test.go new file mode 100644 index 00000000..6b7c89a8 --- /dev/null +++ b/provision/go/internal/theme/theme_test.go @@ -0,0 +1,47 @@ +package theme + +import ( + stdstrings "strings" + "testing" +) + +func TestStyles(t *testing.T) { + if got := Red("Error"); got != "\x1b[31mError\x1b[39m" { + t.Fatalf("Red() = %q", got) + } + if got := Dim(""); got != "" { + t.Fatalf("Dim(empty) = %q", got) + } + if got := ForegroundRGB("Text", 1, 2, 3); got != "\x1b[38;2;1;2;3mText\x1b[0m" { + t.Fatalf("ForegroundRGB() = %q", got) + } +} + +func TestRenderQuestionAndError(t *testing.T) { + if got := RenderQuestion("Name?", "required"); got != "? Name? required " { + t.Fatalf("RenderQuestion() = %q", got) + } + if got := RenderError("Required"); got != "\x1b[31m! Required\x1b[39m\n" { + t.Fatalf("RenderError() = %q", got) + } +} + +func TestRenderTable(t *testing.T) { + result := RenderTable([]string{"Name", "Role"}, [][]string{{"Ada", "Admin"}, {"Linus", "User"}}) + + for _, want := range []string{"┌───────┬───────┐", "\x1b[2mName\x1b[22m", "Ada", "Linus", "└───────┴───────┘"} { + if !stdstrings.Contains(result, want) { + t.Fatalf("RenderTable() missing %q in:\n%s", want, result) + } + } +} + +func TestRenderBox(t *testing.T) { + result := RenderBox(BoxOptions{Body: "Ready", Title: "Task", Width: 10}) + + for _, want := range []string{" ┌ Task ──────┐", " │ Ready │", " └────────────┘"} { + if !stdstrings.Contains(result, want) { + t.Fatalf("RenderBox() missing %q in:\n%s", want, result) + } + } +} diff --git a/provision/go/internal/typedvalue/lines.go b/provision/go/internal/typedvalue/lines.go new file mode 100644 index 00000000..37a58aa9 --- /dev/null +++ b/provision/go/internal/typedvalue/lines.go @@ -0,0 +1,168 @@ +package typedvalue + +import text "github.com/oullin/tui/internal/strings" + +type LineRange struct { + Start int + End int +} + +type VisibleLineWindow struct { + Lines []string + Start int + Total int +} + +func VisibleLines(value string, cursor int, rows int) string { + lines := VisibleLineWindowFor(value, cursor, rows).Lines + result := "" + for index, line := range lines { + if index > 0 { + result += "\n" + } + result += line + } + + return result +} + +func VisibleLineWindowFor(value string, cursor int, rows int, width ...int) VisibleLineWindow { + valueCharacters := Characters(value) + wrapWidth := 0 + if len(width) > 0 { + wrapWidth = width[0] + } + ranges := lineRanges(valueCharacters, wrapWidth) + + if rows <= 0 { + return VisibleLineWindow{ + Lines: linesForRanges(valueCharacters, ranges), + Start: 0, + Total: len(ranges), + } + } + + line := currentLine(ranges, cursor) + start := max(0, line-rows+1) + end := start + rows + if end > len(ranges) { + end = len(ranges) + } + + return VisibleLineWindow{ + Lines: linesForRanges(valueCharacters, ranges[start:end]), + Start: start, + Total: len(ranges), + } +} + +func MoveLine(value []string, cursor int, direction int, width int) int { + ranges := lineRanges(value, width) + index := currentLine(ranges, cursor) + if index < 0 || index >= len(ranges) { + return cursor + } + + targetIndex := index + direction + if targetIndex < 0 { + return 0 + } + if targetIndex >= len(ranges) { + return len(value) + } + + current := ranges[index] + target := ranges[targetIndex] + column := min(cursor-current.Start, current.End-current.Start) + targetColumn := min(column, target.End-target.Start) + + return target.Start + targetColumn +} + +func MoveToLineBoundary(value []string, cursor int, boundary string) int { + ranges := lineRanges(value, 0) + index := currentLine(ranges, cursor) + if index < 0 || index >= len(ranges) { + return cursor + } + + if boundary == "start" { + return ranges[index].Start + } + + return ranges[index].End +} + +func lineRanges(value []string, width int) []LineRange { + ranges := make([]LineRange, 0) + start := 0 + + for index, char := range value { + if char == "\n" { + ranges = append(ranges, wrappedRanges(value, start, index, width)...) + start = index + 1 + } + } + + ranges = append(ranges, wrappedRanges(value, start, len(value), width)...) + + return ranges +} + +func wrappedRanges(value []string, start int, end int, width int) []LineRange { + if width <= 0 || start == end { + return []LineRange{{Start: start, End: end}} + } + + ranges := make([]LineRange, 0) + rangeStart := start + rangeWidth := 0 + + for index := start; index < end; index++ { + characterWidth := text.VisibleWidth(value[index]) + if rangeWidth > 0 && rangeWidth+characterWidth > width { + ranges = append(ranges, LineRange{Start: rangeStart, End: index}) + rangeStart = index + rangeWidth = 0 + } + + rangeWidth += characterWidth + } + + ranges = append(ranges, LineRange{Start: rangeStart, End: end}) + + return ranges +} + +func currentLine(ranges []LineRange, cursor int) int { + for index, lineRange := range ranges { + if cursor <= lineRange.End { + return index + } + } + + return len(ranges) - 1 +} + +func linesForRanges(value []string, ranges []LineRange) []string { + lines := make([]string, len(ranges)) + for index, lineRange := range ranges { + lines[index] = FromCharacters(value[lineRange.Start:lineRange.End]) + } + + return lines +} + +func min(left int, right int) int { + if left < right { + return left + } + return right +} + +func max(left int, right int) int { + if left > right { + return left + } + return right +} diff --git a/provision/go/internal/typedvalue/typedvalue.go b/provision/go/internal/typedvalue/typedvalue.go new file mode 100644 index 00000000..9b602a6b --- /dev/null +++ b/provision/go/internal/typedvalue/typedvalue.go @@ -0,0 +1,247 @@ +package typedvalue + +import ( + "unicode" + + "github.com/oullin/tui/internal/key" +) + +type State struct { + Cursor int + Value string +} + +type AppliedKey struct { + Cursor int + Value string + Cancelled bool + Submitted bool +} + +func InitialState(value string) State { + return State{Cursor: CharacterLength(value), Value: value} +} + +func ApplyKey(state State, pressed string, allowNewLine bool, wrapWidth ...int) AppliedKey { + value := Characters(state.Value) + cursor := clamp(state.Cursor, 0, len(value)) + width := 0 + if len(wrapWidth) > 0 { + width = wrapWidth[0] + } + + if pressed == key.First(key.CtrlC) { + return result(value, cursor, true, false) + } + + if pressed == key.First(key.CtrlD) && allowNewLine { + return result(value, cursor, false, true) + } + + if pressed == key.First(key.Enter) { + if !allowNewLine { + return result(value, cursor, false, true) + } + + value = insertAt(value, cursor, "\n") + return result(value, cursor+1, false, false) + } + + if moved, ok := moveCursor(value, cursor, pressed, allowNewLine, width); ok { + return result(value, moved, false, false) + } + + if next, ok := deleteKey(value, cursor, pressed, allowNewLine); ok { + return result(next.value, next.cursor, false, false) + } + + if next, ok := insertPrintable(value, cursor, pressed); ok { + return result(next.value, next.cursor, false, false) + } + + return result(value, cursor, false, false) +} + +func Characters(value string) []string { + characters := make([]string, 0) + for _, char := range value { + characters = append(characters, string(char)) + } + + return characters +} + +func FromCharacters(value []string) string { + result := "" + for _, char := range value { + result += char + } + + return result +} + +func CharacterLength(value string) int { + return len(Characters(value)) +} + +func IsPrintable(value string) bool { + if value == "" { + return false + } + + for _, char := range value { + if char < 32 || char == 127 { + return false + } + } + + return true +} + +type cursorResult struct { + cursor int + value []string +} + +func result(value []string, cursor int, cancelled bool, submitted bool) AppliedKey { + return AppliedKey{ + Cancelled: cancelled, + Cursor: cursor, + Submitted: submitted, + Value: FromCharacters(value), + } +} + +func moveCursor(value []string, cursor int, pressed string, allowNewLine bool, wrapWidth int) (int, bool) { + switch pressed { + case key.First(key.Left), key.First(key.LeftArrow), key.First(key.CtrlB): + return max(0, cursor-1), true + case key.First(key.Right), key.First(key.RightArrow), key.First(key.CtrlF): + return min(len(value), cursor+1), true + } + + if allowNewLine { + switch pressed { + case key.First(key.Up), key.First(key.UpArrow), key.First(key.CtrlP): + return MoveLine(value, cursor, -1, wrapWidth), true + case key.First(key.Down), key.First(key.DownArrow), key.First(key.CtrlN): + return MoveLine(value, cursor, 1, wrapWidth), true + } + } + + if _, ok := key.OneOf([]key.Value{key.Home, key.CtrlA}, pressed); ok { + if allowNewLine { + return MoveToLineBoundary(value, cursor, "start"), true + } + return 0, true + } + + if _, ok := key.OneOf([]key.Value{key.End, key.CtrlE}, pressed); ok { + if allowNewLine { + return MoveToLineBoundary(value, cursor, "end"), true + } + return len(value), true + } + + return 0, false +} + +func deleteKey(value []string, cursor int, pressed string, allowNewLine bool) (cursorResult, bool) { + switch pressed { + case key.First(key.Delete): + return cursorResult{cursor: cursor, value: deleteRange(value, cursor, cursor+1)}, true + case key.First(key.CtrlU): + start := 0 + if allowNewLine { + start = MoveToLineBoundary(value, cursor, "start") + } + return cursorResult{cursor: start, value: deleteRange(value, start, cursor)}, true + case key.First(key.Backspace), key.First(key.CtrlH): + if cursor == 0 { + return cursorResult{cursor: cursor, value: value}, true + } + return cursorResult{cursor: cursor - 1, value: deleteRange(value, cursor-1, cursor)}, true + case key.First(key.OptionBackspace): + start := previousWordStart(value, cursor) + return cursorResult{cursor: start, value: deleteRange(value, start, cursor)}, true + default: + return cursorResult{}, false + } +} + +func insertPrintable(value []string, cursor int, pressed string) (cursorResult, bool) { + if !IsPrintable(pressed) { + return cursorResult{}, false + } + + next := append([]string{}, value...) + nextCursor := cursor + for _, char := range Characters(pressed) { + next = insertAt(next, nextCursor, char) + nextCursor++ + } + + return cursorResult{cursor: nextCursor, value: next}, true +} + +func insertAt(value []string, cursor int, char string) []string { + next := append([]string{}, value[:cursor]...) + next = append(next, char) + next = append(next, value[cursor:]...) + return next +} + +func deleteRange(value []string, start int, end int) []string { + start = clamp(start, 0, len(value)) + end = clamp(end, start, len(value)) + next := append([]string{}, value[:start]...) + next = append(next, value[end:]...) + return next +} + +func previousWordStart(value []string, cursor int) int { + index := clamp(cursor, 0, len(value)) + for index > 0 && isSpace([]rune(value[index-1])[0]) { + index-- + } + + if index == 0 { + return 0 + } + + char := []rune(value[index-1])[0] + if isWord(char) { + for index > 0 && isWord([]rune(value[index-1])[0]) { + index-- + } + return index + } + + for index > 0 { + previous := []rune(value[index-1])[0] + if isWord(previous) || isSpace(previous) { + break + } + index-- + } + + return index +} + +func isWord(char rune) bool { + return unicode.IsLetter(char) || unicode.IsMark(char) || unicode.IsNumber(char) +} + +func isSpace(char rune) bool { + return unicode.IsSpace(char) +} + +func clamp(value int, low int, high int) int { + if value < low { + return low + } + if value > high { + return high + } + return value +} diff --git a/provision/go/internal/typedvalue/typedvalue_test.go b/provision/go/internal/typedvalue/typedvalue_test.go new file mode 100644 index 00000000..0b71ea42 --- /dev/null +++ b/provision/go/internal/typedvalue/typedvalue_test.go @@ -0,0 +1,139 @@ +package typedvalue + +import ( + "reflect" + "testing" + + "github.com/oullin/tui/internal/key" +) + +func apply(keys []string) string { + state := State{} + for _, pressed := range keys { + next := ApplyKey(state, pressed, false) + state = State{Cursor: next.Cursor, Value: next.Value} + } + + return state.Value +} + +func applyMultiline(keys []string) AppliedKey { + state := State{} + var next AppliedKey + for _, pressed := range keys { + next = ApplyKey(state, pressed, true) + state = State{Cursor: next.Cursor, Value: next.Value} + } + + return next +} + +func applyMultilineState(value string, cursor int, keys []string, wrapWidth ...int) State { + state := State{Cursor: cursor, Value: value} + for _, pressed := range keys { + next := ApplyKey(state, pressed, true, wrapWidth...) + state = State{Cursor: next.Cursor, Value: next.Value} + } + + return state +} + +func TestTypedValueInsertionAndDeletion(t *testing.T) { + if got := apply([]string{"Je", "ss"}); got != "Jess" { + t.Fatalf("apply(insert) = %q", got) + } + + if got := apply([]string{"J", "e", "z", key.First(key.Backspace), "s", "s"}); got != "Jess" { + t.Fatalf("apply(backspace) = %q", got) + } + + if got := apply([]string{"J", "e", "z", key.First(key.Left), key.First(key.Delete), "s", "s"}); got != "Jess" { + t.Fatalf("apply(delete) = %q", got) + } + + if got := apply([]string{"J", "e", "s", "s", key.First(key.Delete)}); got != "Jess" { + t.Fatalf("apply(delete at end) = %q", got) + } +} + +func TestTypedValueMovementAndCancellation(t *testing.T) { + if got := apply([]string{"J", "a", "n", "e", key.First(key.Left), key.First(key.Left), key.First(key.CtrlU), "D", "o"}); got != "Done" { + t.Fatalf("apply(ctrl-u) = %q", got) + } + + if got := apply([]string{"J", "z", "e", key.First(key.CtrlB), key.First(key.CtrlH), key.First(key.CtrlF), "s", "s"}); got != "Jess" { + t.Fatalf("apply(emacs movement) = %q", got) + } + + got := ApplyKey(State{Cursor: 3, Value: "Ada"}, key.First(key.CtrlC), false) + want := AppliedKey{Cancelled: true, Cursor: 3, Submitted: false, Value: "Ada"} + if got != want { + t.Fatalf("ApplyKey(ctrl-c) = %#v, want %#v", got, want) + } +} + +func TestTypedValueUnicodeAndPreviousWordDeletion(t *testing.T) { + if got := applyMultilineState("😀", len("😀"), []string{"!"}); got != (State{Cursor: 2, Value: "😀!"}) { + t.Fatalf("unicode clamp = %#v", got) + } + + if got := applyMultilineState("😀 hello", len([]rune("😀 hello")), []string{key.First(key.OptionBackspace)}); got != (State{Cursor: 2, Value: "😀 "}) { + t.Fatalf("previous word after emoji = %#v", got) + } + + if got := applyMultilineState("hello --", len([]rune("hello --")), []string{key.First(key.OptionBackspace)}); got != (State{Cursor: 6, Value: "hello "}) { + t.Fatalf("previous punctuation group = %#v", got) + } +} + +func TestTypedValueLineMovement(t *testing.T) { + if got := apply([]string{"A", "r", key.Home[0], "J", key.End[0], "c", "h", "e", "r"}); got != "JArcher" { + t.Fatalf("home/end = %q", got) + } + + if got := applyMultiline([]string{"A", key.First(key.Enter), "B", key.First(key.CtrlD)}); !got.Submitted || got.Value != "A\nB" { + t.Fatalf("multiline submit = %#v", got) + } + + tests := []struct { + name string + got State + want State + }{ + {name: "up", got: applyMultilineState("abc\nde\nfghi", 6, []string{key.First(key.Up)}), want: State{Cursor: 2, Value: "abc\nde\nfghi"}}, + {name: "down", got: applyMultilineState("abc\nde\nfghi", 2, []string{key.First(key.Down)}), want: State{Cursor: 6, Value: "abc\nde\nfghi"}}, + {name: "down long", got: applyMultilineState("abc\nde\nfghi", 6, []string{key.First(key.Down)}), want: State{Cursor: 9, Value: "abc\nde\nfghi"}}, + {name: "ctrl-p", got: applyMultilineState("abc\nde\nfghi", 6, []string{key.First(key.CtrlP)}), want: State{Cursor: 2, Value: "abc\nde\nfghi"}}, + {name: "ctrl-n", got: applyMultilineState("abc\nde\nfghi", 2, []string{key.First(key.CtrlN)}), want: State{Cursor: 6, Value: "abc\nde\nfghi"}}, + {name: "wrapped up", got: applyMultilineState("abcdefghijkl", 7, []string{key.First(key.Up)}, 5), want: State{Cursor: 2, Value: "abcdefghijkl"}}, + {name: "wrapped down", got: applyMultilineState("abcdefghijkl", 2, []string{key.First(key.Down)}, 5), want: State{Cursor: 7, Value: "abcdefghijkl"}}, + {name: "wrapped up from end", got: applyMultilineState("abcdefghijkl", 11, []string{key.First(key.Up)}, 5), want: State{Cursor: 6, Value: "abcdefghijkl"}}, + {name: "home line", got: applyMultilineState("abc\nde\nfghi", 5, []string{key.Home[0]}), want: State{Cursor: 4, Value: "abc\nde\nfghi"}}, + {name: "end line", got: applyMultilineState("abc\nde\nfghi", 5, []string{key.End[0]}), want: State{Cursor: 6, Value: "abc\nde\nfghi"}}, + {name: "ctrl-u line", got: applyMultilineState("abc\nde\nfghi", 9, []string{key.First(key.CtrlU)}), want: State{Cursor: 7, Value: "abc\nde\nhi"}}, + } + + for _, test := range tests { + if test.got != test.want { + t.Fatalf("%s = %#v, want %#v", test.name, test.got, test.want) + } + } + + if got := applyMultilineState("abc\nde\nfghi", 9, []string{key.First(key.CtrlA), "X", key.First(key.CtrlE), "Y"}); got != (State{Cursor: 13, Value: "abc\nde\nXfghiY"}) { + t.Fatalf("ctrl-a/ctrl-e edit = %#v", got) + } +} + +func TestVisibleLineWindow(t *testing.T) { + got := VisibleLineWindowFor("A\nB\nC", 5, 2) + want := VisibleLineWindow{Lines: []string{"B", "C"}, Start: 1, Total: 3} + if !reflect.DeepEqual(got, want) { + t.Fatalf("VisibleLineWindowFor() = %#v, want %#v", got, want) + } + + got = VisibleLineWindowFor("abcdefghijkl", 12, 2, 5) + want = VisibleLineWindow{Lines: []string{"fghij", "kl"}, Start: 1, Total: 3} + if !reflect.DeepEqual(got, want) { + t.Fatalf("VisibleLineWindowFor(wrapped) = %#v, want %#v", got, want) + } +} diff --git a/provision/go/package.json b/provision/go/package.json new file mode 100644 index 00000000..a12205f2 --- /dev/null +++ b/provision/go/package.json @@ -0,0 +1,10 @@ +{ + "name": "@ollin/shell", + "version": "0.1.0", + "private": true, + "scripts": { + "build": "mkdir -p ../.cache/bin ../.cache/go-build ../.cache/go-mod ../.cache/go-tmp && GOCACHE=$PWD/../.cache/go-build GOMODCACHE=$PWD/../.cache/go-mod GOTMPDIR=$PWD/../.cache/go-tmp go build -o ../.cache/bin/shell ./portal", + "test": "mkdir -p ../.cache/go-build ../.cache/go-mod ../.cache/go-tmp && GOCACHE=$PWD/../.cache/go-build GOMODCACHE=$PWD/../.cache/go-mod GOTMPDIR=$PWD/../.cache/go-tmp go test ./...", + "typecheck": "mkdir -p ../.cache/go-build ../.cache/go-mod ../.cache/go-tmp && GOCACHE=$PWD/../.cache/go-build GOMODCACHE=$PWD/../.cache/go-mod GOTMPDIR=$PWD/../.cache/go-tmp go test ./... -run '^$'" + } +} diff --git a/provision/go/portal/config.go b/provision/go/portal/config.go new file mode 100644 index 00000000..9fc40811 --- /dev/null +++ b/provision/go/portal/config.go @@ -0,0 +1,157 @@ +package main + +import ( + "errors" + "fmt" + "io" + "os" + "path/filepath" + stdstrings "strings" + + "github.com/spf13/pflag" + "github.com/spf13/viper" +) + +const ( + defaultCommand = "home" + versionCommand = "version" + formCommand = "form" +) + +type portalConfig struct { + Command string + ProjectDefault string + RuntimeDefault string + CreateDefault bool + Interactive bool +} + +func loadPortalConfig(args []string) (portalConfig, error) { + settings := viper.New() + settings.SetEnvPrefix("OLLIN_SHELL") + settings.SetEnvKeyReplacer(stdstrings.NewReplacer(".", "_", "-", "_")) + settings.AutomaticEnv() + + settings.SetDefault("command", defaultCommand) + settings.SetDefault("form.project", "") + settings.SetDefault("form.runtime", "Go") + settings.SetDefault("form.create", true) + settings.SetDefault("interactive", true) + + flags := pflag.NewFlagSet("shell", pflag.ContinueOnError) + flags.SetOutput(io.Discard) + flags.String("config", "", "Path to a shell config file.") + flags.String("command", "", "Command to run.") + flags.String("project", "", "Default project name for the form command.") + flags.String("runtime", "", "Default runtime for the form command.") + flags.Bool("create", true, "Default create confirmation for the form command.") + flags.Bool("interactive", true, "Enable interactive prompts.") + flags.Bool("version", false, "Print the shell version.") + + if err := flags.Parse(args); err != nil { + return portalConfig{}, err + } + + if err := settings.BindPFlag("config", flags.Lookup("config")); err != nil { + return portalConfig{}, err + } + if err := settings.BindPFlag("command", flags.Lookup("command")); err != nil { + return portalConfig{}, err + } + if err := settings.BindPFlag("form.project", flags.Lookup("project")); err != nil { + return portalConfig{}, err + } + if err := settings.BindPFlag("form.runtime", flags.Lookup("runtime")); err != nil { + return portalConfig{}, err + } + if err := settings.BindPFlag("form.create", flags.Lookup("create")); err != nil { + return portalConfig{}, err + } + if err := settings.BindPFlag("interactive", flags.Lookup("interactive")); err != nil { + return portalConfig{}, err + } + + if configPath := settings.GetString("config"); configPath != "" { + settings.SetConfigFile(configPath) + if !hasKnownConfigExtension(configPath) { + settings.SetConfigType("yaml") + } + if err := settings.ReadInConfig(); err != nil { + return portalConfig{}, err + } + } else { + settings.SetConfigName("shell") + settings.SetConfigType("yaml") + settings.AddConfigPath(".") + if home, err := os.UserHomeDir(); err == nil { + settings.AddConfigPath(home + "/.config/ollin") + } + if err := settings.ReadInConfig(); err != nil { + var notFound viper.ConfigFileNotFoundError + if !errors.As(err, ¬Found) { + return portalConfig{}, err + } + } + } + + if flags.Changed("version") && settings.GetBool("version") { + settings.Set("command", versionCommand) + } + + if command := firstPositionalCommand(flags.Args()); command != "" && !flags.Changed("command") { + settings.Set("command", command) + } + + config := portalConfig{ + Command: normalizeCommand(settings.GetString("command")), + ProjectDefault: settings.GetString("form.project"), + RuntimeDefault: settings.GetString("form.runtime"), + CreateDefault: settings.GetBool("form.create"), + Interactive: settings.GetBool("interactive"), + } + if err := config.validate(); err != nil { + return portalConfig{}, err + } + + return config, nil +} + +func firstPositionalCommand(args []string) string { + if len(args) == 0 { + return "" + } + + return args[0] +} + +func normalizeCommand(command string) string { + command = stdstrings.TrimSpace(stdstrings.ToLower(command)) + if command == "" { + return defaultCommand + } + + return command +} + +func hasKnownConfigExtension(path string) bool { + switch stdstrings.ToLower(filepath.Ext(path)) { + case ".json", ".toml", ".yaml", ".yml", ".env", ".hcl", ".ini", ".properties", ".props", ".prop": + return true + default: + return false + } +} + +func (config portalConfig) validate() error { + switch config.Command { + case defaultCommand, versionCommand, formCommand: + default: + return fmt.Errorf("unknown command %q", config.Command) + } + + if config.RuntimeDefault != "Go" && config.RuntimeDefault != "TypeScript" { + return fmt.Errorf("unsupported runtime %q", config.RuntimeDefault) + } + + return nil +} diff --git a/provision/go/portal/main.go b/provision/go/portal/main.go new file mode 100644 index 00000000..78c6f1a3 --- /dev/null +++ b/provision/go/portal/main.go @@ -0,0 +1,89 @@ +package main + +import ( + "fmt" + "os" + + tui "github.com/oullin/tui" + "golang.org/x/term" +) + +func main() { + if err := run(os.Args[1:]); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func run(args []string) error { + config, err := loadPortalConfig(args) + if err != nil { + return err + } + + if config.Command == versionCommand { + fmt.Println("@ollin/shell 0.1.0") + return nil + } + + configureStdioEnvironment(config) + + if config.Command == formCommand { + return runFormCommand(config) + } + + tui.Intro("Oullin TUI shell") + tui.Info("Commands: version, form") + return nil +} + +func configureStdioEnvironment(config portalConfig) { + interactive := config.Interactive + tui.Configure(tui.Patch{ + Input: tui.NewStdioInput(os.Stdin, os.Stdout), + Output: tui.NewWriterOutput(os.Stdout), + ErrorOutput: tui.NewWriterOutput(os.Stderr), + Interactive: &interactive, + }) +} + +func runFormCommand(config portalConfig) error { + restore, err := enableRawInput() + if err != nil { + return err + } + defer restore() + + responses, err := tui.Form(). + Text(tui.TextOptions{Message: "Project name", Default: config.ProjectDefault, Required: tui.Required{Enabled: true}}). + Select(tui.ChoiceOptions{Message: "Runtime", Choices: tui.NewStringChoices("Go", "TypeScript"), Default: config.RuntimeDefault, HasDefault: true}). + Confirm(tui.ConfirmOptions{Message: "Create project?", Default: config.CreateDefault, HasDefault: true}). + Submit() + if err != nil { + return err + } + + tui.Table([]string{"Field", "Value"}, [][]any{ + {"Project", responses.At(0)}, + {"Runtime", responses.At(1)}, + {"Create", responses.At(2)}, + }) + + return nil +} + +func enableRawInput() (func(), error) { + file := os.Stdin + if !term.IsTerminal(int(file.Fd())) { + return func() {}, nil + } + + state, err := term.MakeRaw(int(file.Fd())) + if err != nil { + return nil, err + } + + return func() { + _ = term.Restore(int(file.Fd()), state) + }, nil +} diff --git a/provision/go/portal/main_test.go b/provision/go/portal/main_test.go new file mode 100644 index 00000000..9baadaa4 --- /dev/null +++ b/provision/go/portal/main_test.go @@ -0,0 +1,104 @@ +package main + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestPortalVersionCommand(t *testing.T) { + output := runPortal(t, nil, "version") + + if strings.TrimSpace(output) != "@ollin/shell 0.1.0" { + t.Fatalf("version output = %q", output) + } +} + +func TestPortalDefaultCommandShowsRealCommands(t *testing.T) { + output := runPortal(t, nil) + + if !strings.Contains(output, "Oullin TUI shell") || !strings.Contains(output, "Commands: version, form") { + t.Fatalf("default output = %q", output) + } +} + +func TestPortalFormCommandUsesRealStdio(t *testing.T) { + output := runPortal(t, []byte("Ada\n\n\n"), "form") + + for _, want := range []string{"Project name", "Runtime", "Create project?", "Project", "Ada", "Runtime", "Go", "Create", "true"} { + if !strings.Contains(output, want) { + t.Fatalf("form output missing %q:\n%s", want, output) + } + } +} + +func TestPortalBindsViperFlagsIntoFormDefaults(t *testing.T) { + output := runPortal(t, []byte("\n\n\n"), "form", "--project", "Flag App", "--runtime", "TypeScript", "--create=false") + + for _, want := range []string{"Flag App", "TypeScript", "false"} { + if !strings.Contains(output, want) { + t.Fatalf("flag-backed form output missing %q:\n%s", want, output) + } + } +} + +func TestPortalBindsViperEnvIntoCommandAndDefaults(t *testing.T) { + output := runPortalWithEnv(t, []byte("\n\n\n"), []string{ + "OLLIN_SHELL_COMMAND=form", + "OLLIN_SHELL_FORM_PROJECT=Env App", + "OLLIN_SHELL_FORM_RUNTIME=TypeScript", + "OLLIN_SHELL_FORM_CREATE=false", + }) + + for _, want := range []string{"Env App", "TypeScript", "false"} { + if !strings.Contains(output, want) { + t.Fatalf("env-backed form output missing %q:\n%s", want, output) + } + } +} + +func TestPortalReadsViperConfigFile(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "shell") + err := os.WriteFile(configPath, []byte(strings.Join([]string{ + "command: form", + "form:", + " project: Config App", + " runtime: TypeScript", + " create: false", + "", + }, "\n")), 0o600) + if err != nil { + t.Fatal(err) + } + + output := runPortal(t, []byte("\n\n\n"), "--config", configPath) + + for _, want := range []string{"Config App", "TypeScript", "false"} { + if !strings.Contains(output, want) { + t.Fatalf("config-backed form output missing %q:\n%s", want, output) + } + } +} + +func runPortal(t *testing.T, stdin []byte, args ...string) string { + return runPortalWithEnv(t, stdin, nil, args...) +} + +func runPortalWithEnv(t *testing.T, stdin []byte, env []string, args ...string) string { + t.Helper() + + commandArgs := append([]string{"run", "."}, args...) + command := exec.Command("go", commandArgs...) + command.Stdin = bytes.NewReader(stdin) + command.Env = append(os.Environ(), env...) + + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("go run . %s failed: %v\n%s", strings.Join(args, " "), err, output) + } + + return string(output) +} diff --git a/provision/ts/package.json b/provision/ts/package.json new file mode 100644 index 00000000..c1231910 --- /dev/null +++ b/provision/ts/package.json @@ -0,0 +1,17 @@ +{ + "name": "@ollin/provision-ts", + "version": "0.1.0", + "private": true, + "type": "module", + "imports": { + "#provision-ts/*": "./src/*.ts" + }, + "scripts": { + "build": "XDG_CACHE_HOME=../.cache/xdg PLAYWRIGHT_BROWSERS_PATH=../.cache/playwright tsc -p tsconfig.json --noEmit", + "test": "node -e \"\"", + "typecheck": "XDG_CACHE_HOME=../.cache/xdg PLAYWRIGHT_BROWSERS_PATH=../.cache/playwright tsc -p tsconfig.json --noEmit" + }, + "devDependencies": { + "@ollin/tsconfig": "workspace:*" + } +} diff --git a/provision/ts/src/cache.ts b/provision/ts/src/cache.ts new file mode 100644 index 00000000..1b9c151f --- /dev/null +++ b/provision/ts/src/cache.ts @@ -0,0 +1,26 @@ +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const provisionRoot = dirname(dirname(fileURLToPath(import.meta.url))); + +export const provisionPath = (...segments: string[]): string => resolve(provisionRoot, ...segments); + +export const toolCachePath = (...segments: string[]): string => provisionPath('.cache', ...segments); + +export const toolBinCachePath = (...segments: string[]): string => toolCachePath('bin', ...segments); + +export const goBuildCachePath = (...segments: string[]): string => toolCachePath('go-build', ...segments); + +export const goModCachePath = (...segments: string[]): string => toolCachePath('go-mod', ...segments); + +export const goTmpCachePath = (...segments: string[]): string => toolCachePath('go-tmp', ...segments); + +export const playwrightCachePath = (...segments: string[]): string => toolCachePath('playwright', ...segments); + +export const turboCachePath = (...segments: string[]): string => toolCachePath('turbo', ...segments); + +export const vitestCachePath = (...segments: string[]): string => toolCachePath('vitest', ...segments); + +export const tsBuildCachePath = (...segments: string[]): string => toolCachePath('tsbuild', ...segments); + +export const xdgCachePath = (...segments: string[]): string => toolCachePath('xdg', ...segments); diff --git a/provision/ts/src/index.ts b/provision/ts/src/index.ts new file mode 100644 index 00000000..c32d8a30 --- /dev/null +++ b/provision/ts/src/index.ts @@ -0,0 +1,14 @@ +export { + goBuildCachePath, + goModCachePath, + goTmpCachePath, + playwrightCachePath, + provisionPath, + provisionRoot, + toolBinCachePath, + toolCachePath, + tsBuildCachePath, + turboCachePath, + vitestCachePath, + xdgCachePath, +} from '#provision-ts/cache'; diff --git a/provision/ts/tsconfig.json b/provision/ts/tsconfig.json new file mode 100644 index 00000000..3d1f5420 --- /dev/null +++ b/provision/ts/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "@ollin/tsconfig/base.json", + "compilerOptions": { + "baseUrl": ".", + "tsBuildInfoFile": "../.cache/tsbuild/provision-ts.tsbuildinfo", + "paths": { + "#provision-ts/*": ["./src/*"] + } + }, + "include": ["src/**/*.ts"] +} diff --git a/turbo.json b/turbo.json new file mode 100644 index 00000000..7744ae79 --- /dev/null +++ b/turbo.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://turbo.build/schema.json", + "tasks": { + "build": { + "dependsOn": ["^build"], + "outputs": ["dist/**", "src/.vitepress/dist/**", "src/api/**", "$TURBO_ROOT$/provision/.cache/bin/**"] + }, + "test": { + "dependsOn": ["^test"], + "outputs": [], + "inputs": ["src/**", "tests/**", "vitest.config.ts", "package.json", "tsconfig.json"] + }, + "typecheck": { + "dependsOn": ["^typecheck"], + "outputs": [] + } + } +}