diff --git a/.gitignore b/.gitignore index 59fd5465..53224dfb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,14 +1,27 @@ +# infra/.cache is the only mutable tool-cache root (see infra/README.md). +# Every cache/build artifact below is generated and deliberately ignored; +# nothing cache-related should ever live outside infra/.cache. node_modules/ + +# Tool caches & build output (all redirected into infra/.cache) .turbo/ **/.turbo/ .cache/ **/.cache/ -provision/.cache/ -.DS_Store +infra/.cache/ coverage/ *.tsbuildinfo -packages/artefacts/.logs/ -packages/artefacts/dist/ -packages/tui/dist/ -packages/docs/src/.vitepress/ -packages/docs/src/api/ +**/dist/ +**/.vitepress/cache/ +**/.vitepress/dist/ +apps/docs/src/.vitepress/ +apps/docs/src/api/ + +# OS / editor +.DS_Store +.idea/ + +# Go +pkgs-go/bin/ +*.test +*.out diff --git a/.npmrc b/.npmrc index 94c622a1..d2986d71 100644 --- a/.npmrc +++ b/.npmrc @@ -1 +1 @@ -store-dir=provision/.cache/pnpm/store +store-dir=infra/.cache/pnpm-store diff --git a/Makefile b/Makefile index 87959dc6..c6fab2e3 100644 --- a/Makefile +++ b/Makefile @@ -1,70 +1,19 @@ 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' +INFRA_MAKEFILE := infra/make/Makefile .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 - +# Every real target lives in infra/make. This root Makefile is a transparent +# forwarder so existing `make ` commands keep working. +.PHONY: help FORCE 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) + @$(MAKE) --no-print-directory -f $(INFRA_MAKEFILE) help -##@ Formatting -format: ## Format + lint untracked + modified TS/Vue files via oxfmt/oxlint - $(call run_oxfmt,--others --modified) +Makefile: + @: -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 +%: FORCE + @$(MAKE) --no-print-directory -f $(INFRA_MAKEFILE) $@ -##@ 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) +FORCE: diff --git a/README.md b/README.md index 5e0fa2ae..b6c4c832 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# `@ollin/tui` +# `@ollin/console` An idiomatic TypeScript/OpenTUI prompt toolkit. @@ -7,14 +7,14 @@ This repository targets behavioral parity with reference prompt toolkit release ## Status -The current codebase is a TypeScript ESM package with the public `@ollin/tui` +The current codebase is a TypeScript ESM package with the public `@ollin/console` 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 +consumption through `pkgs-ts/tests`. The docs package builds a VitePress guide plus generated API reference. Reference parity and the completion audit are tracked in `UPSTREAM.md`. diff --git a/UPSTREAM.md b/UPSTREAM.md index bf3e2130..05a554bf 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -11,214 +11,214 @@ TypeScript and OpenTUI internals. | 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/*` | +| Helper functions | `pkgs-ts/console/src/index.ts` | +| Prompt lifecycle and fallback behavior | `pkgs-ts/console/src/prompt.ts`, `pkgs-ts/console/src/environment.ts`, `pkgs-ts/console/src/environment/*` | +| Prompt fallback reset, predicate condition validation, scoped lifecycle, and predicate condition lifecycle | `pkgs-ts/console/src/prompt/fallback.ts`, `pkgs-ts/console/src/prompt/validators/fallback.ts`, `pkgs-ts/console/src/environment/scope.ts`, `pkgs-ts/console/src/prompt.ts`, `pkgs-ts/console/src/index.ts` | +| Prompt cancellation callback scoped lifecycle | `pkgs-ts/console/src/prompt/cancel.ts`, `pkgs-ts/console/src/environment/scope.ts`, `pkgs-ts/console/tests/prompt.test.ts` | +| Prompt revert rejection lifecycle | `pkgs-ts/console/src/prompt/revert.ts`, `pkgs-ts/console/src/form/builder/revert.ts`, `pkgs-ts/console/src/prompts/select/read-confirm.ts`, `pkgs-ts/console/tests/form.test.ts` | +| Prompt validation retry default lifecycle and parser-error submitted-value preservation | `pkgs-ts/console/src/prompt/lifecycle.ts`, `pkgs-ts/console/src/prompt/lifecycle/*`, `pkgs-ts/console/src/prompt/error.ts`, `pkgs-ts/console/tests/text.test.ts` | +| Prompt post-validation submitted-frame lifecycle | `pkgs-ts/console/src/prompt/lifecycle.ts`, `pkgs-ts/console/src/prompt/lifecycle/*`, `pkgs-ts/console/src/prompts/text.ts`, `pkgs-ts/console/src/prompts/text/*`, `pkgs-ts/console/src/prompts/number.ts`, `pkgs-ts/console/tests/text.test.ts`, `pkgs-ts/console/tests/number.test.ts` | +| Prompt invalid-attempt active-frame cleanup lifecycle | `pkgs-ts/console/src/prompt/lifecycle.ts`, `pkgs-ts/console/src/prompt/lifecycle/*`, `pkgs-ts/console/src/prompt/active-frame.ts`, `pkgs-ts/console/src/prompts/*`, `pkgs-ts/console/src/output/data-table.ts` | +| Prompt non-interactive raw-default validation lifecycle | `pkgs-ts/console/src/prompt/lifecycle.ts`, `pkgs-ts/console/src/prompt/lifecycle/*` | +| Prompt validation error styling | `pkgs-ts/console/src/theme.ts`, `pkgs-ts/console/tests/prompt.test.ts` | +| Prompt validation warning line styling | `pkgs-ts/console/src/theme.ts`, `pkgs-ts/console/src/prompt/lifecycle.ts` | +| Prompt local and scoped global validation composition | `pkgs-ts/console/src/prompt/validation.ts`, `pkgs-ts/console/src/prompt/validate-using.ts`, `pkgs-ts/console/src/environment/scope.ts` | +| Key constants, value validation, and event mapping | `pkgs-ts/console/src/key.ts`, `pkgs-ts/console/src/key/*` | +| Terminal helpers | `pkgs-ts/console/src/terminal.ts` | +| Text input | `pkgs-ts/console/src/prompts/text.ts`, `pkgs-ts/console/src/typed-value.ts` | +| Text input defaults, transforms, validation, editing keys, non-interactive behavior, and failed reads | `pkgs-ts/console/src/prompts/text.ts`, `pkgs-ts/console/src/prompts/text/*`, `pkgs-ts/console/src/prompt/lifecycle.ts`, `pkgs-ts/console/src/typed-value.ts`, `pkgs-ts/console/src/typed-value/read/*`, `pkgs-ts/console/tests/text.test.ts` | +| Text active, submitted, cancelled, placeholder, and validation-safe rendering | `pkgs-ts/console/src/prompt/submission.ts`, `pkgs-ts/console/src/prompts/text.ts`, `pkgs-ts/console/src/prompts/text/*`, `pkgs-ts/console/src/typed-value.ts`, `pkgs-ts/console/src/typed-value/read/*`, `pkgs-ts/console/src/typed-value/render.ts`, `pkgs-ts/console/tests/text.test.ts` | +| Text active-frame redraw and submitted-frame replacement | `pkgs-ts/console/src/typed-value.ts`, `pkgs-ts/console/src/typed-value/render.ts`, `pkgs-ts/console/src/prompts/text.ts`, `pkgs-ts/console/src/prompts/text/*` | +| Text transformed fallback value, invalid retry default preservation, overload validation, and plain placeholder rendering | `pkgs-ts/console/src/prompts/text.ts`, `pkgs-ts/console/src/prompts/text/*`, `pkgs-ts/console/src/prompts/text-default.ts`, `pkgs-ts/console/src/concerns/validators/text-options.ts`, `pkgs-ts/console/src/typed-value/render.ts` | +| Textarea input | `pkgs-ts/console/src/prompts/textarea.ts`, `pkgs-ts/console/src/typed-value.ts` | +| Textarea defaults, transforms, validation, editing keys, line navigation, and non-interactive behavior | `pkgs-ts/console/src/prompts/textarea.ts`, `pkgs-ts/console/src/prompt/lifecycle.ts`, `pkgs-ts/console/src/typed-value.ts`, `pkgs-ts/console/src/typed-value/read/*`, `pkgs-ts/console/tests/textarea.test.ts` | +| Textarea active, submitted, cancelled, scrollbar, placeholder, and validation-safe rendering | `pkgs-ts/console/src/prompt/submission.ts`, `pkgs-ts/console/src/prompts/textarea.ts`, `pkgs-ts/console/src/typed-value.ts`, `pkgs-ts/console/src/typed-value/read/*`, `pkgs-ts/console/src/typed-value/textarea-frame.ts`, `pkgs-ts/console/tests/textarea.test.ts` | +| Textarea active-frame redraw and cursor-aware visible window rendering | `pkgs-ts/console/src/typed-value.ts`, `pkgs-ts/console/src/typed-value/lines.ts`, `pkgs-ts/console/src/typed-value/lines/*`, `pkgs-ts/console/src/typed-value/line-ranges.ts`, `pkgs-ts/console/src/typed-value/textarea-frame.ts`, `pkgs-ts/console/src/prompts/textarea.ts` | +| Textarea transformed fallback value, invalid retry default preservation, overload validation, and plain placeholder rendering | `pkgs-ts/console/src/prompts/textarea.ts`, `pkgs-ts/console/src/prompts/text-default.ts`, `pkgs-ts/console/src/concerns/validators/text-options.ts`, `pkgs-ts/console/src/typed-value/textarea-frame.ts` | +| Textarea control-key line navigation and empty-line movement | `pkgs-ts/console/src/typed-value/edit/navigation.ts`, `pkgs-ts/console/tests/textarea.test.ts` | +| Textarea boxed renderer and placeholder styling | `pkgs-ts/console/src/typed-value/textarea-frame.ts`, `pkgs-ts/console/tests/textarea.test.ts` | +| Textarea scrollbar rendering | `pkgs-ts/console/src/typed-value/textarea-frame.ts`, `pkgs-ts/console/src/typed-value/lines.ts`, `pkgs-ts/console/src/typed-value/lines/*`, `pkgs-ts/console/src/typed-value/line-ranges.ts`, `pkgs-ts/console/tests/textarea.test.ts` | +| Textarea wrapped frame line windows | `pkgs-ts/console/src/typed-value/textarea-frame.ts`, `pkgs-ts/console/src/typed-value/lines.ts`, `pkgs-ts/console/src/typed-value/lines/*`, `pkgs-ts/console/src/typed-value/line-ranges.ts`, `pkgs-ts/console/tests/textarea.test.ts`, `pkgs-ts/console/tests/typed-value.test.ts` | +| Textarea wrapped-line cursor navigation | `pkgs-ts/console/src/typed-value.ts`, `pkgs-ts/console/src/typed-value/edit.ts`, `pkgs-ts/console/src/typed-value/edit/navigation.ts`, `pkgs-ts/console/src/typed-value/lines.ts`, `pkgs-ts/console/src/typed-value/lines/*`, `pkgs-ts/console/src/typed-value/line-ranges.ts`, `pkgs-ts/console/tests/textarea.test.ts`, `pkgs-ts/console/tests/typed-value.test.ts` | +| Password input | `pkgs-ts/console/src/prompts/password.ts`, `pkgs-ts/console/src/typed-value.ts` | +| Password input transforms, validation retry, editing keys, cancellation, masking, and non-interactive behavior | `pkgs-ts/console/src/prompts/password.ts`, `pkgs-ts/console/src/prompts/password/options.ts`, `pkgs-ts/console/src/prompts/password/run.ts`, `pkgs-ts/console/src/prompts/password/input.ts`, `pkgs-ts/console/src/prompts/password/fallback.ts`, `pkgs-ts/console/src/prompts/password/session.ts`, `pkgs-ts/console/src/prompt/lifecycle.ts`, `pkgs-ts/console/tests/password.test.ts` | +| Password active, submitted, cancelled, placeholder, masked, and validation-safe rendering | `pkgs-ts/console/src/prompt/submission.ts`, `pkgs-ts/console/src/prompts/password.ts`, `pkgs-ts/console/src/prompts/password/run.ts`, `pkgs-ts/console/src/prompts/password/input.ts`, `pkgs-ts/console/src/prompts/password/session.ts`, `pkgs-ts/console/src/prompts/password/render.ts`, `pkgs-ts/console/tests/password.test.ts` | +| Password active-frame redraw and masked cursor rendering | `pkgs-ts/console/src/prompts/password.ts`, `pkgs-ts/console/src/prompts/password/run.ts`, `pkgs-ts/console/src/prompts/password/input.ts`, `pkgs-ts/console/src/prompts/password/session.ts`, `pkgs-ts/console/src/prompts/password/render.ts` | +| Password transformed fallback value, invalid retry default preservation, overload validation, and plain placeholder rendering | `pkgs-ts/console/src/prompts/password.ts`, `pkgs-ts/console/src/prompts/password/options.ts`, `pkgs-ts/console/src/prompts/password/run.ts`, `pkgs-ts/console/src/prompts/text-default.ts`, `pkgs-ts/console/src/concerns/validators/text-options.ts`, `pkgs-ts/console/src/prompts/password/render.ts` | +| Number input | `pkgs-ts/console/src/prompts/number.ts`, `pkgs-ts/console/src/typed-value.ts` | +| Number active, submitted, cancelled, arrow, placeholder, default, and validation-safe rendering | `pkgs-ts/console/src/prompt/submission.ts`, `pkgs-ts/console/src/prompts/number.ts`, `pkgs-ts/console/src/prompts/number/input.ts`, `pkgs-ts/console/src/prompts/number/interactive.ts`, `pkgs-ts/console/src/prompts/number/session.ts`, `pkgs-ts/console/src/prompts/number/session/*`, `pkgs-ts/console/src/prompts/number/render.ts`, `pkgs-ts/console/tests/number.test.ts` | +| Number default renderer inline arrow controls | `pkgs-ts/console/src/prompts/number/render.ts` | +| Number active-frame redraw and cursor rendering | `pkgs-ts/console/src/prompts/number.ts`, `pkgs-ts/console/src/prompts/number/input.ts`, `pkgs-ts/console/src/prompts/number/interactive.ts`, `pkgs-ts/console/src/prompts/number/session.ts`, `pkgs-ts/console/src/prompts/number/session/*`, `pkgs-ts/console/src/prompts/number/render.ts` | +| Number explicit-default lifecycle, invalid retry default preservation, parser-error retry preservation, overload validation, and transformed fallback value | `pkgs-ts/console/src/prompts/number.ts`, `pkgs-ts/console/src/prompts/number/defaults.ts`, `pkgs-ts/console/src/prompts/number/input.ts`, `pkgs-ts/console/src/prompts/number/fallback.ts`, `pkgs-ts/console/src/prompts/number/validators/overload.ts`, `pkgs-ts/console/src/validators/default.ts` | +| Confirm, select, multiselect | `pkgs-ts/console/src/prompts/select/*` | +| Confirm active, submitted, cancelled, custom-label, fallback, and non-interactive behavior | `pkgs-ts/console/src/prompts/select/read-confirm.ts`, `pkgs-ts/console/src/prompts/select/read-confirm/*`, `pkgs-ts/console/src/prompts/select/render-confirm.ts`, `pkgs-ts/console/tests/choices.test.ts` | +| Confirm submitted frame renders only after validation passes | `pkgs-ts/console/src/prompt/submission.ts`, `pkgs-ts/console/src/prompts/select/confirm.ts`, `pkgs-ts/console/src/prompts/select/confirm-run.ts`, `pkgs-ts/console/src/prompts/select/read-confirm.ts`, `pkgs-ts/console/src/prompts/select/read-confirm/*`, `pkgs-ts/console/tests/choices.test.ts` | +| Confirm explicit-default lifecycle, invalid retry default preservation, overload validation, and transformed fallback value | `pkgs-ts/console/src/prompts/select/confirm.ts`, `pkgs-ts/console/src/prompts/select/confirm-options.ts`, `pkgs-ts/console/src/prompts/select/confirm-run.ts`, `pkgs-ts/console/src/prompts/select/read-confirm.ts`, `pkgs-ts/console/src/prompts/select/read-confirm/*`, `pkgs-ts/console/src/prompts/select/validators/overload.ts`, `pkgs-ts/console/src/validators/default.ts` | +| Confirm active-frame redraw and submitted-frame replacement | `pkgs-ts/console/src/prompts/select/confirm.ts`, `pkgs-ts/console/src/prompts/select/confirm-run.ts`, `pkgs-ts/console/src/prompts/select/read-confirm.ts`, `pkgs-ts/console/src/prompts/select/read-confirm/*`, `pkgs-ts/console/src/prompts/select/render-confirm.ts` | +| Confirm active and cancelled body styling | `pkgs-ts/console/src/prompts/select/render-confirm.ts` | +| Select active boxed layout and row styling | `pkgs-ts/console/src/concerns/choice-label.ts`, `pkgs-ts/console/src/theme/choice-disabled.ts`, `pkgs-ts/console/src/theme/validators/disabled.ts`, `pkgs-ts/console/src/prompts/select/render.ts`, `pkgs-ts/console/src/prompts/select/render-rows.ts`, `pkgs-ts/console/src/prompts/select/render-rows/*`, `pkgs-ts/console/tests/choices.test.ts` | +| Select and multi-select label-first helper argument surface with info | `pkgs-ts/console/src/prompts/select/index.ts`, `pkgs-ts/console/tests/choices.test.ts` | +| Select required-option validation | `pkgs-ts/console/src/prompts/select/validators/options.ts`, `pkgs-ts/console/src/prompts/select/index.ts`, `pkgs-ts/console/src/prompts/select/select.ts`, `pkgs-ts/console/src/prompts/select/options.ts`, `pkgs-ts/console/src/prompts/select/run.ts`, `pkgs-ts/console/tests/choices.test.ts` | +| Select non-interactive default validation | `pkgs-ts/console/src/prompts/select/index.ts`, `pkgs-ts/console/src/prompts/select/select.ts`, `pkgs-ts/console/src/prompts/select/options.ts`, `pkgs-ts/console/src/prompts/select/run.ts`, `pkgs-ts/console/tests/choices.test.ts` | +| Select explicit-default lifecycle, invalid retry default preservation, overload validation, and transformed fallback value | `pkgs-ts/console/src/prompts/select/index.ts`, `pkgs-ts/console/src/prompts/select/select.ts`, `pkgs-ts/console/src/prompts/select/defaults.ts`, `pkgs-ts/console/src/prompts/select/read-selected.ts`, `pkgs-ts/console/src/prompts/select/read-selected/interactive.ts`, `pkgs-ts/console/src/prompts/select/read-selected/result.ts`, `pkgs-ts/console/src/prompts/select/validators/overload.ts`, `pkgs-ts/console/src/concerns/choices.ts`, `pkgs-ts/console/src/concerns/choice-match.ts`, `pkgs-ts/console/src/validators/default.ts` | +| Select line-mode and exhausted-input default fallback with validation retry | `pkgs-ts/console/src/prompts/select/index.ts`, `pkgs-ts/console/src/prompts/select/select.ts`, `pkgs-ts/console/src/prompts/select/read-selected.ts`, `pkgs-ts/console/src/prompts/select/read-selected/interactive.ts`, `pkgs-ts/console/src/prompts/select/read-selected/result.ts`, `pkgs-ts/console/tests/choices.test.ts` | +| Select submitted final frame | `pkgs-ts/console/src/prompts/select/render.ts`, `pkgs-ts/console/src/prompts/select/read-selected.ts`, `pkgs-ts/console/src/prompts/select/read-selected/interactive.ts`, `pkgs-ts/console/src/prompts/select/read-selected/interactive/*`, `pkgs-ts/console/tests/choices.test.ts` | +| Select submitted frame renders only after validation passes | `pkgs-ts/console/src/prompt/submission.ts`, `pkgs-ts/console/src/prompts/select/index.ts`, `pkgs-ts/console/src/prompts/select/select.ts`, `pkgs-ts/console/src/prompts/select/run.ts`, `pkgs-ts/console/src/prompts/select/read-selected.ts`, `pkgs-ts/console/src/prompts/select/read-selected/interactive.ts`, `pkgs-ts/console/src/prompts/select/read-selected/interactive/*`, `pkgs-ts/console/src/prompts/select/read-selected/result.ts`, `pkgs-ts/console/tests/choices.test.ts` | +| Select active-frame redraw and submitted-frame replacement | `pkgs-ts/console/src/prompts/select/index.ts`, `pkgs-ts/console/src/prompts/select/select.ts`, `pkgs-ts/console/src/prompts/select/run.ts`, `pkgs-ts/console/src/prompts/select/read-selected.ts`, `pkgs-ts/console/src/prompts/select/read-selected/interactive.ts`, `pkgs-ts/console/src/prompts/select/read-selected/interactive/*`, `pkgs-ts/console/src/prompts/select/render.ts` | +| Select cancel final frame | `pkgs-ts/console/src/prompts/select/render.ts`, `pkgs-ts/console/src/prompts/select/read-selected.ts`, `pkgs-ts/console/src/prompts/select/read-selected/interactive.ts`, `pkgs-ts/console/src/prompts/select/read-selected/interactive/*`, `pkgs-ts/console/tests/choices.test.ts` | +| Select cancelled frame row styling | `pkgs-ts/console/src/prompts/select/render.ts`, `pkgs-ts/console/src/prompts/select/render-rows.ts`, `pkgs-ts/console/src/prompts/select/render-rows/*` | +| Multi-select active boxed layout and row styling | `pkgs-ts/console/src/prompts/select/render.ts`, `pkgs-ts/console/src/prompts/select/render-rows.ts`, `pkgs-ts/console/src/prompts/select/render-rows/*`, `pkgs-ts/console/tests/choices.test.ts` | +| Multi-select transformed fallback value and invalid retry default preservation | `pkgs-ts/console/src/prompts/select/index.ts`, `pkgs-ts/console/src/prompts/select/multiselect.ts`, `pkgs-ts/console/src/prompts/select/multiselect/*`, `pkgs-ts/console/src/prompts/select/defaults.ts`, `pkgs-ts/console/src/prompts/select/validators/overload.ts` | +| Multi-select submitted final frame | `pkgs-ts/console/src/prompts/select/render.ts`, `pkgs-ts/console/src/prompts/select/read-multiple.ts`, `pkgs-ts/console/src/prompts/select/read-multiple/*`, `pkgs-ts/console/src/prompts/select/read-multiple/session/*`, `pkgs-ts/console/src/prompts/select/read-multiple/result.ts`, `pkgs-ts/console/tests/choices.test.ts` | +| Multi-select empty submitted final frame | `pkgs-ts/console/src/prompts/select/render.ts` | +| Multi-select submitted frame renders only after validation passes | `pkgs-ts/console/src/prompt/submission.ts`, `pkgs-ts/console/src/prompts/select/index.ts`, `pkgs-ts/console/src/prompts/select/multiselect.ts`, `pkgs-ts/console/src/prompts/select/multiselect/*`, `pkgs-ts/console/src/prompts/select/read-multiple.ts`, `pkgs-ts/console/src/prompts/select/read-multiple/*`, `pkgs-ts/console/src/prompts/select/read-multiple/session/*`, `pkgs-ts/console/src/prompts/select/read-multiple/result.ts`, `pkgs-ts/console/tests/choices.test.ts` | +| Multi-select active-frame redraw and submitted-frame replacement | `pkgs-ts/console/src/prompts/select/index.ts`, `pkgs-ts/console/src/prompts/select/multiselect.ts`, `pkgs-ts/console/src/prompts/select/multiselect/*`, `pkgs-ts/console/src/prompts/select/read-multiple.ts`, `pkgs-ts/console/src/prompts/select/read-multiple/*`, `pkgs-ts/console/src/prompts/select/read-multiple/session/*`, `pkgs-ts/console/src/prompts/select/render.ts` | +| Multi-select cancel final frame | `pkgs-ts/console/src/prompts/select/render.ts`, `pkgs-ts/console/src/prompts/select/read-multiple.ts`, `pkgs-ts/console/src/prompts/select/read-multiple/*`, `pkgs-ts/console/src/prompts/select/read-multiple/session/*`, `pkgs-ts/console/src/prompts/select/read-multiple/result.ts`, `pkgs-ts/console/tests/choices.test.ts` | +| Multi-select cancelled frame row styling | `pkgs-ts/console/src/prompts/select/render.ts`, `pkgs-ts/console/src/prompts/select/render-rows.ts`, `pkgs-ts/console/src/prompts/select/render-rows/*` | +| Suggest, autocomplete | `pkgs-ts/console/src/prompts/suggest/*` | +| Suggest active, submitted, cancelled, placeholder, info, scrolling, and validation-safe rendering | `pkgs-ts/console/src/prompt/submission.ts`, `pkgs-ts/console/src/prompts/suggest/suggest.ts`, `pkgs-ts/console/src/prompts/suggest/lifecycle.ts`, `pkgs-ts/console/src/prompts/suggest/read.ts`, `pkgs-ts/console/src/prompts/suggest/read/*`, `pkgs-ts/console/src/prompts/suggest/read/state/*`, `pkgs-ts/console/src/prompts/suggest/state/*`, `pkgs-ts/console/src/prompts/suggest/read-result.ts`, `pkgs-ts/console/src/prompts/suggest/render.ts`, `pkgs-ts/console/src/prompts/suggest/render/*`, `pkgs-ts/console/tests/suggest.test.ts` | +| Suggest active-frame redraw and cursor rendering | `pkgs-ts/console/src/prompts/suggest/suggest.ts`, `pkgs-ts/console/src/prompts/suggest/read.ts`, `pkgs-ts/console/src/prompts/suggest/read/*`, `pkgs-ts/console/src/prompts/suggest/read/state/*`, `pkgs-ts/console/src/prompts/suggest/state/*`, `pkgs-ts/console/src/prompts/suggest/read-result.ts`, `pkgs-ts/console/src/prompts/suggest/render.ts`, `pkgs-ts/console/src/prompts/suggest/render/*` | +| Suggest empty-result rendering for empty and typed queries | `pkgs-ts/console/src/prompts/suggest/render.ts`, `pkgs-ts/console/src/prompts/suggest/render/*` | +| Suggest and autocomplete transformed fallback value, invalid retry default preservation, source validation, and overload validation | `pkgs-ts/console/src/prompts/suggest/suggest.ts`, `pkgs-ts/console/src/prompts/suggest/autocomplete-prompt.ts`, `pkgs-ts/console/src/prompts/suggest/lifecycle.ts`, `pkgs-ts/console/src/prompts/suggest/validators/overload.ts`, `pkgs-ts/console/src/prompts/suggest/validators/source.ts`, `pkgs-ts/console/src/prompts/text-default.ts` | +| Suggest and autocomplete plain placeholder rendering | `pkgs-ts/console/src/prompts/suggest/render.ts`, `pkgs-ts/console/src/prompts/suggest/render-autocomplete.ts` | +| Suggest and autocomplete label-first info rendering | `pkgs-ts/console/src/prompts/suggest/suggest.ts`, `pkgs-ts/console/src/prompts/suggest/autocomplete-prompt.ts`, `pkgs-ts/console/src/prompts/suggest/options.ts`, `pkgs-ts/console/src/prompts/suggest/read-autocomplete.ts`, `pkgs-ts/console/src/prompts/suggest/read-result.ts`, `pkgs-ts/console/src/prompts/suggest/render-autocomplete.ts`, `pkgs-ts/console/tests/suggest.test.ts` | +| Autocomplete styled cursor-aware ghost text and Unicode-safe completion acceptance | `pkgs-ts/console/src/prompts/suggest/ghost-text.ts`, `pkgs-ts/console/src/prompts/suggest/autocomplete.ts`, `pkgs-ts/console/src/theme/styles.ts`, `pkgs-ts/console/tests/suggest.test.ts` | +| Autocomplete active, submitted, cancelled, placeholder, ghost-text, and validation-safe rendering | `pkgs-ts/console/src/prompt/submission.ts`, `pkgs-ts/console/src/prompts/suggest/autocomplete-prompt.ts`, `pkgs-ts/console/src/prompts/suggest/lifecycle.ts`, `pkgs-ts/console/src/prompts/suggest/read-autocomplete.ts`, `pkgs-ts/console/src/prompts/suggest/read-autocomplete/*`, `pkgs-ts/console/src/prompts/suggest/read-autocomplete/state/*`, `pkgs-ts/console/src/prompts/suggest/state/*`, `pkgs-ts/console/src/prompts/suggest/read-result.ts`, `pkgs-ts/console/src/prompts/suggest/render-autocomplete.ts`, `pkgs-ts/console/tests/suggest.test.ts` | +| Autocomplete active-frame redraw and cursor fallback rendering | `pkgs-ts/console/src/prompts/suggest/autocomplete-prompt.ts`, `pkgs-ts/console/src/prompts/suggest/read-autocomplete.ts`, `pkgs-ts/console/src/prompts/suggest/read-autocomplete/*`, `pkgs-ts/console/src/prompts/suggest/read-autocomplete/state/*`, `pkgs-ts/console/src/prompts/suggest/state/*`, `pkgs-ts/console/src/prompts/suggest/read-result.ts`, `pkgs-ts/console/src/prompts/suggest/render-autocomplete.ts`, `pkgs-ts/console/src/prompts/suggest/ghost-text.ts` | +| Search, multisearch | `pkgs-ts/console/src/prompts/search/*` | +| Search and multi-search label-first helper argument surface with info | `pkgs-ts/console/src/prompts/search/index.ts`, `pkgs-ts/console/tests/search.test.ts` | +| Search active boxed layout and row styling | `pkgs-ts/console/src/concerns/choice-label.ts`, `pkgs-ts/console/src/theme/choice-disabled.ts`, `pkgs-ts/console/src/theme/validators/disabled.ts`, `pkgs-ts/console/src/prompts/search/render.ts`, `pkgs-ts/console/src/prompts/search/render-body.ts`, `pkgs-ts/console/src/prompts/search/body/*`, `pkgs-ts/console/tests/search.test.ts` | +| Search active-frame redraw and cursor rendering | `pkgs-ts/console/src/prompts/search/index.ts`, `pkgs-ts/console/src/prompts/search/search.ts`, `pkgs-ts/console/src/prompts/search/run.ts`, `pkgs-ts/console/src/prompts/search/keys.ts`, `pkgs-ts/console/src/prompts/search/keys/*`, `pkgs-ts/console/src/prompts/search/read-single.ts`, `pkgs-ts/console/src/prompts/search/read-single/*`, `pkgs-ts/console/src/prompts/search/read-single/interactive/*`, `pkgs-ts/console/src/prompts/search/read-single/session/*`, `pkgs-ts/console/src/prompts/search/render.ts` | +| Search retry initial highlight lifecycle | `pkgs-ts/console/src/prompts/search/read-single.ts`, `pkgs-ts/console/src/prompts/search/read-single/*`, `pkgs-ts/console/src/prompts/search/read-single/state/*`, `pkgs-ts/console/src/prompts/search/navigation.ts` | +| Search and multisearch empty-result rendering | `pkgs-ts/console/src/prompts/search/render.ts`, `pkgs-ts/console/src/prompts/search/render-body.ts`, `pkgs-ts/console/src/prompts/search/body/*`, `pkgs-ts/console/tests/search.test.ts` | +| Search highlighted-result row styling | `pkgs-ts/console/src/prompts/search/render.ts`, `pkgs-ts/console/src/prompts/search/render-body.ts`, `pkgs-ts/console/src/prompts/search/body/*`, `pkgs-ts/console/src/theme/styles.ts`, `pkgs-ts/console/tests/search.test.ts` | +| Search explicit-default lifecycle, invalid retry default preservation, source validation, overload validation, and transformed fallback value | `pkgs-ts/console/src/prompts/search/index.ts`, `pkgs-ts/console/src/prompts/search/search.ts`, `pkgs-ts/console/src/prompts/search/options.ts`, `pkgs-ts/console/src/prompts/search/run.ts`, `pkgs-ts/console/src/prompts/search/defaults.ts`, `pkgs-ts/console/src/prompts/search/read-single.ts`, `pkgs-ts/console/src/prompts/search/read-single/*`, `pkgs-ts/console/src/prompts/search/read-single/state/*`, `pkgs-ts/console/src/prompts/search/read-single/result.ts`, `pkgs-ts/console/src/prompts/search/line-mode.ts`, `pkgs-ts/console/src/prompts/search/validators/overload.ts`, `pkgs-ts/console/src/prompts/search/validators/source.ts`, `pkgs-ts/console/src/concerns/choices.ts`, `pkgs-ts/console/src/concerns/choice-match.ts`, `pkgs-ts/console/src/validators/default.ts` | +| Search submitted final frame | `pkgs-ts/console/src/prompts/search/render.ts`, `pkgs-ts/console/src/prompts/search/read-single.ts`, `pkgs-ts/console/src/prompts/search/read-single/*`, `pkgs-ts/console/src/prompts/search/read-single/interactive/*`, `pkgs-ts/console/tests/search.test.ts` | +| Search submitted frame renders only after validation passes | `pkgs-ts/console/src/prompt/submission.ts`, `pkgs-ts/console/src/prompts/search/index.ts`, `pkgs-ts/console/src/prompts/search/search.ts`, `pkgs-ts/console/src/prompts/search/run.ts`, `pkgs-ts/console/src/prompts/search/read-single.ts`, `pkgs-ts/console/src/prompts/search/read-single/*`, `pkgs-ts/console/src/prompts/search/read-single/interactive/*`, `pkgs-ts/console/src/prompts/search/read-single/result.ts`, `pkgs-ts/console/tests/search.test.ts` | +| Search cancel final frame | `pkgs-ts/console/src/prompts/search/render.ts`, `pkgs-ts/console/src/prompts/search/read-single.ts`, `pkgs-ts/console/src/prompts/search/read-single/interactive/*`, `pkgs-ts/console/tests/search.test.ts` | +| Multi-search active boxed layout and selected-count footer | `pkgs-ts/console/src/prompts/search/render.ts`, `pkgs-ts/console/src/prompts/search/render-body.ts`, `pkgs-ts/console/src/prompts/search/body/*`, `pkgs-ts/console/src/prompts/search/read-multi/state/*`, `pkgs-ts/console/tests/search.test.ts` | +| Multi-search transformed fallback value and invalid retry default preservation | `pkgs-ts/console/src/prompts/search/index.ts`, `pkgs-ts/console/src/prompts/search/multisearch.ts`, `pkgs-ts/console/src/prompts/search/multi-options.ts`, `pkgs-ts/console/src/prompts/search/multi-run.ts`, `pkgs-ts/console/src/prompts/search/defaults.ts`, `pkgs-ts/console/src/prompts/search/validators/overload.ts` | +| Multi-search submitted final frame | `pkgs-ts/console/src/prompts/search/render.ts`, `pkgs-ts/console/src/prompts/search/multi-run.ts`, `pkgs-ts/console/src/prompts/search/read-multi.ts`, `pkgs-ts/console/src/prompts/search/read-multi/*`, `pkgs-ts/console/src/prompts/search/read-multi/state/*`, `pkgs-ts/console/tests/search.test.ts` | +| Multi-search empty submitted final frame | `pkgs-ts/console/src/prompts/search/render.ts` | +| Multi-search submitted frame renders only after validation passes | `pkgs-ts/console/src/prompt/submission.ts`, `pkgs-ts/console/src/prompts/search/index.ts`, `pkgs-ts/console/src/prompts/search/multisearch.ts`, `pkgs-ts/console/src/prompts/search/multi-run.ts`, `pkgs-ts/console/src/prompts/search/read-multi.ts`, `pkgs-ts/console/src/prompts/search/read-multi/*`, `pkgs-ts/console/src/prompts/search/read-multi/state/*`, `pkgs-ts/console/src/prompts/search/read-multi/result.ts`, `pkgs-ts/console/tests/search.test.ts` | +| Multi-search active-frame redraw and cursor rendering | `pkgs-ts/console/src/prompts/search/index.ts`, `pkgs-ts/console/src/prompts/search/multisearch.ts`, `pkgs-ts/console/src/prompts/search/multi-run.ts`, `pkgs-ts/console/src/prompts/search/keys.ts`, `pkgs-ts/console/src/prompts/search/keys/*`, `pkgs-ts/console/src/prompts/search/read-multi.ts`, `pkgs-ts/console/src/prompts/search/read-multi/*`, `pkgs-ts/console/src/prompts/search/read-multi/state/*`, `pkgs-ts/console/src/prompts/search/render.ts` | +| Multi-search cancel final frame | `pkgs-ts/console/src/prompts/search/render.ts`, `pkgs-ts/console/src/prompts/search/read-multi.ts`, `pkgs-ts/console/src/prompts/search/read-multi/*`, `pkgs-ts/console/src/prompts/search/read-multi/state/*`, `pkgs-ts/console/tests/search.test.ts` | +| Pause | `pkgs-ts/console/src/prompts/pause.ts`, `pkgs-ts/console/src/prompts/pause/*` | +| Pause themed key-driven frame | `pkgs-ts/console/src/prompts/pause.ts`, `pkgs-ts/console/src/prompts/pause/read.ts`, `pkgs-ts/console/src/prompts/pause/render.ts`, `pkgs-ts/console/tests/pause.test.ts` | +| Pause non-interactive no-render behavior | `pkgs-ts/console/src/prompts/pause.ts`, `pkgs-ts/console/src/prompts/pause/read.ts`, `pkgs-ts/console/tests/pause.test.ts` | +| Choice normalization, matching, navigation, and list rendering concerns | `pkgs-ts/console/src/concerns/choices.ts`, `pkgs-ts/console/src/concerns/choice-normalize.ts`, `pkgs-ts/console/src/concerns/choice-match.ts`, `pkgs-ts/console/src/concerns/choice-navigation.ts`, `pkgs-ts/console/src/concerns/choice-render.ts` | +| Choice and search scrollbar rendering | `pkgs-ts/console/src/concerns/scrollbar.ts`, `pkgs-ts/console/src/concerns/choice-navigation.ts`, `pkgs-ts/console/src/concerns/choice-render.ts`, `pkgs-ts/console/src/prompts/search/render.ts` | +| Form builder | `pkgs-ts/console/src/form.ts`, `pkgs-ts/console/src/form/builder/*`, `pkgs-ts/console/src/form/builder/responses.ts` | +| Form builder nested prompt revert short-circuiting | `pkgs-ts/console/src/form/builder/revert.ts`, `pkgs-ts/console/src/form/builder/submit.ts`, `pkgs-ts/console/tests/form.test.ts` | +| Form builder first-step and conditional revert feature coverage | `pkgs-ts/console/src/form/builder/conditions.ts`, `pkgs-ts/console/src/form/builder/submit.ts`, `pkgs-ts/console/tests/form.test.ts` | +| Form builder asynchronous condition lifecycle | `pkgs-ts/console/src/form/builder/conditions.ts`, `pkgs-ts/console/src/form/builder/submit.ts`, `pkgs-ts/console/src/form/types.ts`, `pkgs-ts/console/tests/form.test.ts` | +| Form builder side-effect step surface and revert skipping | `pkgs-ts/console/src/form/builder/index.ts`, `pkgs-ts/console/src/form/builder/methods.ts`, `pkgs-ts/console/src/form/builder/step.ts`, `pkgs-ts/console/src/form/builder/output.ts`, `pkgs-ts/console/src/form/builder/status.ts` | +| Notes and alert-style output | `pkgs-ts/console/src/output/notes.ts`, `pkgs-ts/console/src/output/notes/*` | +| Note multiline and typed theme rendering | `pkgs-ts/console/src/output/notes.ts`, `pkgs-ts/console/src/output/notes/render.ts`, `pkgs-ts/console/src/theme/styles.ts`, `pkgs-ts/console/tests/output.test.ts` | +| Note intro/outro visible-width padding | `pkgs-ts/console/src/output/notes.ts`, `pkgs-ts/console/src/output/notes/render.ts`, `pkgs-ts/console/tests/output.test.ts` | +| Native notifications | `pkgs-ts/console/src/output/notify.ts` | +| Notification command construction, executable discovery, and process result reporting | `pkgs-ts/console/src/output/notify.ts`, `pkgs-ts/console/src/output/notify/*` | +| Tables, data tables, and grids | `pkgs-ts/console/src/output/table.ts`, `pkgs-ts/console/src/output/table-output.ts`, `pkgs-ts/console/src/output/grid-output.ts`, `pkgs-ts/console/src/output/data-table.ts` | +| Data table label-first helper argument surface | `pkgs-ts/console/src/output/data-table.ts`, `pkgs-ts/console/tests/table.test.ts` | +| Data table transform callback lifecycle | `pkgs-ts/console/src/contracts/output.ts`, `pkgs-ts/console/src/output/data-table.ts`, `pkgs-ts/console/src/output/data-table/run.ts`, `pkgs-ts/console/src/output/data-table/options.ts`, `pkgs-ts/console/tests/table.test.ts` | +| Data table interactive default selection | `pkgs-ts/console/src/output/data-table/read.ts`, `pkgs-ts/console/src/output/data-table/reader/result.ts`, `pkgs-ts/console/tests/table.test.ts` | +| Data table exhausted-input default selection | `pkgs-ts/console/src/output/data-table/read.ts`, `pkgs-ts/console/src/output/data-table/reader/result.ts`, `pkgs-ts/console/tests/table.test.ts` | +| Data table line-input fallback filtering | `pkgs-ts/console/src/output/data-table/read.ts`, `pkgs-ts/console/src/output/data-table/reader/fallback.ts`, `pkgs-ts/console/src/prompt/ask.ts` | +| Data table no-results search rendering | `pkgs-ts/console/src/output/data-table/render.ts`, `pkgs-ts/console/src/output/data-table/render-body.ts`, `pkgs-ts/console/src/output/data-table/body/*`, `pkgs-ts/console/tests/table.test.ts` | +| Data table viewing-info rendering | `pkgs-ts/console/src/output/data-table/render.ts`, `pkgs-ts/console/src/output/data-table/render-body.ts`, `pkgs-ts/console/src/output/data-table/body/*`, `pkgs-ts/console/tests/table.test.ts` | +| Data table submitted and cancelled final frames | `pkgs-ts/console/src/output/data-table/render.ts`, `pkgs-ts/console/src/output/data-table/render-body.ts`, `pkgs-ts/console/src/output/data-table/body/*`, `pkgs-ts/console/src/output/data-table/read.ts`, `pkgs-ts/console/src/output/data-table/reader/interactive.ts`, `pkgs-ts/console/src/output/data-table/reader/cancel.ts`, `pkgs-ts/console/tests/table.test.ts` | +| Data table submitted frame renders only after validation passes | `pkgs-ts/console/src/prompt/submission.ts`, `pkgs-ts/console/src/output/data-table.ts`, `pkgs-ts/console/src/output/data-table/run.ts`, `pkgs-ts/console/src/output/data-table/read.ts`, `pkgs-ts/console/src/output/data-table/reader/interactive.ts`, `pkgs-ts/console/src/output/data-table/reader/result.ts`, `pkgs-ts/console/src/output/data-table/types.ts`, `pkgs-ts/console/tests/table.test.ts` | +| Data table active-frame redraw and cursor-aware search rendering | `pkgs-ts/console/src/output/data-table.ts`, `pkgs-ts/console/src/output/data-table/run.ts`, `pkgs-ts/console/src/output/data-table/read.ts`, `pkgs-ts/console/src/output/data-table/reader/interactive.ts`, `pkgs-ts/console/src/output/data-table/reader/session.ts`, `pkgs-ts/console/src/output/data-table/reader/session/*`, `pkgs-ts/console/src/output/data-table/render.ts`, `pkgs-ts/console/src/output/data-table/types.ts` | +| Data table explicit-default lifecycle, invalid retry default preservation, and transformed fallback value | `pkgs-ts/console/src/output/data-table.ts`, `pkgs-ts/console/src/output/data-table/options.ts`, `pkgs-ts/console/src/output/data-table/read.ts`, `pkgs-ts/console/src/output/data-table/reader/result.ts`, `pkgs-ts/console/src/validators/default.ts` | +| Data table themed cancellation warning | `pkgs-ts/console/src/output/data-table/render.ts`, `pkgs-ts/console/src/output/data-table/render-body.ts`, `pkgs-ts/console/src/output/data-table/body/*`, `pkgs-ts/console/src/output/data-table/reader/cancel.ts`, `pkgs-ts/console/tests/table.test.ts` | +| Data table multiline cell rendering | `pkgs-ts/console/src/output/data-table/multiline.ts`, `pkgs-ts/console/src/output/data-table/render.ts`, `pkgs-ts/console/src/output/data-table/render-body.ts`, `pkgs-ts/console/src/output/data-table/body/*`, `pkgs-ts/console/tests/table.test.ts` | +| Data table scrollbar rendering | `pkgs-ts/console/src/output/data-table/scrollbar.ts`, `pkgs-ts/console/src/output/data-table/render.ts`, `pkgs-ts/console/src/output/data-table/render-body.ts`, `pkgs-ts/console/src/output/data-table/body/*`, `pkgs-ts/console/tests/table.test.ts` | +| Data table no-header and blank-cell rendering | `pkgs-ts/console/src/output/data-table/render.ts`, `pkgs-ts/console/src/output/data-table/render-body.ts`, `pkgs-ts/console/src/output/data-table/body/*`, `pkgs-ts/console/src/output/data-table/rows.ts`, `pkgs-ts/console/src/output/data-table/rows/*`, `pkgs-ts/console/tests/table.test.ts` | +| Data table row-shape validation and normalization | `pkgs-ts/console/src/output/validators/cells.ts`, `pkgs-ts/console/src/output/validators/data-table.ts`, `pkgs-ts/console/src/output/data-table/rows.ts`, `pkgs-ts/console/src/output/data-table/rows/*`, `pkgs-ts/console/tests/table.test.ts` | +| Static table row-shape validation and header inference | `pkgs-ts/console/src/output/validators/cells.ts`, `pkgs-ts/console/src/output/validators/table.ts`, `pkgs-ts/console/src/output/validators/table/*`, `pkgs-ts/console/tests/table.test.ts` | +| Data table fixed visual height | `pkgs-ts/console/src/output/data-table/visual-window.ts`, `pkgs-ts/console/src/output/data-table/render.ts`, `pkgs-ts/console/src/output/data-table/render-body.ts`, `pkgs-ts/console/src/output/data-table/body/*`, `pkgs-ts/console/tests/table.test.ts` | +| Data table comfortable width and outlier truncation | `pkgs-ts/console/src/output/data-table/widths.ts`, `pkgs-ts/console/src/output/data-table/widths/*`, `pkgs-ts/console/src/output/data-table/render.ts`, `pkgs-ts/console/src/output/data-table/render-body.ts`, `pkgs-ts/console/src/output/data-table/body/*`, `pkgs-ts/console/tests/table.test.ts` | +| Boxed balanced grid rendering | `pkgs-ts/console/src/output/grid.ts`, `pkgs-ts/console/src/output/grid/*`, `pkgs-ts/console/tests/output.test.ts` | +| Terminal title and clear output | `pkgs-ts/console/src/output/terminal.ts` | +| Spinner, progress, task, stream | `pkgs-ts/console/src/status/*`, `pkgs-ts/console/src/status.ts` | +| Form builder label-first search and multisearch methods | `pkgs-ts/console/src/form/builder/prompts/choices.ts`, `pkgs-ts/console/src/form/builder/prompts/choice/*`, `pkgs-ts/console/tests/form.test.ts` | +| Form builder label-first choice and search info rendering | `pkgs-ts/console/src/form/builder/prompts/choices.ts`, `pkgs-ts/console/src/form/builder/prompts/choice/*`, `pkgs-ts/console/src/form/builder/prompts/contracts/choices.ts`, `pkgs-ts/console/tests/form.test.ts` | +| Form builder previous-response defaults | `pkgs-ts/console/src/form/builder/previous.ts`, `pkgs-ts/console/src/form/builder/validators/previous.ts`, `pkgs-ts/console/tests/form.test.ts` | +| Form builder basic prompt object-option method surface with validator-backed names | `pkgs-ts/console/src/form/builder/prompts/basic.ts`, `pkgs-ts/console/src/form/builder/prompts/basic/*`, `pkgs-ts/console/src/form/builder/prompts/contracts/basic.ts`, `pkgs-ts/console/src/form/builder/prompts/validators/basic.ts`, `pkgs-ts/console/tests/form.test.ts` | +| Form builder choice prompt object-option method surface with validator-backed overload parsing | `pkgs-ts/console/src/form/builder/prompts/choices.ts`, `pkgs-ts/console/src/form/builder/prompts/choice/*`, `pkgs-ts/console/src/form/builder/prompts/contracts/choices.ts`, `pkgs-ts/console/src/form/builder/prompts/validators/select.ts`, `pkgs-ts/console/tests/form.test.ts` | +| Form builder suggest and autocomplete placeholder/default arguments | `pkgs-ts/console/src/form/builder/prompts/choices.ts`, `pkgs-ts/console/src/form/builder/prompts/choice/*`, `pkgs-ts/console/src/form/builder/prompts/contracts/choices.ts`, `pkgs-ts/console/tests/form.test.ts` | +| Form builder suggest and autocomplete object-option method surface with validator-backed overload parsing | `pkgs-ts/console/src/form/builder/prompts/choices.ts`, `pkgs-ts/console/src/form/builder/prompts/choice/*`, `pkgs-ts/console/src/form/builder/prompts/contracts/choices.ts`, `pkgs-ts/console/src/form/builder/prompts/validators/suggest.ts`, `pkgs-ts/console/tests/form.test.ts` | +| Form builder suggest and autocomplete label-first info rendering | `pkgs-ts/console/src/form/builder/prompts/choices.ts`, `pkgs-ts/console/src/form/builder/prompts/choice/*`, `pkgs-ts/console/src/form/builder/prompts/contracts/choices.ts`, `pkgs-ts/console/tests/form.test.ts` | +| Form builder number transform argument surface | `pkgs-ts/console/src/form/builder/prompts/basic.ts`, `pkgs-ts/console/src/form/builder/prompts/basic/*`, `pkgs-ts/console/src/form/builder/prompts/basic/number/*`, `pkgs-ts/console/src/form/builder/prompts/contracts/basic.ts`, `pkgs-ts/console/src/prompts/number.ts`, `pkgs-ts/console/src/prompts/number/defaults.ts`, `pkgs-ts/console/tests/form.test.ts`, `pkgs-ts/console/tests/number.test.ts` | +| Form builder password previous-response defaults | `pkgs-ts/console/src/form/builder/prompts/basic.ts`, `pkgs-ts/console/src/form/builder/prompts/basic/*`, `pkgs-ts/console/tests/form.test.ts` | +| Form builder search and multisearch previous-response defaults | `pkgs-ts/console/src/form/builder/prompts/choices.ts`, `pkgs-ts/console/src/form/builder/prompts/choice/*`, `pkgs-ts/console/tests/form.test.ts` | +| Form builder named prompt defaults after skipped conditional responses | `pkgs-ts/console/src/form/builder/previous.ts`, `pkgs-ts/console/src/form/builder/prompts/choices.ts`, `pkgs-ts/console/src/form/builder/prompts/choice/*`, `pkgs-ts/console/src/form/builder/validators/previous.ts`, `pkgs-ts/console/tests/form.test.ts` | +| Form builder label-first data table method surface | `pkgs-ts/console/src/form/builder/output.ts`, `pkgs-ts/console/src/form/builder/output/*`, `pkgs-ts/console/src/form/builder/output/contracts.ts`, `pkgs-ts/console/tests/form.test.ts` | +| Form builder label-first data table previous-response defaults | `pkgs-ts/console/src/form/builder/output.ts`, `pkgs-ts/console/src/form/builder/output/*`, `pkgs-ts/console/src/form/builder/output/contracts.ts`, `pkgs-ts/console/tests/form.test.ts` | +| Form builder interactive data table object-option method surface | `pkgs-ts/console/src/form/builder/output.ts`, `pkgs-ts/console/src/form/builder/output/*`, `pkgs-ts/console/src/form/builder/output/contracts.ts`, `pkgs-ts/console/src/output/validators/data-table.ts`, `pkgs-ts/console/tests/form.test.ts` | +| Form builder output overload and row validator layer | `pkgs-ts/console/src/form/builder/output.ts`, `pkgs-ts/console/src/form/builder/output/*`, `pkgs-ts/console/src/form/builder/output/contracts.ts`, `pkgs-ts/console/src/form/builder/validators/output.ts` | +| Form builder object-option table output method surface | `pkgs-ts/console/src/form/builder/output.ts`, `pkgs-ts/console/src/form/builder/output/*`, `pkgs-ts/console/src/form/builder/output/contracts.ts`, `pkgs-ts/console/src/output/validators/table.ts`, `pkgs-ts/console/src/output/validators/table/*`, `pkgs-ts/console/tests/form.test.ts` | +| Form builder output method surface for grid, display data table, terminal helpers, and notifications | `pkgs-ts/console/src/form/builder/index.ts`, `pkgs-ts/console/src/form/builder/methods.ts`, `pkgs-ts/console/src/form/builder/output.ts`, `pkgs-ts/console/src/form/builder/output/*`, `pkgs-ts/console/tests/form.test.ts` | +| Display-only output helper return values and form responses | `pkgs-ts/console/src/output/notes.ts`, `pkgs-ts/console/src/output/table.ts`, `pkgs-ts/console/src/form/builder/output.ts`, `pkgs-ts/console/src/form/builder/output/*`, `pkgs-ts/console/tests/output.test.ts`, `pkgs-ts/console/tests/form.test.ts` | +| Progress boxed frame rendering, clamped fill rendering, and formatted fractions | `pkgs-ts/console/src/status/progress/render.ts`, `pkgs-ts/console/src/status/progress/state/*`, `pkgs-ts/console/src/theme/box.ts` | +| Spinner static and deterministic animated frame rendering | `pkgs-ts/console/src/status/spinner.ts`, `pkgs-ts/console/src/status/spinner/lifecycle.ts`, `pkgs-ts/console/src/status/spinner/render.ts` | +| Spinner empty default message | `pkgs-ts/console/src/status/spinner.ts`, `pkgs-ts/console/src/status/spinner/options.ts`, `pkgs-ts/console/src/status/spinner/validators/*`, `pkgs-ts/console/src/form/builder/status.ts`, `pkgs-ts/console/src/form/builder/status/*`, `pkgs-ts/console/tests/spinner.test.ts`, `pkgs-ts/console/tests/form.test.ts` | +| Task static and deterministic animated frame rendering, active redraw, bounded logs, and stable summary symbols | `pkgs-ts/console/src/status/task.ts`, `pkgs-ts/console/src/status/task/definition.ts`, `pkgs-ts/console/src/status/task/validators/*`, `pkgs-ts/console/src/status/task/lifecycle.ts`, `pkgs-ts/console/src/status/task/lifecycle/*`, `pkgs-ts/console/src/status/task/frame.ts`, `pkgs-ts/console/src/status/task/*` | +| Task non-positive log limit defaulting | `pkgs-ts/console/src/status/validators/limit.ts`, `pkgs-ts/console/src/status/task/logger.ts`, `pkgs-ts/console/src/status/task/logger/*`, `pkgs-ts/console/tests/task.test.ts` | +| Form builder root-aligned spinner and task overloads with validator-backed parsing | `pkgs-ts/console/src/form/builder/status.ts`, `pkgs-ts/console/src/form/builder/status/*`, `pkgs-ts/console/src/form/builder/validators/status.ts`, `pkgs-ts/console/tests/form.test.ts` | +| Form builder root-aligned manual progress side-effect lifecycle and validator-backed overloads | `pkgs-ts/console/src/form/builder/status.ts`, `pkgs-ts/console/src/form/builder/status/*`, `pkgs-ts/console/src/form/builder/validators/status.ts`, `pkgs-ts/console/tests/form.test.ts` | +| Task process output capture, writer validation, and restore-before-flush lifecycle | `pkgs-ts/console/src/status/task/process-output.ts`, `pkgs-ts/console/src/status/task/process-output/*`, `pkgs-ts/console/tests/task.test.ts` | +| Stream frame rendering, active redraw, wrapping, fade styles, chunk validation, lifecycle cleanup, burst coalescing, and close flushing | `pkgs-ts/console/src/status/stream.ts`, `pkgs-ts/console/src/status/stream/output.ts`, `pkgs-ts/console/src/status/stream/output/*`, `pkgs-ts/console/src/status/stream/lifecycle.ts`, `pkgs-ts/console/src/status/stream/renderer.ts`, `pkgs-ts/console/src/status/stream/scheduler.ts`, `pkgs-ts/console/src/status/stream/validators/*`, `pkgs-ts/console/src/status/stream/*` | +| Form builder root-aligned manual stream named-response overload | `pkgs-ts/console/src/form/builder/status.ts`, `pkgs-ts/console/src/form/builder/status/*`, `pkgs-ts/console/src/form/builder/validators/status.ts`, `pkgs-ts/console/src/validators/iterable.ts`, `pkgs-ts/console/tests/form.test.ts` | +| Status cursor visibility cleanup | `pkgs-ts/console/src/status/spinner.ts`, `pkgs-ts/console/src/status/stream.ts`, `pkgs-ts/console/src/status/task.ts` | +| Status signal cleanup for cursor and rendered-frame restoration | `pkgs-ts/console/src/status/signals.ts`, `pkgs-ts/console/src/status/spinner.ts`, `pkgs-ts/console/src/status/stream.ts`, `pkgs-ts/console/src/status/task.ts` | +| Status rendered-frame cleanup | `pkgs-ts/console/src/status/frame.ts`, `pkgs-ts/console/src/status/spinner.ts`, `pkgs-ts/console/src/status/task.ts` | +| Progress cursor visibility and rendered-frame redraw cleanup | `pkgs-ts/console/src/status/progress/progress.ts`, `pkgs-ts/console/src/status/progress/controller.ts`, `pkgs-ts/console/src/status/progress/renderer.ts`, `pkgs-ts/console/src/status/progress/state.ts`, `pkgs-ts/console/src/status/progress/state/*`, `pkgs-ts/console/src/status/progress/terminal.ts`, `pkgs-ts/console/src/status/frame.ts` | +| Progress signal cleanup | `pkgs-ts/console/src/status/progress/progress.ts`, `pkgs-ts/console/src/status/progress/terminal.ts`, `pkgs-ts/console/src/status/progress/terminal/*`, `pkgs-ts/console/tests/progress.test.ts` | +| Progress prompt-disable and manual advance semantics | `pkgs-ts/console/src/status/progress.ts`, `pkgs-ts/console/src/status/progress/progress.ts`, `pkgs-ts/console/src/status/progress/prompt.ts`, `pkgs-ts/console/src/status/progress/controller.ts`, `pkgs-ts/console/src/status/progress/validators/*`, `pkgs-ts/console/src/validators/iterable.ts` | +| Progress integer step parsing and clamped manual advance range | `pkgs-ts/console/src/status/progress.ts`, `pkgs-ts/console/src/status/progress/progress.ts`, `pkgs-ts/console/src/status/progress/state.ts`, `pkgs-ts/console/src/status/progress/state/*`, `pkgs-ts/console/src/status/progress/validators/*`, `pkgs-ts/console/src/status/validators/progress.ts` | +| Terminal clear/title, cursor visibility, true-color detection, default colors, and raw input restoration | `pkgs-ts/console/src/terminal.ts`, `pkgs-ts/console/src/terminal/*`, `pkgs-ts/console/src/environment/raw-key.ts`, `pkgs-ts/console/src/environment/raw-key/mode.ts`, `pkgs-ts/console/src/environment/raw-key/types.ts` | +| Terminal clear/title and notification helper return values | `pkgs-ts/console/src/output/terminal.ts`, `pkgs-ts/console/src/output/notify.ts`, `pkgs-ts/console/tests/terminal.test.ts`, `pkgs-ts/console/tests/notify.test.ts` | +| Terminal raw key alias normalization | `pkgs-ts/console/src/environment/raw-key/normalize.ts`, `pkgs-ts/console/tests/environment.test.ts` | +| Terminal split escape-sequence buffering | `pkgs-ts/console/src/environment/raw-key.ts`, `pkgs-ts/console/src/environment/raw-key/session.ts`, `pkgs-ts/console/src/environment/raw-key/normalize.ts`, `pkgs-ts/console/tests/environment.test.ts` | +| Red, strikethrough, and border-styled frames | `pkgs-ts/console/src/theme/styles.ts`, `pkgs-ts/console/src/theme/box.ts` | +| Default box renderer terminal-width cap | `pkgs-ts/console/src/theme/box.ts` | +| ANSI segment parsing, ANSI-aware wrapping, truncation, Unicode width | `pkgs-ts/console/src/strings.ts`, `pkgs-ts/console/src/string-utils/*`, `pkgs-ts/console/src/string-utils/ansi/*`, `pkgs-ts/console/tests/strings.test.ts` | +| ANSI truncation style cleanup and visible-width markers | `pkgs-ts/console/src/string-utils/truncate.ts`, `pkgs-ts/console/src/string-utils/width.ts`, `pkgs-ts/console/tests/strings.test.ts` | +| Key-mode search default submission | `pkgs-ts/console/src/prompts/search/read-single.ts`, `pkgs-ts/console/src/prompts/search/read-single/state/*`, `pkgs-ts/console/src/prompts/search/read-single/result.ts`, `pkgs-ts/console/tests/search.test.ts` | +| OpenTUI adapter | `pkgs-ts/console/src/opentui.ts` | +| Type contracts | `pkgs-ts/console/src/types.ts`, `pkgs-ts/console/src/contracts/*` | +| Public ESM package runtime, type-consumption surface, and domain subpath exports | `pkgs-ts/console/package.json`, `pkgs-ts/console/src/index.ts`, `pkgs-ts/console/src/prompts.ts`, `pkgs-ts/console/src/prompts/*`, `pkgs-ts/console/src/form/builder/index.ts`, `pkgs-ts/console/src/output.ts`, `pkgs-ts/console/src/output/*`, `pkgs-ts/console/src/status.ts`, `pkgs-ts/console/src/status/*`, `pkgs-ts/console/src/output/notify.ts`, `pkgs-ts/console/src/output/notify/*`, `pkgs-ts/tests/tests/package.test.ts` | +| Infra workspace and tool cache isolation | `infra`, `infra/.cache`, `package.json`, `pnpm-workspace.yaml`, `turbo.json`, `.npmrc` | +| Data table prompt argument validation layer | `pkgs-ts/console/src/output/validators/data-table.ts`, `pkgs-ts/console/src/output/data-table.ts`, `pkgs-ts/console/src/output/data-table/reader/types.ts`, `pkgs-ts/console/tests/table.test.ts` | +| Data table filter callback lifecycle | `pkgs-ts/console/src/contracts/output.ts`, `pkgs-ts/console/src/output/data-table/rows.ts`, `pkgs-ts/console/src/output/data-table/rows/*`, `pkgs-ts/console/src/output/data-table/reader/state.ts`, `pkgs-ts/console/src/output/data-table/reader/state/*`, `pkgs-ts/console/src/output/data-table.ts` | +| Data table search title and non-empty query filter lifecycle | `pkgs-ts/console/src/output/data-table/render.ts`, `pkgs-ts/console/src/output/data-table/rows.ts`, `pkgs-ts/console/src/output/data-table/rows/*`, `pkgs-ts/console/src/output/data-table/reader/state.ts`, `pkgs-ts/console/src/output/data-table/reader/state/*`, `pkgs-ts/console/src/output/data-table/reader/session.ts`, `pkgs-ts/console/src/output/data-table/reader/session/*`, `pkgs-ts/console/tests/form.test.ts` | +| Table row cell normalization validation layer | `pkgs-ts/console/src/output/validators/table.ts`, `pkgs-ts/console/src/output/validators/table/*`, `pkgs-ts/console/src/output/table.ts`, `pkgs-ts/console/tests/table.test.ts` | +| Choice option shape validation layer | `pkgs-ts/console/src/concerns/validators/choice.ts`, `pkgs-ts/console/src/concerns/choice-normalize.ts`, `pkgs-ts/console/src/concerns/choices.ts`, `pkgs-ts/console/tests/choices.test.ts` | +| Required value and message validation layer | `pkgs-ts/console/src/validators/required.ts`, `pkgs-ts/console/tests/prompt.test.ts` | +| Keyboard event normalization and key aliases | `pkgs-ts/console/src/key/event.ts`, `pkgs-ts/console/src/key/event/*`, `pkgs-ts/console/tests/key.test.ts` | +| Key match validation layer | `pkgs-ts/console/src/key/validators/match.ts`, `pkgs-ts/console/src/key/match.ts`, `pkgs-ts/console/tests/key.test.ts` | +| Form builder number empty-default lifecycle | `pkgs-ts/console/src/form/builder/prompts/basic.ts`, `pkgs-ts/console/src/form/builder/prompts/basic/*`, `pkgs-ts/console/src/form/builder/prompts/basic/number/*`, `pkgs-ts/console/tests/form.test.ts` | +| Prompt fallback and global validation lifecycle hooks | `pkgs-ts/console/src/prompt/fallback.ts`, `pkgs-ts/console/src/prompt/validate-using.ts`, `pkgs-ts/console/src/prompt/lifecycle.ts`, `pkgs-ts/console/src/prompts/*`, `pkgs-ts/console/src/output/data-table.ts` | +| Pipe-style default table rendering | `pkgs-ts/console/src/theme.ts`, `pkgs-ts/console/src/theme/*`, `pkgs-ts/console/src/output/table.ts`, `pkgs-ts/console/src/output/data-table/render.ts` | +| Default theme symbol, prompt, validation, choice, and table rendering concerns | `pkgs-ts/console/src/theme.ts`, `pkgs-ts/console/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`. +- Public runtime helpers are exported from `pkgs-ts/console/src/index.ts` and + verified by `pkgs-ts/tests/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`. + `pkgs-ts/console/tests` and `pkgs-ts/tests/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/*` + `pkgs-ts/console/src/form/builder/*` with validator layers under + `pkgs-ts/console/src/form/builder/**/validators/*`. +- Prompt implementations are split by concern under `pkgs-ts/console/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 +- The package is ESM-only through `pkgs-ts/console/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. diff --git a/packages/docs/examples/basic-prompts.ts b/apps/docs/examples/basic-prompts.ts similarity index 93% rename from packages/docs/examples/basic-prompts.ts rename to apps/docs/examples/basic-prompts.ts index 9a659750..db046f80 100644 --- a/packages/docs/examples/basic-prompts.ts +++ b/apps/docs/examples/basic-prompts.ts @@ -1,4 +1,4 @@ -import { number, password, text, textarea } from '@ollin/tui'; +import { number, password, text, textarea } from '@ollin/console'; export async function basicPromptExamples(): Promise { const name = await text({ diff --git a/packages/docs/examples/choice-prompts.ts b/apps/docs/examples/choice-prompts.ts similarity index 98% rename from packages/docs/examples/choice-prompts.ts rename to apps/docs/examples/choice-prompts.ts index f8a01cec..790c0a7a 100644 --- a/packages/docs/examples/choice-prompts.ts +++ b/apps/docs/examples/choice-prompts.ts @@ -1,4 +1,4 @@ -import { autocomplete, confirm, multiselect, select, suggest } from '@ollin/tui'; +import { autocomplete, confirm, multiselect, select, suggest } from '@ollin/console'; const roles = { member: 'Member', diff --git a/packages/docs/examples/forms.ts b/apps/docs/examples/forms.ts similarity index 95% rename from packages/docs/examples/forms.ts rename to apps/docs/examples/forms.ts index fb0ecab4..f3c0f692 100644 --- a/packages/docs/examples/forms.ts +++ b/apps/docs/examples/forms.ts @@ -1,4 +1,4 @@ -import { form } from '@ollin/tui'; +import { form } from '@ollin/console'; export async function formExample(): Promise { const responses = await form() diff --git a/apps/docs/examples/output.ts b/apps/docs/examples/output.ts new file mode 100644 index 00000000..b5b39abe --- /dev/null +++ b/apps/docs/examples/output.ts @@ -0,0 +1,31 @@ +import { alert, clear, dataTable, error, grid, info, intro, note, outro, table, title, warning } from '@ollin/console'; + +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/apps/docs/examples/search-prompts.ts similarity index 93% rename from packages/docs/examples/search-prompts.ts rename to apps/docs/examples/search-prompts.ts index bcdc20de..6efee0cc 100644 --- a/packages/docs/examples/search-prompts.ts +++ b/apps/docs/examples/search-prompts.ts @@ -1,4 +1,4 @@ -import { multisearch, search } from '@ollin/tui'; +import { multisearch, search } from '@ollin/console'; const users = new Map([ [1, 'Lin Chen'], diff --git a/apps/docs/examples/status.ts b/apps/docs/examples/status.ts new file mode 100644 index 00000000..d33e4ade --- /dev/null +++ b/apps/docs/examples/status.ts @@ -0,0 +1,25 @@ +import { progress, spin, stream, task } from '@ollin/console'; + +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/apps/docs/examples/testing.ts similarity index 89% rename from packages/docs/examples/testing.ts rename to apps/docs/examples/testing.ts index 9c1fb8c4..18554629 100644 --- a/packages/docs/examples/testing.ts +++ b/apps/docs/examples/testing.ts @@ -1,4 +1,4 @@ -import { Key, createMemoryOutput, createScriptedInput, text, withPromptEnvironment } from '@ollin/tui'; +import { Key, createMemoryOutput, createScriptedInput, text, withPromptEnvironment } from '@ollin/console'; export async function testingExample(): Promise { const output = createMemoryOutput(); diff --git a/apps/docs/package.json b/apps/docs/package.json new file mode 100644 index 00000000..a1bf8f0d --- /dev/null +++ b/apps/docs/package.json @@ -0,0 +1,28 @@ +{ + "name": "docs", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "api": "XDG_CACHE_HOME=../../infra/.cache/xdg PLAYWRIGHT_BROWSERS_PATH=../../infra/.cache/playwright typedoc --options typedoc.json && cp src/api/README.md src/api/index.md", + "build": "XDG_CACHE_HOME=../../infra/.cache/xdg PLAYWRIGHT_BROWSERS_PATH=../../infra/.cache/playwright pnpm api && XDG_CACHE_HOME=../../infra/.cache/xdg PLAYWRIGHT_BROWSERS_PATH=../../infra/.cache/playwright vitepress build src --config ../vitepress/config.ts", + "dev": "XDG_CACHE_HOME=../../infra/.cache/xdg PLAYWRIGHT_BROWSERS_PATH=../../infra/.cache/playwright pnpm api && XDG_CACHE_HOME=../../infra/.cache/xdg PLAYWRIGHT_BROWSERS_PATH=../../infra/.cache/playwright vitepress dev src --config ../vitepress/config.ts --host 127.0.0.1", + "preview": "XDG_CACHE_HOME=../../infra/.cache/xdg PLAYWRIGHT_BROWSERS_PATH=../../infra/.cache/playwright vitepress preview src --config ../vitepress/config.ts --host 127.0.0.1", + "test": "XDG_CACHE_HOME=../../infra/.cache/xdg PLAYWRIGHT_BROWSERS_PATH=../../infra/.cache/playwright vitest run --config vitest.config.ts", + "typecheck": "XDG_CACHE_HOME=../../infra/.cache/xdg PLAYWRIGHT_BROWSERS_PATH=../../infra/.cache/playwright tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@ollin/console": "workspace:*", + "@ollin/vitepress": "workspace:*" + }, + "devDependencies": { + "@ollin/console-infra": "workspace:*", + "@types/node": "^25.6.2", + "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" + } +} diff --git a/packages/docs/src/guide/autocomplete.md b/apps/docs/src/guide/autocomplete.md similarity index 88% rename from packages/docs/src/guide/autocomplete.md rename to apps/docs/src/guide/autocomplete.md index 54b475df..dfeee18a 100644 --- a/packages/docs/src/guide/autocomplete.md +++ b/apps/docs/src/guide/autocomplete.md @@ -3,7 +3,7 @@ The `autocomplete` helper completes the current input from known options. ```ts -import { autocomplete } from '@ollin/tui'; +import { autocomplete } from '@ollin/console'; const runtime = await autocomplete({ message: 'Runtime', @@ -19,7 +19,7 @@ Use `suggest` instead when arbitrary values are common and suggestions are only ## Complete Usage ```ts -import { autocomplete } from '@ollin/tui'; +import { autocomplete } from '@ollin/console'; const runtime = await autocomplete({ message: 'Runtime', @@ -39,7 +39,7 @@ void [runtime, packageManager]; ## Consumer Call And Output ```ts -import { autocomplete } from '@ollin/tui'; +import { autocomplete } from '@ollin/console'; const runtime = await autocomplete({ message: 'Runtime', diff --git a/packages/docs/src/guide/available-prompts.md b/apps/docs/src/guide/available-prompts.md similarity index 92% rename from packages/docs/src/guide/available-prompts.md rename to apps/docs/src/guide/available-prompts.md index 5929217b..3ebb6b57 100644 --- a/packages/docs/src/guide/available-prompts.md +++ b/apps/docs/src/guide/available-prompts.md @@ -1,6 +1,6 @@ # Available Prompts -`@ollin/tui` provides prompt helpers for text input, choice selection, search flows, confirmation, pausing, and autocomplete. +`@ollin/console` provides prompt helpers for text input, choice selection, search flows, confirmation, pausing, and autocomplete. | Helper | Purpose | | --- | --- | @@ -22,7 +22,7 @@ Object options use `message` as the visible prompt text. Label-first overloads a ## Complete Prompt Tour ```ts -import { autocomplete, confirm, multiselect, multisearch, number, password, pause, search, select, suggest, text, textarea } from '@ollin/tui'; +import { autocomplete, confirm, multiselect, multisearch, number, password, pause, search, select, suggest, text, textarea } from '@ollin/console'; const name = await text({ message: 'Name', required: true }); const description = await textarea({ message: 'Description', rows: 4 }); @@ -44,7 +44,7 @@ void [name, description, copies, secret, accepted, role, permissions, author, ru ## Consumer Call And Output ```ts -import { confirm, select, text } from '@ollin/tui'; +import { confirm, select, text } from '@ollin/console'; const name = await text({ message: 'Name', required: true }); const template = await select({ message: 'Template', options: ['cli', 'library', 'docs'] }); diff --git a/packages/docs/src/guide/clearing-terminal.md b/apps/docs/src/guide/clearing-terminal.md similarity index 87% rename from packages/docs/src/guide/clearing-terminal.md rename to apps/docs/src/guide/clearing-terminal.md index 9ca67804..232442b6 100644 --- a/packages/docs/src/guide/clearing-terminal.md +++ b/apps/docs/src/guide/clearing-terminal.md @@ -3,7 +3,7 @@ Use `clear` to clear the configured prompt output. ```ts -import { clear } from '@ollin/tui'; +import { clear } from '@ollin/console'; clear(); ``` @@ -11,7 +11,7 @@ clear(); The package also exports lower-level helpers such as `clearTerminal`, `eraseLine`, and `erasePreviousLines` for direct terminal behaviour. ```ts -import { clearTerminal } from '@ollin/tui'; +import { clearTerminal } from '@ollin/console'; clearTerminal(); ``` @@ -23,7 +23,7 @@ Use `clear` sparingly for full-screen command flows where previous output would ## Complete Usage ```ts -import { clear, clearTerminal, eraseLine, erasePreviousLines, form } from '@ollin/tui'; +import { clear, clearTerminal, eraseLine, erasePreviousLines, form } from '@ollin/console'; clear(); clearTerminal(); @@ -42,7 +42,7 @@ void responses; ## Consumer Call And Output ```ts -import { clear, info } from '@ollin/tui'; +import { clear, info } from '@ollin/console'; clear(); info('Fresh screen ready.'); diff --git a/packages/docs/src/guide/confirm.md b/apps/docs/src/guide/confirm.md similarity index 88% rename from packages/docs/src/guide/confirm.md rename to apps/docs/src/guide/confirm.md index 1ecf8512..51f8723c 100644 --- a/packages/docs/src/guide/confirm.md +++ b/apps/docs/src/guide/confirm.md @@ -3,7 +3,7 @@ The `confirm` helper asks for a boolean response. ```ts -import { confirm } from '@ollin/tui'; +import { confirm } from '@ollin/console'; const accepted = await confirm({ message: 'Do you accept the terms?', @@ -19,7 +19,7 @@ const accepted = await confirm({ ## Complete Usage ```ts -import { confirm } from '@ollin/tui'; +import { confirm } from '@ollin/console'; const accepted = await confirm({ message: 'Do you accept the terms?', @@ -39,7 +39,7 @@ void [accepted, overwrite]; ## Consumer Call And Output ```ts -import { confirm } from '@ollin/tui'; +import { confirm } from '@ollin/console'; const publish = await confirm({ message: 'Publish release?', diff --git a/packages/docs/src/guide/fallbacks.md b/apps/docs/src/guide/fallbacks.md similarity index 85% rename from packages/docs/src/guide/fallbacks.md rename to apps/docs/src/guide/fallbacks.md index ff932c2e..b96fdb86 100644 --- a/packages/docs/src/guide/fallbacks.md +++ b/apps/docs/src/guide/fallbacks.md @@ -1,11 +1,11 @@ # Custom Environments -`@ollin/tui` uses `PromptEnvironment` as the boundary for input, output, and interactivity. +`@ollin/console` 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'; +import { createMemoryOutput, createScriptedInput, text, withPromptEnvironment } from '@ollin/console'; const output = createMemoryOutput(); const input = createScriptedInput(['Ada']); @@ -23,7 +23,7 @@ Keep environment-specific behaviour near the command entrypoint so prompt code c ## Complete Usage ```ts -import { createMemoryOutput, createScriptedInput, select, text, withPromptEnvironment } from '@ollin/tui'; +import { createMemoryOutput, createScriptedInput, select, text, withPromptEnvironment } from '@ollin/console'; const output = createMemoryOutput(); const input = createScriptedInput(['Ada', 'Member']); @@ -50,7 +50,7 @@ void [result, output.text()]; ## Consumer Call And Output ```ts -import { createMemoryOutput, createScriptedInput, text, withPromptEnvironment } from '@ollin/tui'; +import { createMemoryOutput, createScriptedInput, text, withPromptEnvironment } from '@ollin/console'; const output = createMemoryOutput(); const input = createScriptedInput(['Ada']); diff --git a/packages/docs/src/guide/forms.md b/apps/docs/src/guide/forms.md similarity index 92% rename from packages/docs/src/guide/forms.md rename to apps/docs/src/guide/forms.md index 174dbc6e..9a4cec15 100644 --- a/packages/docs/src/guide/forms.md +++ b/apps/docs/src/guide/forms.md @@ -3,7 +3,7 @@ The `form` helper composes prompt, output, and status steps into one submission flow. ```ts -import { form } from '@ollin/tui'; +import { form } from '@ollin/console'; const responses = await form() .intro('Create an account') @@ -16,7 +16,7 @@ const responses = await form() Named steps are written into the response object. The builder also supports conditional steps with `addIf`. ```ts -import { form } from '@ollin/tui'; +import { form } from '@ollin/console'; await form() .confirm({ message: 'Enable notifications?', default: true }, 'notifications') @@ -33,7 +33,7 @@ You can also pass an async callback to `form` and submit from inside the callbac ## Complete Usage ```ts -import { form } from '@ollin/tui'; +import { form } from '@ollin/console'; const responses = await form(async (builder) => builder @@ -72,7 +72,7 @@ void responses; ## Consumer Call And Output ```ts -import { form } from '@ollin/tui'; +import { form } from '@ollin/console'; const responses = await form() .text({ message: 'Project name', required: true }, 'project') diff --git a/packages/docs/src/guide/informational-messages.md b/apps/docs/src/guide/informational-messages.md similarity index 92% rename from packages/docs/src/guide/informational-messages.md rename to apps/docs/src/guide/informational-messages.md index 3e73bf2d..0d3ee68d 100644 --- a/packages/docs/src/guide/informational-messages.md +++ b/apps/docs/src/guide/informational-messages.md @@ -3,7 +3,7 @@ Use informational helpers for durable terminal messages that are not prompts. ```ts -import { alert, error, info, intro, note, outro, warning } from '@ollin/tui'; +import { alert, error, info, intro, note, outro, warning } from '@ollin/console'; intro('Deployment'); info('Preparing release artefacts.'); @@ -19,7 +19,7 @@ These helpers write to the configured prompt output. In tests, configure a memor ## Complete Usage ```ts -import { alert, error, form, info, intro, note, outro, warning } from '@ollin/tui'; +import { alert, error, form, info, intro, note, outro, warning } from '@ollin/console'; intro('Deployment'); info('Preparing release artefacts.'); @@ -39,7 +39,7 @@ await form() ## Consumer Call And Output ```ts -import { info, intro, outro, warning } from '@ollin/tui'; +import { info, intro, outro, warning } from '@ollin/console'; intro('Release'); info('Preparing release artefacts.'); diff --git a/packages/docs/src/guide/installation.md b/apps/docs/src/guide/installation.md similarity index 83% rename from packages/docs/src/guide/installation.md rename to apps/docs/src/guide/installation.md index 664f391f..cf7a1404 100644 --- a/packages/docs/src/guide/installation.md +++ b/apps/docs/src/guide/installation.md @@ -1,15 +1,15 @@ # Installation -Install the package from the workspace or package registry that contains `@ollin/tui`. +Install the package from the workspace or package registry that contains `@ollin/console`. ```sh -pnpm add @ollin/tui +pnpm add @ollin/console ``` Then import helpers from the public package entrypoint. ```ts -import { text } from '@ollin/tui'; +import { text } from '@ollin/console'; const name = await text({ message: 'What is your name?', @@ -21,7 +21,7 @@ The package is ESM-first. The workspace acceptance tests verify that the built r ## 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. +Inside this repository, `@ollin/console` is a workspace package. The docs package depends on it with `workspace:*`, so examples compile against the package source. ## Common Imports @@ -42,7 +42,7 @@ import { task, text, withPromptEnvironment, -} from '@ollin/tui'; +} from '@ollin/console'; const project = await text({ message: 'Project name', required: true }); const runtime = await select({ message: 'Runtime', options: ['Node.js', 'Bun', 'Deno'] }); @@ -65,7 +65,7 @@ await withPromptEnvironment({ input, output }, () => search({ message: 'Owner', ## Consumer Call And Output ```ts -import { text } from '@ollin/tui'; +import { text } from '@ollin/console'; const project = await text({ message: 'Project name', required: true }); ``` diff --git a/apps/docs/src/guide/introduction.md b/apps/docs/src/guide/introduction.md new file mode 100644 index 00000000..339f23a0 --- /dev/null +++ b/apps/docs/src/guide/introduction.md @@ -0,0 +1,74 @@ +# Introduction + +`@ollin/console` 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/console` entrypoint. + +This guide documents the current `@ollin/console` 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/console` 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/console`. + +```ts +import { form, progress, table, task } from '@ollin/console'; + +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/console'; + +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/apps/docs/src/guide/multisearch.md similarity index 90% rename from packages/docs/src/guide/multisearch.md rename to apps/docs/src/guide/multisearch.md index c2e796ec..66d96657 100644 --- a/packages/docs/src/guide/multisearch.md +++ b/apps/docs/src/guide/multisearch.md @@ -3,7 +3,7 @@ The `multisearch` helper combines search input with multiple selected results. ```ts -import { multisearch } from '@ollin/tui'; +import { multisearch } from '@ollin/console'; const userIds = await multisearch({ message: 'Search for users receiving the email.', @@ -22,7 +22,7 @@ Use this helper when the option set is too large for `multiselect` and should be ## Complete Usage ```ts -import { multisearch } from '@ollin/tui'; +import { multisearch } from '@ollin/console'; const users = { 1: 'Lin Chen', @@ -51,7 +51,7 @@ void [reviewers, labels]; ## Consumer Call And Output ```ts -import { multisearch } from '@ollin/tui'; +import { multisearch } from '@ollin/console'; const reviewers = await multisearch({ message: 'Reviewers', diff --git a/packages/docs/src/guide/multiselect.md b/apps/docs/src/guide/multiselect.md similarity index 91% rename from packages/docs/src/guide/multiselect.md rename to apps/docs/src/guide/multiselect.md index 6801cc26..c4303cb7 100644 --- a/packages/docs/src/guide/multiselect.md +++ b/apps/docs/src/guide/multiselect.md @@ -3,7 +3,7 @@ The `multiselect` helper asks the user to choose multiple values. ```ts -import { multiselect } from '@ollin/tui'; +import { multiselect } from '@ollin/console'; const permissions = await multiselect({ message: 'What permissions should be assigned?', @@ -24,7 +24,7 @@ By default, multiple-selection prompts can return an empty array. Use `required` ## Complete Usage ```ts -import { multiselect } from '@ollin/tui'; +import { multiselect } from '@ollin/console'; const permissions = await multiselect({ message: 'What permissions should be assigned?', @@ -49,7 +49,7 @@ void [permissions, checks]; ## Consumer Call And Output ```ts -import { multiselect } from '@ollin/tui'; +import { multiselect } from '@ollin/console'; const checks = await multiselect({ message: 'Checks to run', diff --git a/packages/docs/src/guide/number.md b/apps/docs/src/guide/number.md similarity index 86% rename from packages/docs/src/guide/number.md rename to apps/docs/src/guide/number.md index b197d9a4..b1ea36e3 100644 --- a/packages/docs/src/guide/number.md +++ b/apps/docs/src/guide/number.md @@ -3,7 +3,7 @@ The `number` helper reads numeric input and supports numeric constraints. ```ts -import { number } from '@ollin/tui'; +import { number } from '@ollin/console'; const copies = await number({ message: 'How many copies?', @@ -19,7 +19,7 @@ const copies = await number({ Use `min`, `max`, and `integer` for common numeric rules. Use `validate` when the rule depends on application state. ```ts -import { number } from '@ollin/tui'; +import { number } from '@ollin/console'; const amount = await number({ message: 'How many seats?', @@ -30,7 +30,7 @@ const amount = await number({ ## Complete Usage ```ts -import { number } from '@ollin/tui'; +import { number } from '@ollin/console'; const seats = await number({ message: 'How many seats?', @@ -51,7 +51,7 @@ void [seats, retries]; ## Consumer Call And Output ```ts -import { number } from '@ollin/tui'; +import { number } from '@ollin/console'; const seats = await number({ message: 'Seats', diff --git a/packages/docs/src/guide/password.md b/apps/docs/src/guide/password.md similarity index 86% rename from packages/docs/src/guide/password.md rename to apps/docs/src/guide/password.md index 4cb8bf94..0e5bbdd5 100644 --- a/packages/docs/src/guide/password.md +++ b/apps/docs/src/guide/password.md @@ -3,7 +3,7 @@ The `password` helper reads masked text input for sensitive values. ```ts -import { password } from '@ollin/tui'; +import { password } from '@ollin/console'; const token = await password({ message: 'Enter your API token.', @@ -15,7 +15,7 @@ const token = await password({ `password` shares the text prompt validation model. Use a typed callback for length, format, or application-specific checks. ```ts -import { password } from '@ollin/tui'; +import { password } from '@ollin/console'; const secret = await password({ message: 'Secret', @@ -26,7 +26,7 @@ const secret = await password({ ## Complete Usage ```ts -import { password } from '@ollin/tui'; +import { password } from '@ollin/console'; const token = await password({ message: 'API token', @@ -45,7 +45,7 @@ void [token, confirmation]; ## Consumer Call And Output ```ts -import { password } from '@ollin/tui'; +import { password } from '@ollin/console'; const token = await password({ message: 'API token', diff --git a/packages/docs/src/guide/pause.md b/apps/docs/src/guide/pause.md similarity index 86% rename from packages/docs/src/guide/pause.md rename to apps/docs/src/guide/pause.md index 53d602e4..84caa0bc 100644 --- a/packages/docs/src/guide/pause.md +++ b/apps/docs/src/guide/pause.md @@ -3,7 +3,7 @@ The `pause` helper waits for the user to continue. ```ts -import { pause } from '@ollin/tui'; +import { pause } from '@ollin/console'; await pause('Press enter to continue.'); ``` @@ -13,7 +13,7 @@ Use `pause` between groups of prompts or before a destructive step that should b `pause` is also available on `form()`. ```ts -import { form } from '@ollin/tui'; +import { form } from '@ollin/console'; await form().pause('Review the summary before continuing.').submit(); ``` @@ -25,7 +25,7 @@ await form().pause('Review the summary before continuing.').submit(); ## Complete Usage ```ts -import { form, pause } from '@ollin/tui'; +import { form, pause } from '@ollin/console'; const continued = await pause('Press enter to continue.'); @@ -41,7 +41,7 @@ void [continued, responses]; ## Consumer Call And Output ```ts -import { pause } from '@ollin/tui'; +import { pause } from '@ollin/console'; await pause('Review the summary, then press enter.'); ``` diff --git a/packages/docs/src/guide/progress.md b/apps/docs/src/guide/progress.md similarity index 87% rename from packages/docs/src/guide/progress.md rename to apps/docs/src/guide/progress.md index 43341b65..b02bc5e2 100644 --- a/packages/docs/src/guide/progress.md +++ b/apps/docs/src/guide/progress.md @@ -3,7 +3,7 @@ The `progress` helper supports iterable work and manual progress control. ```ts -import { progress } from '@ollin/tui'; +import { progress } from '@ollin/console'; await progress('Processing files', ['one.ts', 'two.ts'], async (file) => file.toUpperCase()); ``` @@ -11,7 +11,7 @@ await progress('Processing files', ['one.ts', 'two.ts'], async (file) => file.to For manual control, create a `Progress` instance and call `start`, `advance`, and `finish`. ```ts -import { progress } from '@ollin/tui'; +import { progress } from '@ollin/console'; const bar = progress(3, 'Manual steps'); @@ -23,7 +23,7 @@ bar.finish(); ## Complete Usage ```ts -import { form, progress } from '@ollin/tui'; +import { form, progress } from '@ollin/console'; const files = await progress('Processing files', ['one.ts', 'two.ts'], async (file, bar) => { bar.hint(`Current file: ${String(file)}`); @@ -47,7 +47,7 @@ void [files, responses]; ## Consumer Call And Output ```ts -import { progress } from '@ollin/tui'; +import { progress } from '@ollin/console'; await progress('Building packages', ['tui', 'docs'], async (pkg) => String(pkg)); ``` diff --git a/packages/docs/src/guide/search.md b/apps/docs/src/guide/search.md similarity index 91% rename from packages/docs/src/guide/search.md rename to apps/docs/src/guide/search.md index 9360c246..1734cae5 100644 --- a/packages/docs/src/guide/search.md +++ b/apps/docs/src/guide/search.md @@ -3,7 +3,7 @@ The `search` helper filters options as the user types and returns one selected value. ```ts -import { search } from '@ollin/tui'; +import { search } from '@ollin/console'; const userId = await search({ message: 'Search for the user receiving the email.', @@ -23,7 +23,7 @@ When `options` returns an object, the selected key is returned. When it returns ## Complete Usage ```ts -import { search } from '@ollin/tui'; +import { search } from '@ollin/console'; const users = new Map([ [1, 'Lin Chen'], @@ -49,7 +49,7 @@ void [userId, userName]; ## Consumer Call And Output ```ts -import { search } from '@ollin/tui'; +import { search } from '@ollin/console'; const reviewer = await search({ message: 'Reviewer', diff --git a/packages/docs/src/guide/select.md b/apps/docs/src/guide/select.md similarity index 90% rename from packages/docs/src/guide/select.md rename to apps/docs/src/guide/select.md index 204da382..c0e766ae 100644 --- a/packages/docs/src/guide/select.md +++ b/apps/docs/src/guide/select.md @@ -3,7 +3,7 @@ The `select` helper asks the user to choose one item. ```ts -import { select } from '@ollin/tui'; +import { select } from '@ollin/console'; const role = await select({ message: 'What role should the user have?', @@ -24,7 +24,7 @@ Associative options return the selected key. Array options return the selected v `select` always returns one value. Use `validate` if a visible option may be unavailable. ```ts -import { select } from '@ollin/tui'; +import { select } from '@ollin/console'; const role = await select({ message: 'Role', @@ -36,7 +36,7 @@ const role = await select({ ## Complete Usage ```ts -import { select } from '@ollin/tui'; +import { select } from '@ollin/console'; const role = await select({ message: 'What role should the user have?', @@ -60,7 +60,7 @@ void [role, channel]; ## Consumer Call And Output ```ts -import { select } from '@ollin/tui'; +import { select } from '@ollin/console'; const channel = await select({ message: 'Release channel', diff --git a/packages/docs/src/guide/spin.md b/apps/docs/src/guide/spin.md similarity index 91% rename from packages/docs/src/guide/spin.md rename to apps/docs/src/guide/spin.md index 786805b0..60a0efa0 100644 --- a/packages/docs/src/guide/spin.md +++ b/apps/docs/src/guide/spin.md @@ -3,7 +3,7 @@ The `spin` helper runs async work while rendering a spinner. ```ts -import { spin } from '@ollin/tui'; +import { spin } from '@ollin/console'; const result = await spin('Installing dependencies', async () => 'installed'); ``` @@ -17,7 +17,7 @@ Use `spin` for one async operation where users only need to know that work is in ## Complete Usage ```ts -import { form, spin } from '@ollin/tui'; +import { form, spin } from '@ollin/console'; const result = await spin('Installing dependencies', async () => { return 'installed'; @@ -38,7 +38,7 @@ The callback return value is preserved, so `spin` can wrap existing application ## Consumer Call And Output ```ts -import { spin } from '@ollin/tui'; +import { spin } from '@ollin/console'; const result = await spin('Installing dependencies', async () => 'installed'); ``` diff --git a/packages/docs/src/guide/stream.md b/apps/docs/src/guide/stream.md similarity index 84% rename from packages/docs/src/guide/stream.md rename to apps/docs/src/guide/stream.md index c354f11e..4edbfe6d 100644 --- a/packages/docs/src/guide/stream.md +++ b/apps/docs/src/guide/stream.md @@ -3,7 +3,7 @@ The `stream` helper renders streaming text from an iterable or async iterable. ```ts -import { stream } from '@ollin/tui'; +import { stream } from '@ollin/console'; await stream(['Preparing release\n', 'Release ready\n']); ``` @@ -11,7 +11,7 @@ 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'; +import { stream } from '@ollin/console'; const output = stream(); @@ -22,7 +22,7 @@ output.close(); ## Complete Usage ```ts -import { form, stream } from '@ollin/tui'; +import { form, stream } from '@ollin/console'; await stream(['Preparing release\n', 'Release ready\n']); @@ -40,7 +40,7 @@ void responses; ## Consumer Call And Output ```ts -import { stream } from '@ollin/tui'; +import { stream } from '@ollin/console'; await stream(['Preparing release\n', 'Release ready\n']); ``` diff --git a/packages/docs/src/guide/suggest.md b/apps/docs/src/guide/suggest.md similarity index 90% rename from packages/docs/src/guide/suggest.md rename to apps/docs/src/guide/suggest.md index f8a5800f..bd5b57a7 100644 --- a/packages/docs/src/guide/suggest.md +++ b/apps/docs/src/guide/suggest.md @@ -3,7 +3,7 @@ The `suggest` helper offers completions while still allowing any typed value. ```ts -import { suggest } from '@ollin/tui'; +import { suggest } from '@ollin/console'; const author = await suggest({ message: 'Author name', @@ -19,7 +19,7 @@ Use `required`, `validate`, and `transform` the same way you would on `text`. ## Complete Usage ```ts -import { suggest } from '@ollin/tui'; +import { suggest } from '@ollin/console'; const names = ['Ada', 'Grace', 'Lin', 'Katherine']; @@ -42,7 +42,7 @@ void [author, reviewer]; ## Consumer Call And Output ```ts -import { suggest } from '@ollin/tui'; +import { suggest } from '@ollin/console'; const maintainer = await suggest({ message: 'Maintainer', diff --git a/packages/docs/src/guide/tables.md b/apps/docs/src/guide/tables.md similarity index 78% rename from packages/docs/src/guide/tables.md rename to apps/docs/src/guide/tables.md index 3d4ea08d..ebe41956 100644 --- a/packages/docs/src/guide/tables.md +++ b/apps/docs/src/guide/tables.md @@ -3,7 +3,7 @@ Use `table` or `dataTable` to render tabular output. ```ts -import { dataTable, table } from '@ollin/tui'; +import { dataTable, table } from '@ollin/console'; table(['Name', 'Runtime'], [ ['Prompts', 'TypeScript'], @@ -22,7 +22,7 @@ dataTable({ `dataTable` is a display alias for `table`. For an interactive selectable table, use `datatable`. ```ts -import { datatable } from '@ollin/tui'; +import { datatable } from '@ollin/console'; const selected = await datatable({ message: 'Select a user', @@ -38,12 +38,12 @@ const selected = await datatable({ ## Complete Usage ```ts -import { dataTable, datatable, form, grid, table } from '@ollin/tui'; +import { dataTable, datatable, form, grid, table } from '@ollin/console'; table({ headers: ['Package', 'Purpose'], rows: [ - { Package: '@ollin/tui', Purpose: 'Runtime prompts' }, + { Package: '@ollin/console', Purpose: 'Runtime prompts' }, { Package: 'docs', Purpose: 'Documentation site' }, ], }); @@ -59,7 +59,7 @@ const selected = await datatable({ message: 'Select a package', headers: ['Package', 'Purpose'], rows: [ - ['@ollin/tui', 'Runtime prompts'], + ['@ollin/console', 'Runtime prompts'], ['docs', 'Documentation site'], ], required: true, @@ -76,12 +76,12 @@ void [selected, responses]; ## Consumer Call And Output ```ts -import { table } from '@ollin/tui'; +import { table } from '@ollin/console'; table(['Package', 'Purpose'], [ - ['@ollin/tui', 'Prompts'], + ['@ollin/console', 'Prompts'], ['docs', 'Documentation'], ]); ``` - + diff --git a/packages/docs/src/guide/task.md b/apps/docs/src/guide/task.md similarity index 89% rename from packages/docs/src/guide/task.md rename to apps/docs/src/guide/task.md index 4d53f390..2710bee9 100644 --- a/packages/docs/src/guide/task.md +++ b/apps/docs/src/guide/task.md @@ -3,7 +3,7 @@ The `task` helper runs work with structured task logging. ```ts -import { task } from '@ollin/tui'; +import { task } from '@ollin/console'; const result = await task('Publishing', async (logger) => { logger.info('Uploading assets'); @@ -18,7 +18,7 @@ Use `logger.info`, `logger.line`, `logger.success`, `logger.warning`, and `logge ## Complete Usage ```ts -import { form, task } from '@ollin/tui'; +import { form, task } from '@ollin/console'; const result = await task('Publishing', async (logger) => { logger.info('Uploading assets'); @@ -43,7 +43,7 @@ void [result, responses]; ## Consumer Call And Output ```ts -import { task } from '@ollin/tui'; +import { task } from '@ollin/console'; await task('Publishing package', async (logger) => { logger.info('Uploading files'); diff --git a/packages/docs/src/guide/terminal-considerations.md b/apps/docs/src/guide/terminal-considerations.md similarity index 78% rename from packages/docs/src/guide/terminal-considerations.md rename to apps/docs/src/guide/terminal-considerations.md index 5bf6576a..6c188253 100644 --- a/packages/docs/src/guide/terminal-considerations.md +++ b/apps/docs/src/guide/terminal-considerations.md @@ -1,9 +1,9 @@ # 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. +`@ollin/console` 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'; +import { promptEnvironment, terminalSize } from '@ollin/console'; const environment = promptEnvironment(); const size = terminalSize(); @@ -20,7 +20,7 @@ Terminal helpers restore cursor state around status renderers such as `spin`, `p ## Complete Usage ```ts -import { createMemoryOutput, createScriptedInput, promptEnvironment, terminalSize, text, withPromptEnvironment } from '@ollin/tui'; +import { createMemoryOutput, createScriptedInput, promptEnvironment, terminalSize, text, withPromptEnvironment } from '@ollin/console'; const current = promptEnvironment(); const size = terminalSize(); @@ -45,7 +45,7 @@ Keep process-specific assumptions near your application entrypoint. Library code ## Consumer Call And Output ```ts -import { createMemoryOutput, createScriptedInput, text, withPromptEnvironment } from '@ollin/tui'; +import { createMemoryOutput, createScriptedInput, text, withPromptEnvironment } from '@ollin/console'; const output = createMemoryOutput(); const input = createScriptedInput(['Ada']); diff --git a/packages/docs/src/guide/terminal-title.md b/apps/docs/src/guide/terminal-title.md similarity index 83% rename from packages/docs/src/guide/terminal-title.md rename to apps/docs/src/guide/terminal-title.md index 3753952f..3082cad4 100644 --- a/packages/docs/src/guide/terminal-title.md +++ b/apps/docs/src/guide/terminal-title.md @@ -3,7 +3,7 @@ Use `title` to set the terminal title through the configured prompt output. ```ts -import { title } from '@ollin/tui'; +import { title } from '@ollin/console'; title('Ollin TUI'); ``` @@ -11,7 +11,7 @@ title('Ollin TUI'); The lower-level terminal helper `setTerminalTitle` is also exported for direct terminal control. ```ts -import { setTerminalTitle } from '@ollin/tui'; +import { setTerminalTitle } from '@ollin/console'; setTerminalTitle('Ollin TUI'); ``` @@ -23,7 +23,7 @@ Use `title` when you want the same output boundary as the rest of the prompt hel ## Complete Usage ```ts -import { form, setTerminalTitle, title } from '@ollin/tui'; +import { form, setTerminalTitle, title } from '@ollin/console'; title('Ollin TUI'); setTerminalTitle('Ollin TUI: Build'); @@ -40,7 +40,7 @@ Terminal title support depends on the terminal emulator. Terminals that ignore t ## Consumer Call And Output ```ts -import { title } from '@ollin/tui'; +import { title } from '@ollin/console'; title('Ollin TUI: Release'); ``` diff --git a/packages/docs/src/guide/testing.md b/apps/docs/src/guide/testing.md similarity index 91% rename from packages/docs/src/guide/testing.md rename to apps/docs/src/guide/testing.md index 76a86ed2..b646c7a5 100644 --- a/packages/docs/src/guide/testing.md +++ b/apps/docs/src/guide/testing.md @@ -3,7 +3,7 @@ Use prompt environment helpers to exercise prompt flows without process input. ```ts -import { Key, createMemoryOutput, createScriptedInput, text, withPromptEnvironment } from '@ollin/tui'; +import { Key, createMemoryOutput, createScriptedInput, text, withPromptEnvironment } from '@ollin/console'; const output = createMemoryOutput(); const input = createScriptedInput(['Ada', Key.enter]); @@ -23,7 +23,7 @@ This uses the same exported helpers as application code. The scripted input supp ## Complete Usage ```ts -import { Key, confirm, createMemoryOutput, createScriptedInput, select, text, withPromptEnvironment } from '@ollin/tui'; +import { Key, confirm, createMemoryOutput, createScriptedInput, select, text, withPromptEnvironment } from '@ollin/console'; const output = createMemoryOutput(); const input = createScriptedInput(['Ada', Key.enter, Key.down, Key.enter, 'y']); @@ -56,7 +56,7 @@ void [result, rendered]; ## Consumer Call And Output ```ts -import { createMemoryOutput, createScriptedInput, text, withPromptEnvironment } from '@ollin/tui'; +import { createMemoryOutput, createScriptedInput, text, withPromptEnvironment } from '@ollin/console'; const output = createMemoryOutput(); const input = createScriptedInput(['Ada']); diff --git a/packages/docs/src/guide/text.md b/apps/docs/src/guide/text.md similarity index 89% rename from packages/docs/src/guide/text.md rename to apps/docs/src/guide/text.md index 7eee9dc7..38bd598c 100644 --- a/packages/docs/src/guide/text.md +++ b/apps/docs/src/guide/text.md @@ -3,7 +3,7 @@ The `text` helper reads a single-line string. ```ts -import { text } from '@ollin/tui'; +import { text } from '@ollin/console'; const name = await text({ message: 'What is your name?', @@ -22,7 +22,7 @@ The `validate` callback returns a string error message or `null`. Use callbacks ## Label-First Form ```ts -import { text } from '@ollin/tui'; +import { text } from '@ollin/console'; const name = await text('What is your name?', 'Ada Lovelace', '', true); ``` @@ -30,7 +30,7 @@ const name = await text('What is your name?', 'Ada Lovelace', '', true); ## Complete Usage ```ts -import { text } from '@ollin/tui'; +import { text } from '@ollin/console'; const project = await text({ message: 'Project name', @@ -50,7 +50,7 @@ void [project, owner]; ## Consumer Call And Output ```ts -import { text } from '@ollin/tui'; +import { text } from '@ollin/console'; const project = await text({ message: 'Project name', diff --git a/packages/docs/src/guide/textarea.md b/apps/docs/src/guide/textarea.md similarity index 89% rename from packages/docs/src/guide/textarea.md rename to apps/docs/src/guide/textarea.md index a25b73f5..7cac4205 100644 --- a/packages/docs/src/guide/textarea.md +++ b/apps/docs/src/guide/textarea.md @@ -3,7 +3,7 @@ The `textarea` helper reads multi-line input. ```ts -import { textarea } from '@ollin/tui'; +import { textarea } from '@ollin/console'; const biography = await textarea({ message: 'Tell us about your project.', @@ -22,7 +22,7 @@ Use `validate` for custom checks and `transform` to normalise the submitted stri ## Complete Usage ```ts -import { textarea } from '@ollin/tui'; +import { textarea } from '@ollin/console'; const summary = await textarea({ message: 'Release notes', @@ -42,7 +42,7 @@ void [summary, supportMessage]; ## Consumer Call And Output ```ts -import { textarea } from '@ollin/tui'; +import { textarea } from '@ollin/console'; const notes = await textarea({ message: 'Release notes', diff --git a/packages/docs/src/guide/transforms.md b/apps/docs/src/guide/transforms.md similarity index 91% rename from packages/docs/src/guide/transforms.md rename to apps/docs/src/guide/transforms.md index f6a477c9..962e08b9 100644 --- a/packages/docs/src/guide/transforms.md +++ b/apps/docs/src/guide/transforms.md @@ -3,7 +3,7 @@ Prompt options can include `transform`. The transform runs on the submitted value before validation completes. ```ts -import { text } from '@ollin/tui'; +import { text } from '@ollin/console'; const slug = await text({ message: 'Project slug', @@ -19,7 +19,7 @@ Use callbacks for `validate` and `transform`. ## Complete Usage ```ts -import { confirm, multiselect, number, text } from '@ollin/tui'; +import { confirm, multiselect, number, text } from '@ollin/console'; const slug = await text({ message: 'Project slug', @@ -50,7 +50,7 @@ void [slug, seats, permissions, publish]; ## Consumer Call And Output ```ts -import { text } from '@ollin/tui'; +import { text } from '@ollin/console'; const slug = await text({ message: 'Project slug', diff --git a/packages/docs/src/index.md b/apps/docs/src/index.md similarity index 82% rename from packages/docs/src/index.md rename to apps/docs/src/index.md index d76ef722..261b32c2 100644 --- a/packages/docs/src/index.md +++ b/apps/docs/src/index.md @@ -1,4 +1,4 @@ -# @ollin/tui +# @ollin/console Prompt and terminal helpers for command-line applications. @@ -8,14 +8,14 @@ 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. +`@ollin/console` 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'; +import { form, table, task } from '@ollin/console'; const answers = await form() .text({ message: 'Package name', required: true }, 'name') @@ -45,7 +45,7 @@ table(['Field', 'Value'], [ ## Consumer Call And Output ```ts -import { text } from '@ollin/tui'; +import { text } from '@ollin/console'; const name = await text({ message: 'Package name', default: 'cli-tools' }); ``` diff --git a/packages/docs/tests/docs.test.ts b/apps/docs/tests/docs.test.ts similarity index 98% rename from packages/docs/tests/docs.test.ts rename to apps/docs/tests/docs.test.ts index caa2b7e7..12293096 100644 --- a/packages/docs/tests/docs.test.ts +++ b/apps/docs/tests/docs.test.ts @@ -2,7 +2,7 @@ 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'; +import { guideSections } from '@ollin/vitepress'; const testsPath = dirname(fileURLToPath(import.meta.url)); const packagePath = dirname(testsPath); diff --git a/apps/docs/tsconfig.json b/apps/docs/tsconfig.json new file mode 100644 index 00000000..76bc3e36 --- /dev/null +++ b/apps/docs/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "@ollin/console-infra/tsconfig/base.json", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@docs/*": ["src/*"], + "@docs-tests/*": ["tests/*"], + "@ollin/vitepress": ["../vitepress/config.ts"], + "@ollin/console": ["../../pkgs-ts/console/src/index.ts"], + "#console/*": ["../../pkgs-ts/console/src/*"], + "#console/prompts/*": ["../../pkgs-ts/console/src/prompts/*"] + }, + "tsBuildInfoFile": "../../infra/.cache/tsbuild/docs.tsbuildinfo" + }, + "include": ["examples/**/*.ts", "tests/**/*.ts", "vitest.config.ts", "../vitepress/config.ts", "../../pkgs-ts/console/src/**/*.ts"] +} diff --git a/apps/docs/typedoc.json b/apps/docs/typedoc.json new file mode 100644 index 00000000..852e1e8b --- /dev/null +++ b/apps/docs/typedoc.json @@ -0,0 +1,12 @@ +{ + "entryPoints": ["../../pkgs-ts/console/src/index.ts"], + "excludeInternal": true, + "excludePrivate": true, + "excludeProtected": true, + "hideBreadcrumbs": true, + "hidePageHeader": true, + "out": "src/api", + "plugin": ["typedoc-plugin-markdown"], + "readme": "none", + "tsconfig": "../../pkgs-ts/console/tsconfig.json" +} diff --git a/apps/docs/vitest.config.ts b/apps/docs/vitest.config.ts new file mode 100644 index 00000000..15ae5838 --- /dev/null +++ b/apps/docs/vitest.config.ts @@ -0,0 +1,45 @@ +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 cachePath = join(workspacePath, 'infra', '.cache', 'vitest', 'docs'); +const consoleSourcePath = join(workspacePath, 'pkgs-ts', 'console', 'src', 'index.ts'); +const internalSourcePath = join(workspacePath, 'pkgs-ts', 'console', 'src', '$1'); +const vitepressConfigPath = join(packagesPath, 'vitepress', 'config.ts'); +const docsTestsPath = join(packagePath, 'tests'); + +export default defineConfig({ + cacheDir: cachePath, + resolve: { + alias: [ + { + find: '@docs', + replacement: docsSourcePath, + }, + { + find: '@docs-tests', + replacement: docsTestsPath, + }, + { + find: '@ollin/vitepress', + replacement: vitepressConfigPath, + }, + { + find: '@ollin/console', + replacement: consoleSourcePath, + }, + { + find: /^#console\/(.+)$/u, + replacement: internalSourcePath, + }, + ], + }, + test: { + environment: 'node', + globals: false, + }, +}); diff --git a/packages/docs/components.json b/apps/vitepress/components.json similarity index 90% rename from packages/docs/components.json rename to apps/vitepress/components.json index acd2bf12..1073bf41 100644 --- a/packages/docs/components.json +++ b/apps/vitepress/components.json @@ -4,7 +4,7 @@ "typescript": true, "tailwind": { "config": "", - "css": "vitepress/theme/styles.css", + "css": "theme/styles.css", "baseColor": "neutral", "cssVariables": true, "prefix": "" diff --git a/packages/docs/vitepress/config.ts b/apps/vitepress/config.ts similarity index 87% rename from packages/docs/vitepress/config.ts rename to apps/vitepress/config.ts index 381e78ad..bd689855 100644 --- a/packages/docs/vitepress/config.ts +++ b/apps/vitepress/config.ts @@ -2,12 +2,11 @@ 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 viteCachePath = fileURLToPath(new URL('../../infra/.cache/vitepress/docs', import.meta.url)); const tailwindPlugin = tailwindcss() as never; export const guideSections = [ @@ -42,8 +41,8 @@ export const guideSections = [ ] as const; export default defineConfig({ - title: '@ollin/tui', - description: 'Documentation for the Ollin TUI prompt toolkit.', + title: '@ollin/console', + description: 'Documentation for the Ollin Console prompt toolkit.', cleanUrls: true, lastUpdated: true, vite: { @@ -51,7 +50,6 @@ export default defineConfig({ plugins: [tailwindPlugin], resolve: { alias: [ - { find: '@docs', replacement: sourcePath }, { find: '@docs-components', replacement: themeComponentsPath }, { find: '@docs-lib', replacement: themeLibPath }, { find: '@docs-theme', replacement: themePath }, @@ -78,7 +76,7 @@ export default defineConfig({ }, ], footer: { - message: 'Documentation for the @ollin/tui TypeScript package.', + message: 'Documentation for the @ollin/console TypeScript package.', }, }, }); diff --git a/apps/vitepress/package.json b/apps/vitepress/package.json new file mode 100644 index 00000000..d01b5096 --- /dev/null +++ b/apps/vitepress/package.json @@ -0,0 +1,31 @@ +{ + "name": "@ollin/vitepress", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": { + ".": "./config.ts" + }, + "scripts": { + "typecheck": "XDG_CACHE_HOME=../../infra/.cache/xdg PLAYWRIGHT_BROWSERS_PATH=../../infra/.cache/playwright tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@fontsource/geist-mono": "^5.2.8", + "@fontsource/geist-sans": "^5.2.5", + "@vueuse/core": "^14.3.0", + "@xterm/xterm": "^6.0.0", + "reka-ui": "^2.9.10" + }, + "devDependencies": { + "@ollin/console-infra": "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", + "typescript": "^6.0.3", + "vitepress": "^1.6.4", + "vue": "^3.5.38" + } +} diff --git a/packages/docs/vitepress/theme/components/TerminalOutput.vue b/apps/vitepress/theme/components/TerminalOutput.vue similarity index 100% rename from packages/docs/vitepress/theme/components/TerminalOutput.vue rename to apps/vitepress/theme/components/TerminalOutput.vue diff --git a/packages/docs/vitepress/theme/components/ui/badge/Badge.vue b/apps/vitepress/theme/components/ui/badge/Badge.vue similarity index 100% rename from packages/docs/vitepress/theme/components/ui/badge/Badge.vue rename to apps/vitepress/theme/components/ui/badge/Badge.vue diff --git a/packages/docs/vitepress/theme/components/ui/badge/index.ts b/apps/vitepress/theme/components/ui/badge/index.ts similarity index 100% rename from packages/docs/vitepress/theme/components/ui/badge/index.ts rename to apps/vitepress/theme/components/ui/badge/index.ts diff --git a/packages/docs/vitepress/theme/components/ui/badge/variants.ts b/apps/vitepress/theme/components/ui/badge/variants.ts similarity index 100% rename from packages/docs/vitepress/theme/components/ui/badge/variants.ts rename to apps/vitepress/theme/components/ui/badge/variants.ts diff --git a/packages/docs/vitepress/theme/components/ui/card/Card.vue b/apps/vitepress/theme/components/ui/card/Card.vue similarity index 100% rename from packages/docs/vitepress/theme/components/ui/card/Card.vue rename to apps/vitepress/theme/components/ui/card/Card.vue diff --git a/packages/docs/vitepress/theme/components/ui/card/CardAction.vue b/apps/vitepress/theme/components/ui/card/CardAction.vue similarity index 100% rename from packages/docs/vitepress/theme/components/ui/card/CardAction.vue rename to apps/vitepress/theme/components/ui/card/CardAction.vue diff --git a/packages/docs/vitepress/theme/components/ui/card/CardContent.vue b/apps/vitepress/theme/components/ui/card/CardContent.vue similarity index 100% rename from packages/docs/vitepress/theme/components/ui/card/CardContent.vue rename to apps/vitepress/theme/components/ui/card/CardContent.vue diff --git a/packages/docs/vitepress/theme/components/ui/card/CardDescription.vue b/apps/vitepress/theme/components/ui/card/CardDescription.vue similarity index 100% rename from packages/docs/vitepress/theme/components/ui/card/CardDescription.vue rename to apps/vitepress/theme/components/ui/card/CardDescription.vue diff --git a/packages/docs/vitepress/theme/components/ui/card/CardFooter.vue b/apps/vitepress/theme/components/ui/card/CardFooter.vue similarity index 100% rename from packages/docs/vitepress/theme/components/ui/card/CardFooter.vue rename to apps/vitepress/theme/components/ui/card/CardFooter.vue diff --git a/packages/docs/vitepress/theme/components/ui/card/CardHeader.vue b/apps/vitepress/theme/components/ui/card/CardHeader.vue similarity index 100% rename from packages/docs/vitepress/theme/components/ui/card/CardHeader.vue rename to apps/vitepress/theme/components/ui/card/CardHeader.vue diff --git a/packages/docs/vitepress/theme/components/ui/card/CardTitle.vue b/apps/vitepress/theme/components/ui/card/CardTitle.vue similarity index 100% rename from packages/docs/vitepress/theme/components/ui/card/CardTitle.vue rename to apps/vitepress/theme/components/ui/card/CardTitle.vue diff --git a/packages/docs/vitepress/theme/components/ui/card/index.ts b/apps/vitepress/theme/components/ui/card/index.ts similarity index 100% rename from packages/docs/vitepress/theme/components/ui/card/index.ts rename to apps/vitepress/theme/components/ui/card/index.ts diff --git a/packages/docs/vitepress/theme/components/ui/separator/Separator.vue b/apps/vitepress/theme/components/ui/separator/Separator.vue similarity index 100% rename from packages/docs/vitepress/theme/components/ui/separator/Separator.vue rename to apps/vitepress/theme/components/ui/separator/Separator.vue diff --git a/packages/docs/vitepress/theme/components/ui/separator/index.ts b/apps/vitepress/theme/components/ui/separator/index.ts similarity index 100% rename from packages/docs/vitepress/theme/components/ui/separator/index.ts rename to apps/vitepress/theme/components/ui/separator/index.ts diff --git a/packages/docs/vitepress/theme/index.ts b/apps/vitepress/theme/index.ts similarity index 100% rename from packages/docs/vitepress/theme/index.ts rename to apps/vitepress/theme/index.ts diff --git a/packages/docs/vitepress/theme/lib/utils.ts b/apps/vitepress/theme/lib/utils.ts similarity index 100% rename from packages/docs/vitepress/theme/lib/utils.ts rename to apps/vitepress/theme/lib/utils.ts diff --git a/packages/docs/vitepress/theme/styles.css b/apps/vitepress/theme/styles.css similarity index 100% rename from packages/docs/vitepress/theme/styles.css rename to apps/vitepress/theme/styles.css diff --git a/apps/vitepress/tsconfig.json b/apps/vitepress/tsconfig.json new file mode 100644 index 00000000..e753fe3c --- /dev/null +++ b/apps/vitepress/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "@ollin/console-infra/tsconfig/base.json", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@docs-components/*": ["theme/components/*"], + "@docs-lib/*": ["theme/lib/*"], + "@docs-theme/*": ["theme/*"], + "@docs-ui/*": ["theme/components/ui/*"] + }, + "tsBuildInfoFile": "../../infra/.cache/tsbuild/vitepress.tsbuildinfo" + }, + "include": ["config.ts", "theme/**/*.ts", "vue.d.ts"] +} diff --git a/packages/docs/vitepress/vue.d.ts b/apps/vitepress/vue.d.ts similarity index 100% rename from packages/docs/vitepress/vue.d.ts rename to apps/vitepress/vue.d.ts diff --git a/infra/README.md b/infra/README.md new file mode 100644 index 00000000..3dc18dd5 --- /dev/null +++ b/infra/README.md @@ -0,0 +1,29 @@ +# Infra Workspace + +The `infra` workspace (`@ollin/console-infra`) owns project tooling that is not part of +the public package runtime: + +- `src` exposes shared TypeScript path helpers (`infraRoot`, `cacheDir`, + `workspacePath`, …) for the tool cache. +- `tsconfig` holds the centralized TypeScript configs every package extends + (`base.json`, `console.json`, `tests.json`), published via the + `@ollin/console-infra/tsconfig/*.json` exports. +- `make` holds the modular Makefile. The repo-root `Makefile` is a transparent + forwarder into `infra/make/Makefile`. +- `scripts/run-turbo.mjs` is the single entry point for workspace turbo tasks + (`build`, `test`, `typecheck`). It points turbo's cache, logs, and XDG/Playwright + env at `infra/.cache`, then deletes the per-package `.turbo/turbo-.log` + dirs turbo writes into each workspace (turbo offers no flag to relocate those). +- `.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/pnpm-store` + +Do not add package-local cache directories for new tools. Add a named helper in +`infra/src/index.ts` and point the tool at `infra/.cache/`. diff --git a/infra/make/Makefile b/infra/make/Makefile new file mode 100644 index 00000000..b208344c --- /dev/null +++ b/infra/make/Makefile @@ -0,0 +1,15 @@ +SHELL := /bin/bash + +INFRA_MAKE_DIR := $(abspath $(dir $(lastword $(MAKEFILE_LIST)))) +ROOT_PATH := $(abspath $(INFRA_MAKE_DIR)/../..) +INFRA_MAKE_DIR := $(ROOT_PATH)/infra/make + +# Shared helpers/vars first (defines run_in, paths, etc.), then each concern. +# All make target definitions live under infra/make so they have one owner. +include $(INFRA_MAKE_DIR)/shared.mk +include $(INFRA_MAKE_DIR)/turbo.mk +include $(INFRA_MAKE_DIR)/format.mk +include $(INFRA_MAKE_DIR)/dev.mk +include $(INFRA_MAKE_DIR)/fresh.mk + +.DEFAULT_GOAL := help diff --git a/infra/make/dev.mk b/infra/make/dev.mk new file mode 100644 index 00000000..84f70da7 --- /dev/null +++ b/infra/make/dev.mk @@ -0,0 +1,5 @@ +.PHONY: dev + +##@ Development +dev: ## Run the docs dev server + $(call run_in,$(ROOT_PATH),pnpm --filter docs dev) diff --git a/infra/make/format.mk b/infra/make/format.mk new file mode 100644 index 00000000..c5238fb9 --- /dev/null +++ b/infra/make/format.mk @@ -0,0 +1,8 @@ +.PHONY: format format-all + +##@ 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 diff --git a/infra/make/fresh.mk b/infra/make/fresh.mk new file mode 100644 index 00000000..cb42b43b --- /dev/null +++ b/infra/make/fresh.mk @@ -0,0 +1,28 @@ +.PHONY: fresh + +##@ 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)"/pkgs-ts/*/node_modules \ + "$(ROOT_PATH)"/apps/*/node_modules \ + "$(ROOT_PATH)/pkgs-go/node_modules" \ + "$(ROOT_PATH)/vitepress/node_modules" \ + "$(ROOT_PATH)/infra/node_modules" \ + "$(ROOT_PATH)/infra/.cache" \ + "$(ROOT_PATH)/apps/docs/src/.vitepress/cache" \ + "$(ROOT_PATH)/apps/docs/src/.vitepress/dist" \ + "$(ROOT_PATH)/apps/docs/src/api" \ + "$(ROOT_PATH)/pkgs-ts/console/dist" \ + "$(ROOT_PATH)/pkgs-ts/infra" + $(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)/infra/.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/infra/make/shared.mk b/infra/make/shared.mk new file mode 100644 index 00000000..f4064b59 --- /dev/null +++ b/infra/make/shared.mk @@ -0,0 +1,29 @@ +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' + +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 + +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 "" diff --git a/infra/make/turbo.mk b/infra/make/turbo.mk new file mode 100644 index 00000000..46d03728 --- /dev/null +++ b/infra/make/turbo.mk @@ -0,0 +1,5 @@ +.PHONY: test + +##@ Quality +test: ## Run the workspace test suite from the repo root + $(call run_in,$(ROOT_PATH),pnpm run test) diff --git a/infra/package.json b/infra/package.json new file mode 100644 index 00000000..6e45f8fa --- /dev/null +++ b/infra/package.json @@ -0,0 +1,23 @@ +{ + "name": "@ollin/console-infra", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./tsconfig/*.json": "./tsconfig/*.json" + }, + "imports": { + "#infra/*": "./src/*.ts" + }, + "scripts": { + "typecheck": "XDG_CACHE_HOME=.cache/xdg PLAYWRIGHT_BROWSERS_PATH=.cache/playwright tsc -p tsconfig.json --noEmit" + }, + "devDependencies": { + "@types/node": "^25.6.2", + "typescript": "^6.0.3" + } +} diff --git a/infra/scripts/run-turbo.mjs b/infra/scripts/run-turbo.mjs new file mode 100644 index 00000000..81b59fcc --- /dev/null +++ b/infra/scripts/run-turbo.mjs @@ -0,0 +1,91 @@ +// Single entry point for workspace turbo tasks. +// +// Turbo writes its content cache wherever `--cache-dir` points (we send it to +// infra/.cache/turbo), but it ALSO drops a per-package `.turbo/turbo-.log` +// into every workspace dir and offers no flag to relocate those. This runner +// centralizes the cache env/flags in one place and deletes the stray `.turbo` +// dirs after the run so `infra/.cache` stays the only cache root on disk. +// +// Usage: node infra/scripts/run-turbo.mjs [outputLogs] +// turbo task to run (build | test | typecheck) +// [outputLogs] value for --output-logs (default: none) + +import { spawnSync } from 'node:child_process'; +import { existsSync, mkdirSync, readdirSync, rmSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const infraRoot = resolve(scriptDir, '..'); +const repoRoot = resolve(infraRoot, '..'); +const cacheRoot = resolve(infraRoot, '.cache'); + +const turboCache = join(cacheRoot, 'turbo'); +const task = process.argv[2]; +const outputLogs = process.argv[3] ?? 'none'; + +if (!task) { + console.error('run-turbo: missing task name (build | test | typecheck)'); + process.exit(2); +} + +// Skip node_modules/.git when hunting for stray .turbo dirs; never matches the +// real cache bucket, which is named `turbo` (no dot) under infra/.cache. +const PRUNE = new Set(['node_modules', '.git']); + +function removeStrayTurboDirs(dir) { + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + if (!entry.isDirectory()) { + continue; + } + if (entry.name === '.turbo') { + rmSync(join(dir, entry.name), { recursive: true, force: true }); + continue; + } + if (PRUNE.has(entry.name)) { + continue; + } + removeStrayTurboDirs(join(dir, entry.name)); + } +} + +mkdirSync(join(turboCache, 'logs'), { recursive: true }); + +const turboBin = join(repoRoot, 'node_modules', '.bin', 'turbo'); +const command = existsSync(turboBin) ? turboBin : 'turbo'; + +let exitCode = 1; +try { + const result = spawnSync( + command, + [ + 'run', + task, + '--cache-dir', + turboCache, + '--log-file', + join(turboCache, 'logs', `${task}.json`), + `--output-logs=${outputLogs}`, + ], + { + cwd: repoRoot, + stdio: 'inherit', + env: { + ...process.env, + XDG_CACHE_HOME: join(cacheRoot, 'xdg'), + PLAYWRIGHT_BROWSERS_PATH: join(cacheRoot, 'playwright'), + }, + }, + ); + exitCode = result.status ?? 1; +} finally { + removeStrayTurboDirs(repoRoot); +} + +process.exit(exitCode); diff --git a/infra/src/index.ts b/infra/src/index.ts new file mode 100644 index 00000000..b2f2b353 --- /dev/null +++ b/infra/src/index.ts @@ -0,0 +1,27 @@ +import { fileURLToPath } from 'node:url'; +import { resolve } from 'node:path'; + +// infra/ lives at the repo root and owns the single mutable tool-cache root. +export const infraRoot = resolve(fileURLToPath(new URL('..', import.meta.url))); + +export const cacheRoot = resolve(infraRoot, '.cache'); + +export const repoRoot = resolve(infraRoot, '..'); + +export const repoPath = (...segments: string[]): string => resolve(repoRoot, ...segments); + +export const workspacePath = (...segments: string[]): string => repoPath('pkgs-ts', ...segments); + +export const cacheDir = (...segments: string[]): string => resolve(cacheRoot, ...segments); + +// Named buckets, one per tool. Add new tools here and point them at +// infra/.cache/ rather than introducing package-local cache directories. +export const turboCacheDir = (...segments: string[]): string => cacheDir('turbo', ...segments); + +export const vitestCacheDir = (...segments: string[]): string => cacheDir('vitest', ...segments); + +export const tsBuildCacheDir = (...segments: string[]): string => cacheDir('tsbuild', ...segments); + +export const playwrightCacheDir = (...segments: string[]): string => cacheDir('playwright', ...segments); + +export const xdgCacheDir = (...segments: string[]): string => cacheDir('xdg', ...segments); diff --git a/infra/tsconfig.json b/infra/tsconfig.json new file mode 100644 index 00000000..5ccb2ab4 --- /dev/null +++ b/infra/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig/base.json", + "compilerOptions": { + "baseUrl": ".", + "tsBuildInfoFile": "./.cache/tsbuild/infra.tsbuildinfo", + "paths": { + "#infra/*": ["./src/*"] + } + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/tsconfig/base.json b/infra/tsconfig/base.json similarity index 100% rename from packages/tsconfig/base.json rename to infra/tsconfig/base.json diff --git a/infra/tsconfig/console.json b/infra/tsconfig/console.json new file mode 100644 index 00000000..57d7b495 --- /dev/null +++ b/infra/tsconfig/console.json @@ -0,0 +1,5 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "../.cache/tsbuild/console.tsbuildinfo" + } +} diff --git a/infra/tsconfig/tests.json b/infra/tsconfig/tests.json new file mode 100644 index 00000000..3db97d85 --- /dev/null +++ b/infra/tsconfig/tests.json @@ -0,0 +1,5 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "../.cache/tsbuild/tests.tsbuildinfo" + } +} diff --git a/package.json b/package.json index b3f39def..04c1bd55 100644 --- a/package.json +++ b/package.json @@ -4,16 +4,15 @@ "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", + "build": "node infra/scripts/run-turbo.mjs build 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" + "test": "node infra/scripts/run-turbo.mjs test full", + "typecheck": "node infra/scripts/run-turbo.mjs typecheck none" }, "devDependencies": { - "@ollin/artefacts": "workspace:*", - "@ollin/tsconfig": "workspace:*", + "@ollin/console-infra": "workspace:*", "@types/node": "^25.6.2", "turbo": "^2.9.12", "typescript": "^6.0.3", diff --git a/packages/acceptance/package.json b/packages/acceptance/package.json deleted file mode 100644 index 7a184d1f..00000000 --- a/packages/acceptance/package.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "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 deleted file mode 100644 index 9b7bd211..00000000 --- a/packages/acceptance/tests/helpers.test.ts +++ /dev/null @@ -1,121 +0,0 @@ -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/tsconfig.json b/packages/acceptance/tsconfig.json deleted file mode 100644 index e10cbc7c..00000000 --- a/packages/acceptance/tsconfig.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "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 deleted file mode 100644 index 17427c87..00000000 --- a/packages/acceptance/vitest.config.ts +++ /dev/null @@ -1,30 +0,0 @@ -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 deleted file mode 100644 index af007a96..00000000 --- a/packages/artefacts/package.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "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 deleted file mode 100644 index 740a539e..00000000 --- a/packages/artefacts/src/index.ts +++ /dev/null @@ -1,34 +0,0 @@ -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 deleted file mode 100644 index 7519ca98..00000000 --- a/packages/artefacts/tsconfig.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "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 deleted file mode 100644 index 7de753e2..00000000 --- a/packages/artefacts/tsconfig/acceptance.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "compilerOptions": { - "tsBuildInfoFile": "../../../provision/.cache/tsbuild/acceptance.tsbuildinfo" - } -} diff --git a/packages/artefacts/tsconfig/tui.json b/packages/artefacts/tsconfig/tui.json deleted file mode 100644 index 058cde9e..00000000 --- a/packages/artefacts/tsconfig/tui.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "compilerOptions": { - "tsBuildInfoFile": "../../../provision/.cache/tsbuild/tui.tsbuildinfo" - } -} diff --git a/packages/docs/examples/output.ts b/packages/docs/examples/output.ts deleted file mode 100644 index d1421886..00000000 --- a/packages/docs/examples/output.ts +++ /dev/null @@ -1,31 +0,0 @@ -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/status.ts b/packages/docs/examples/status.ts deleted file mode 100644 index 4b49da2a..00000000 --- a/packages/docs/examples/status.ts +++ /dev/null @@ -1,25 +0,0 @@ -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/package.json b/packages/docs/package.json deleted file mode 100644 index 5ea6d6c4..00000000 --- a/packages/docs/package.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "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/introduction.md b/packages/docs/src/guide/introduction.md deleted file mode 100644 index 61b69e75..00000000 --- a/packages/docs/src/guide/introduction.md +++ /dev/null @@ -1,74 +0,0 @@ -# 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/tsconfig.json b/packages/docs/tsconfig.json deleted file mode 100644 index cc1e8a5b..00000000 --- a/packages/docs/tsconfig.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "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 deleted file mode 100644 index c163a56f..00000000 --- a/packages/docs/typedoc.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "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/vitest.config.ts b/packages/docs/vitest.config.ts deleted file mode 100644 index a6c77d2f..00000000 --- a/packages/docs/vitest.config.ts +++ /dev/null @@ -1,66 +0,0 @@ -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/package.json b/packages/tsconfig/package.json deleted file mode 100644 index d224a6e4..00000000 --- a/packages/tsconfig/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "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 deleted file mode 100644 index c85e8c6a..00000000 --- a/packages/tui/package.json +++ /dev/null @@ -1,209 +0,0 @@ -{ - "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 deleted file mode 100644 index 23b72cdb..00000000 --- a/packages/tui/src/concerns/choice-label.ts +++ /dev/null @@ -1,9 +0,0 @@ -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/choices.ts b/packages/tui/src/concerns/choices.ts deleted file mode 100644 index 3d7b060f..00000000 --- a/packages/tui/src/concerns/choices.ts +++ /dev/null @@ -1,4 +0,0 @@ -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 deleted file mode 100644 index cbd411c7..00000000 --- a/packages/tui/src/concerns/info.ts +++ /dev/null @@ -1,15 +0,0 @@ -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 deleted file mode 100644 index 4619cdbc..00000000 --- a/packages/tui/src/concerns/scrollbar.ts +++ /dev/null @@ -1,42 +0,0 @@ -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 deleted file mode 100644 index afdf6055..00000000 --- a/packages/tui/src/concerns/text-options.ts +++ /dev/null @@ -1,10 +0,0 @@ -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/info.ts b/packages/tui/src/concerns/validators/info.ts deleted file mode 100644 index 3fcb3fa2..00000000 --- a/packages/tui/src/concerns/validators/info.ts +++ /dev/null @@ -1,10 +0,0 @@ -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/contracts/environment.ts b/packages/tui/src/contracts/environment.ts deleted file mode 100644 index fc0ea335..00000000 --- a/packages/tui/src/contracts/environment.ts +++ /dev/null @@ -1,17 +0,0 @@ -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 deleted file mode 100644 index 2263fdaf..00000000 --- a/packages/tui/src/contracts/options.ts +++ /dev/null @@ -1,9 +0,0 @@ -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 deleted file mode 100644 index 238beef4..00000000 --- a/packages/tui/src/contracts/output.ts +++ /dev/null @@ -1,24 +0,0 @@ -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 deleted file mode 100644 index 3f74f228..00000000 --- a/packages/tui/src/contracts/prompts.ts +++ /dev/null @@ -1,66 +0,0 @@ -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/environment.ts b/packages/tui/src/environment.ts deleted file mode 100644 index c625150d..00000000 --- a/packages/tui/src/environment.ts +++ /dev/null @@ -1,5 +0,0 @@ -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/memory-output.ts b/packages/tui/src/environment/memory-output.ts deleted file mode 100644 index 3fc2b6ce..00000000 --- a/packages/tui/src/environment/memory-output.ts +++ /dev/null @@ -1,23 +0,0 @@ -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 deleted file mode 100644 index 6dc0d4ea..00000000 --- a/packages/tui/src/environment/raw-key.ts +++ /dev/null @@ -1,27 +0,0 @@ -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/session.ts b/packages/tui/src/environment/raw-key/session.ts deleted file mode 100644 index f7884df7..00000000 --- a/packages/tui/src/environment/raw-key/session.ts +++ /dev/null @@ -1,94 +0,0 @@ -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/runtime.ts b/packages/tui/src/environment/runtime.ts deleted file mode 100644 index 1feafba7..00000000 --- a/packages/tui/src/environment/runtime.ts +++ /dev/null @@ -1,11 +0,0 @@ -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 deleted file mode 100644 index 6e042ac0..00000000 --- a/packages/tui/src/environment/scope.ts +++ /dev/null @@ -1,32 +0,0 @@ -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 deleted file mode 100644 index 892382f3..00000000 --- a/packages/tui/src/environment/scope/merge.ts +++ /dev/null @@ -1,12 +0,0 @@ -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/scripted-input.ts b/packages/tui/src/environment/scripted-input.ts deleted file mode 100644 index b1db05b4..00000000 --- a/packages/tui/src/environment/scripted-input.ts +++ /dev/null @@ -1,15 +0,0 @@ -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 deleted file mode 100644 index 9aa6c6d9..00000000 --- a/packages/tui/src/environment/stream-output.ts +++ /dev/null @@ -1,14 +0,0 @@ -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/stream-output.ts b/packages/tui/src/environment/validators/stream-output.ts deleted file mode 100644 index 34032db9..00000000 --- a/packages/tui/src/environment/validators/stream-output.ts +++ /dev/null @@ -1,18 +0,0 @@ -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 deleted file mode 100644 index 98646ace..00000000 --- a/packages/tui/src/form.ts +++ /dev/null @@ -1,15 +0,0 @@ -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 deleted file mode 100644 index 4d2b3caa..00000000 --- a/packages/tui/src/form/builder/conditions.ts +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 5b4f73d5..00000000 --- a/packages/tui/src/form/builder/index.ts +++ /dev/null @@ -1,50 +0,0 @@ -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 deleted file mode 100644 index e5575e7a..00000000 --- a/packages/tui/src/form/builder/methods.ts +++ /dev/null @@ -1,52 +0,0 @@ -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 deleted file mode 100644 index 2506901b..00000000 --- a/packages/tui/src/form/builder/output.ts +++ /dev/null @@ -1,38 +0,0 @@ -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/data-table.ts b/packages/tui/src/form/builder/output/data-table.ts deleted file mode 100644 index 4f2b6f50..00000000 --- a/packages/tui/src/form/builder/output/data-table.ts +++ /dev/null @@ -1,46 +0,0 @@ -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/index.ts b/packages/tui/src/form/builder/output/index.ts deleted file mode 100644 index 18f28855..00000000 --- a/packages/tui/src/form/builder/output/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -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 deleted file mode 100644 index 2b610874..00000000 --- a/packages/tui/src/form/builder/output/notification.ts +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 0d9b10df..00000000 --- a/packages/tui/src/form/builder/output/table.ts +++ /dev/null @@ -1,36 +0,0 @@ -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 deleted file mode 100644 index 7ae6d250..00000000 --- a/packages/tui/src/form/builder/output/terminal.ts +++ /dev/null @@ -1,10 +0,0 @@ -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/prompts.ts b/packages/tui/src/form/builder/prompts.ts deleted file mode 100644 index 31c017af..00000000 --- a/packages/tui/src/form/builder/prompts.ts +++ /dev/null @@ -1,11 +0,0 @@ -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 deleted file mode 100644 index 1713b987..00000000 --- a/packages/tui/src/form/builder/prompts/basic.ts +++ /dev/null @@ -1,10 +0,0 @@ -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 deleted file mode 100644 index 3d2ef7bc..00000000 --- a/packages/tui/src/form/builder/prompts/basic/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -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 deleted file mode 100644 index 9b4c6f6b..00000000 --- a/packages/tui/src/form/builder/prompts/basic/number.ts +++ /dev/null @@ -1,62 +0,0 @@ -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 deleted file mode 100644 index c9c9f96b..00000000 --- a/packages/tui/src/form/builder/prompts/basic/number/defaults.ts +++ /dev/null @@ -1,20 +0,0 @@ -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 deleted file mode 100644 index 98b798d9..00000000 --- a/packages/tui/src/form/builder/prompts/basic/number/options.ts +++ /dev/null @@ -1,54 +0,0 @@ -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 deleted file mode 100644 index fa3b8b8b..00000000 --- a/packages/tui/src/form/builder/prompts/basic/number/step.ts +++ /dev/null @@ -1,11 +0,0 @@ -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 deleted file mode 100644 index 8b4d0ba6..00000000 --- a/packages/tui/src/form/builder/prompts/basic/password.ts +++ /dev/null @@ -1,33 +0,0 @@ -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 deleted file mode 100644 index d5eb4730..00000000 --- a/packages/tui/src/form/builder/prompts/basic/text.ts +++ /dev/null @@ -1,35 +0,0 @@ -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 deleted file mode 100644 index dc19f8d3..00000000 --- a/packages/tui/src/form/builder/prompts/basic/textarea.ts +++ /dev/null @@ -1,37 +0,0 @@ -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 deleted file mode 100644 index 3c93b90b..00000000 --- a/packages/tui/src/form/builder/prompts/choice/autocomplete.ts +++ /dev/null @@ -1,40 +0,0 @@ -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 deleted file mode 100644 index 20ef2295..00000000 --- a/packages/tui/src/form/builder/prompts/choice/confirm.ts +++ /dev/null @@ -1,37 +0,0 @@ -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 deleted file mode 100644 index c3394d13..00000000 --- a/packages/tui/src/form/builder/prompts/choice/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -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 deleted file mode 100644 index a9ade6e3..00000000 --- a/packages/tui/src/form/builder/prompts/choice/multisearch.ts +++ /dev/null @@ -1,39 +0,0 @@ -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 deleted file mode 100644 index 372e43ae..00000000 --- a/packages/tui/src/form/builder/prompts/choice/multiselect.ts +++ /dev/null @@ -1,39 +0,0 @@ -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 deleted file mode 100644 index d56165b6..00000000 --- a/packages/tui/src/form/builder/prompts/choice/pause.ts +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index c4087278..00000000 --- a/packages/tui/src/form/builder/prompts/choice/search.ts +++ /dev/null @@ -1,39 +0,0 @@ -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 deleted file mode 100644 index 3e06ad10..00000000 --- a/packages/tui/src/form/builder/prompts/choice/select.ts +++ /dev/null @@ -1,39 +0,0 @@ -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 deleted file mode 100644 index 5ad47066..00000000 --- a/packages/tui/src/form/builder/prompts/choice/suggest.ts +++ /dev/null @@ -1,42 +0,0 @@ -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 deleted file mode 100644 index 7cb9be43..00000000 --- a/packages/tui/src/form/builder/prompts/choices.ts +++ /dev/null @@ -1,24 +0,0 @@ -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 deleted file mode 100644 index 46f27a1f..00000000 --- a/packages/tui/src/form/builder/prompts/contracts/basic.ts +++ /dev/null @@ -1,57 +0,0 @@ -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 deleted file mode 100644 index 3901ccc1..00000000 --- a/packages/tui/src/form/builder/prompts/contracts/choices.ts +++ /dev/null @@ -1,17 +0,0 @@ -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 deleted file mode 100644 index ccb7c424..00000000 --- a/packages/tui/src/form/builder/prompts/contracts/choices/autocomplete.ts +++ /dev/null @@ -1,20 +0,0 @@ -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 deleted file mode 100644 index 52c2df09..00000000 --- a/packages/tui/src/form/builder/prompts/contracts/choices/confirm.ts +++ /dev/null @@ -1,18 +0,0 @@ -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 deleted file mode 100644 index 04f78ea5..00000000 --- a/packages/tui/src/form/builder/prompts/contracts/choices/multisearch.ts +++ /dev/null @@ -1,19 +0,0 @@ -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 deleted file mode 100644 index cdccf478..00000000 --- a/packages/tui/src/form/builder/prompts/contracts/choices/multiselect.ts +++ /dev/null @@ -1,19 +0,0 @@ -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 deleted file mode 100644 index 679a9d4f..00000000 --- a/packages/tui/src/form/builder/prompts/contracts/choices/pause.ts +++ /dev/null @@ -1,5 +0,0 @@ -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 deleted file mode 100644 index ff855f9f..00000000 --- a/packages/tui/src/form/builder/prompts/contracts/choices/search.ts +++ /dev/null @@ -1,19 +0,0 @@ -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 deleted file mode 100644 index f76ebc02..00000000 --- a/packages/tui/src/form/builder/prompts/contracts/choices/select.ts +++ /dev/null @@ -1,19 +0,0 @@ -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 deleted file mode 100644 index 3760d9b6..00000000 --- a/packages/tui/src/form/builder/prompts/contracts/choices/suggest.ts +++ /dev/null @@ -1,21 +0,0 @@ -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 deleted file mode 100644 index 3edc9899..00000000 --- a/packages/tui/src/form/builder/prompts/contracts/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -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 deleted file mode 100644 index cc3b5efa..00000000 --- a/packages/tui/src/form/builder/prompts/types.ts +++ /dev/null @@ -1,3 +0,0 @@ -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.ts b/packages/tui/src/form/builder/prompts/validators/basic.ts deleted file mode 100644 index c5b8597c..00000000 --- a/packages/tui/src/form/builder/prompts/validators/basic.ts +++ /dev/null @@ -1,7 +0,0 @@ -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 deleted file mode 100644 index f6d3e632..00000000 --- a/packages/tui/src/form/builder/prompts/validators/search.ts +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 0befbd99..00000000 --- a/packages/tui/src/form/builder/prompts/validators/search/common.ts +++ /dev/null @@ -1,36 +0,0 @@ -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 deleted file mode 100644 index a1b93cf5..00000000 --- a/packages/tui/src/form/builder/prompts/validators/search/multisearch.ts +++ /dev/null @@ -1,43 +0,0 @@ -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 deleted file mode 100644 index 8aee2cde..00000000 --- a/packages/tui/src/form/builder/prompts/validators/search/search.ts +++ /dev/null @@ -1,43 +0,0 @@ -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 deleted file mode 100644 index 66fafe55..00000000 --- a/packages/tui/src/form/builder/prompts/validators/select.ts +++ /dev/null @@ -1,7 +0,0 @@ -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 deleted file mode 100644 index 6efa49ef..00000000 --- a/packages/tui/src/form/builder/prompts/validators/select/common.ts +++ /dev/null @@ -1,36 +0,0 @@ -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 deleted file mode 100644 index 947cf4ec..00000000 --- a/packages/tui/src/form/builder/prompts/validators/select/confirm.ts +++ /dev/null @@ -1,41 +0,0 @@ -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 deleted file mode 100644 index 21f68065..00000000 --- a/packages/tui/src/form/builder/prompts/validators/select/multiselect.ts +++ /dev/null @@ -1,42 +0,0 @@ -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 deleted file mode 100644 index bbbe9c89..00000000 --- a/packages/tui/src/form/builder/prompts/validators/select/select.ts +++ /dev/null @@ -1,42 +0,0 @@ -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 deleted file mode 100644 index c18d51ad..00000000 --- a/packages/tui/src/form/builder/prompts/validators/suggest.ts +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 8f9b2327..00000000 --- a/packages/tui/src/form/builder/prompts/validators/suggest/autocomplete.ts +++ /dev/null @@ -1,44 +0,0 @@ -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 deleted file mode 100644 index 9e3231f3..00000000 --- a/packages/tui/src/form/builder/prompts/validators/suggest/common.ts +++ /dev/null @@ -1,33 +0,0 @@ -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 deleted file mode 100644 index f7af1779..00000000 --- a/packages/tui/src/form/builder/prompts/validators/suggest/suggest.ts +++ /dev/null @@ -1,46 +0,0 @@ -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/revert.ts b/packages/tui/src/form/builder/revert.ts deleted file mode 100644 index 739114f7..00000000 --- a/packages/tui/src/form/builder/revert.ts +++ /dev/null @@ -1,42 +0,0 @@ -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 deleted file mode 100644 index 37ff4439..00000000 --- a/packages/tui/src/form/builder/status.ts +++ /dev/null @@ -1,23 +0,0 @@ -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 deleted file mode 100644 index e51471b9..00000000 --- a/packages/tui/src/form/builder/status/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -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 deleted file mode 100644 index 64915bd6..00000000 --- a/packages/tui/src/form/builder/status/progress.ts +++ /dev/null @@ -1,38 +0,0 @@ -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 deleted file mode 100644 index 20d25120..00000000 --- a/packages/tui/src/form/builder/status/spin.ts +++ /dev/null @@ -1,18 +0,0 @@ -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 deleted file mode 100644 index 5892bca3..00000000 --- a/packages/tui/src/form/builder/status/stream.ts +++ /dev/null @@ -1,19 +0,0 @@ -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 deleted file mode 100644 index 637f0127..00000000 --- a/packages/tui/src/form/builder/status/task.ts +++ /dev/null @@ -1,27 +0,0 @@ -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 deleted file mode 100644 index c0f30adc..00000000 --- a/packages/tui/src/form/builder/step.ts +++ /dev/null @@ -1,9 +0,0 @@ -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 deleted file mode 100644 index 57c30985..00000000 --- a/packages/tui/src/form/builder/submit.ts +++ /dev/null @@ -1,46 +0,0 @@ -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 deleted file mode 100644 index e38689a0..00000000 --- a/packages/tui/src/form/builder/validators/conditions.ts +++ /dev/null @@ -1,15 +0,0 @@ -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 deleted file mode 100644 index 4a1b32c5..00000000 --- a/packages/tui/src/form/builder/validators/output.ts +++ /dev/null @@ -1,5 +0,0 @@ -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/data-table.ts b/packages/tui/src/form/builder/validators/output/data-table.ts deleted file mode 100644 index 3bd37999..00000000 --- a/packages/tui/src/form/builder/validators/output/data-table.ts +++ /dev/null @@ -1,61 +0,0 @@ -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 deleted file mode 100644 index 4eca758c..00000000 --- a/packages/tui/src/form/builder/validators/output/table.ts +++ /dev/null @@ -1,59 +0,0 @@ -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/revert.ts b/packages/tui/src/form/builder/validators/revert.ts deleted file mode 100644 index a942e66b..00000000 --- a/packages/tui/src/form/builder/validators/revert.ts +++ /dev/null @@ -1,8 +0,0 @@ -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 deleted file mode 100644 index dc672f82..00000000 --- a/packages/tui/src/form/builder/validators/status.ts +++ /dev/null @@ -1,9 +0,0 @@ -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 deleted file mode 100644 index 3758f215..00000000 --- a/packages/tui/src/form/builder/validators/status/common.ts +++ /dev/null @@ -1,50 +0,0 @@ -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 deleted file mode 100644 index 77ca388c..00000000 --- a/packages/tui/src/form/builder/validators/status/progress.ts +++ /dev/null @@ -1,46 +0,0 @@ -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 deleted file mode 100644 index dfac6e12..00000000 --- a/packages/tui/src/form/builder/validators/status/spin.ts +++ /dev/null @@ -1,38 +0,0 @@ -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 deleted file mode 100644 index 576dcb99..00000000 --- a/packages/tui/src/form/builder/validators/status/stream.ts +++ /dev/null @@ -1,32 +0,0 @@ -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 deleted file mode 100644 index 4ca5745c..00000000 --- a/packages/tui/src/form/builder/validators/status/task.ts +++ /dev/null @@ -1,48 +0,0 @@ -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 deleted file mode 100644 index a5941ca5..00000000 --- a/packages/tui/src/form/types.ts +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 3be5aeaf..00000000 --- a/packages/tui/src/index.ts +++ /dev/null @@ -1,83 +0,0 @@ -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 deleted file mode 100644 index 44382ce1..00000000 --- a/packages/tui/src/key.ts +++ /dev/null @@ -1,4 +0,0 @@ -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/event.ts b/packages/tui/src/key/event.ts deleted file mode 100644 index c89e727c..00000000 --- a/packages/tui/src/key/event.ts +++ /dev/null @@ -1,59 +0,0 @@ -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/value.ts b/packages/tui/src/key/event/value.ts deleted file mode 100644 index b6fb2f2c..00000000 --- a/packages/tui/src/key/event/value.ts +++ /dev/null @@ -1,15 +0,0 @@ -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 deleted file mode 100644 index a5204feb..00000000 --- a/packages/tui/src/key/match.ts +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 19091507..00000000 --- a/packages/tui/src/key/types.ts +++ /dev/null @@ -1,13 +0,0 @@ -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 deleted file mode 100644 index beb73bfe..00000000 --- a/packages/tui/src/key/validators/event.ts +++ /dev/null @@ -1,18 +0,0 @@ -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 deleted file mode 100644 index 2d70a9ec..00000000 --- a/packages/tui/src/key/validators/match.ts +++ /dev/null @@ -1,16 +0,0 @@ -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 deleted file mode 100644 index bcb879e2..00000000 --- a/packages/tui/src/key/validators/value.ts +++ /dev/null @@ -1,26 +0,0 @@ -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/output.ts b/packages/tui/src/output.ts deleted file mode 100644 index db10a4f6..00000000 --- a/packages/tui/src/output.ts +++ /dev/null @@ -1,7 +0,0 @@ -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 deleted file mode 100644 index db43acf9..00000000 --- a/packages/tui/src/output/data-table.ts +++ /dev/null @@ -1,35 +0,0 @@ -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 deleted file mode 100644 index f5b3fb29..00000000 --- a/packages/tui/src/output/data-table/body/active.ts +++ /dev/null @@ -1,38 +0,0 @@ -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 deleted file mode 100644 index db2c346d..00000000 --- a/packages/tui/src/output/data-table/body/cancelled.ts +++ /dev/null @@ -1,20 +0,0 @@ -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/submitted.ts b/packages/tui/src/output/data-table/body/submitted.ts deleted file mode 100644 index 6e657049..00000000 --- a/packages/tui/src/output/data-table/body/submitted.ts +++ /dev/null @@ -1,8 +0,0 @@ -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 deleted file mode 100644 index 2ce3ad5b..00000000 --- a/packages/tui/src/output/data-table/body/types.ts +++ /dev/null @@ -1,11 +0,0 @@ -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 deleted file mode 100644 index c9280adc..00000000 --- a/packages/tui/src/output/data-table/keys.ts +++ /dev/null @@ -1,33 +0,0 @@ -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/navigation.ts b/packages/tui/src/output/data-table/navigation.ts deleted file mode 100644 index d25bf1ef..00000000 --- a/packages/tui/src/output/data-table/navigation.ts +++ /dev/null @@ -1,53 +0,0 @@ -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 deleted file mode 100644 index e6911a8c..00000000 --- a/packages/tui/src/output/data-table/options.ts +++ /dev/null @@ -1,45 +0,0 @@ -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 deleted file mode 100644 index 347fc08b..00000000 --- a/packages/tui/src/output/data-table/read.ts +++ /dev/null @@ -1,15 +0,0 @@ -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 deleted file mode 100644 index c1eaa854..00000000 --- a/packages/tui/src/output/data-table/reader/cancel.ts +++ /dev/null @@ -1,43 +0,0 @@ -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 deleted file mode 100644 index dfdd2fff..00000000 --- a/packages/tui/src/output/data-table/reader/fallback.ts +++ /dev/null @@ -1,21 +0,0 @@ -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 deleted file mode 100644 index 3a0e71c2..00000000 --- a/packages/tui/src/output/data-table/reader/interactive.ts +++ /dev/null @@ -1,58 +0,0 @@ -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 deleted file mode 100644 index ec3c52c6..00000000 --- a/packages/tui/src/output/data-table/reader/result.ts +++ /dev/null @@ -1,48 +0,0 @@ -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 deleted file mode 100644 index c8e7e541..00000000 --- a/packages/tui/src/output/data-table/reader/session.ts +++ /dev/null @@ -1,62 +0,0 @@ -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 deleted file mode 100644 index f4aeaeb6..00000000 --- a/packages/tui/src/output/data-table/reader/session/render.ts +++ /dev/null @@ -1,25 +0,0 @@ -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 deleted file mode 100644 index 133f1006..00000000 --- a/packages/tui/src/output/data-table/reader/state.ts +++ /dev/null @@ -1,70 +0,0 @@ -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 deleted file mode 100644 index a83d4c49..00000000 --- a/packages/tui/src/output/data-table/reader/state/search.ts +++ /dev/null @@ -1,12 +0,0 @@ -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 deleted file mode 100644 index e32f6a71..00000000 --- a/packages/tui/src/output/data-table/reader/state/selection.ts +++ /dev/null @@ -1,31 +0,0 @@ -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 deleted file mode 100644 index 42360b01..00000000 --- a/packages/tui/src/output/data-table/reader/types.ts +++ /dev/null @@ -1,5 +0,0 @@ -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 deleted file mode 100644 index ebd11d9f..00000000 --- a/packages/tui/src/output/data-table/render-body.ts +++ /dev/null @@ -1,5 +0,0 @@ -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 deleted file mode 100644 index eb9187ab..00000000 --- a/packages/tui/src/output/data-table/render.ts +++ /dev/null @@ -1,61 +0,0 @@ -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 deleted file mode 100644 index 5e263843..00000000 --- a/packages/tui/src/output/data-table/rows.ts +++ /dev/null @@ -1,4 +0,0 @@ -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 deleted file mode 100644 index 062577f2..00000000 --- a/packages/tui/src/output/data-table/rows/cells.ts +++ /dev/null @@ -1,5 +0,0 @@ -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/headers.ts b/packages/tui/src/output/data-table/rows/headers.ts deleted file mode 100644 index b7ded314..00000000 --- a/packages/tui/src/output/data-table/rows/headers.ts +++ /dev/null @@ -1,19 +0,0 @@ -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 deleted file mode 100644 index 22555f64..00000000 --- a/packages/tui/src/output/data-table/rows/visible.ts +++ /dev/null @@ -1,15 +0,0 @@ -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 deleted file mode 100644 index e81e4576..00000000 --- a/packages/tui/src/output/data-table/run.ts +++ /dev/null @@ -1,48 +0,0 @@ -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 deleted file mode 100644 index b0b51b4b..00000000 --- a/packages/tui/src/output/data-table/scrollbar.ts +++ /dev/null @@ -1,11 +0,0 @@ -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 deleted file mode 100644 index 82dbf924..00000000 --- a/packages/tui/src/output/data-table/search.ts +++ /dev/null @@ -1,65 +0,0 @@ -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 deleted file mode 100644 index 85f448ac..00000000 --- a/packages/tui/src/output/data-table/selection.ts +++ /dev/null @@ -1,22 +0,0 @@ -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 deleted file mode 100644 index 1c5d089a..00000000 --- a/packages/tui/src/output/data-table/types.ts +++ /dev/null @@ -1,15 +0,0 @@ -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/widths.ts b/packages/tui/src/output/data-table/widths.ts deleted file mode 100644 index 4565a66e..00000000 --- a/packages/tui/src/output/data-table/widths.ts +++ /dev/null @@ -1,17 +0,0 @@ -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/columns.ts b/packages/tui/src/output/data-table/widths/columns.ts deleted file mode 100644 index 52d7a86d..00000000 --- a/packages/tui/src/output/data-table/widths/columns.ts +++ /dev/null @@ -1,22 +0,0 @@ -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/truncate.ts b/packages/tui/src/output/data-table/widths/truncate.ts deleted file mode 100644 index 28780424..00000000 --- a/packages/tui/src/output/data-table/widths/truncate.ts +++ /dev/null @@ -1,18 +0,0 @@ -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/grid-output.ts b/packages/tui/src/output/grid-output.ts deleted file mode 100644 index dd9ccaab..00000000 --- a/packages/tui/src/output/grid-output.ts +++ /dev/null @@ -1,12 +0,0 @@ -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 deleted file mode 100644 index f80e47f3..00000000 --- a/packages/tui/src/output/grid.ts +++ /dev/null @@ -1,19 +0,0 @@ -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/lines.ts b/packages/tui/src/output/grid/lines.ts deleted file mode 100644 index 66fb0b5d..00000000 --- a/packages/tui/src/output/grid/lines.ts +++ /dev/null @@ -1,24 +0,0 @@ -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/notes.ts b/packages/tui/src/output/notes.ts deleted file mode 100644 index 389542a5..00000000 --- a/packages/tui/src/output/notes.ts +++ /dev/null @@ -1,21 +0,0 @@ -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 deleted file mode 100644 index 9dcc0362..00000000 --- a/packages/tui/src/output/notes/render.ts +++ /dev/null @@ -1,49 +0,0 @@ -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/table-output.ts b/packages/tui/src/output/table-output.ts deleted file mode 100644 index 95920245..00000000 --- a/packages/tui/src/output/table-output.ts +++ /dev/null @@ -1,13 +0,0 @@ -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 deleted file mode 100644 index 531dee3c..00000000 --- a/packages/tui/src/output/table.ts +++ /dev/null @@ -1,2 +0,0 @@ -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 deleted file mode 100644 index 4c1444d5..00000000 --- a/packages/tui/src/output/terminal.ts +++ /dev/null @@ -1,9 +0,0 @@ -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 deleted file mode 100644 index 96ae0a07..00000000 --- a/packages/tui/src/output/validators/cells.ts +++ /dev/null @@ -1,8 +0,0 @@ -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 deleted file mode 100644 index d70a3d9f..00000000 --- a/packages/tui/src/output/validators/data-table.ts +++ /dev/null @@ -1,10 +0,0 @@ -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 deleted file mode 100644 index 4e900f9f..00000000 --- a/packages/tui/src/output/validators/data-table/options.ts +++ /dev/null @@ -1,83 +0,0 @@ -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/schemas.ts b/packages/tui/src/output/validators/data-table/schemas.ts deleted file mode 100644 index ea81c4ad..00000000 --- a/packages/tui/src/output/validators/data-table/schemas.ts +++ /dev/null @@ -1,31 +0,0 @@ -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 deleted file mode 100644 index 760d8045..00000000 --- a/packages/tui/src/output/validators/table.ts +++ /dev/null @@ -1,58 +0,0 @@ -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 deleted file mode 100644 index 4da15eab..00000000 --- a/packages/tui/src/output/validators/table/rows.ts +++ /dev/null @@ -1,34 +0,0 @@ -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 deleted file mode 100644 index 6772fd0a..00000000 --- a/packages/tui/src/output/validators/table/schemas.ts +++ /dev/null @@ -1,18 +0,0 @@ -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 deleted file mode 100644 index 04338177..00000000 --- a/packages/tui/src/prompt.ts +++ /dev/null @@ -1,10 +0,0 @@ -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/ask.ts b/packages/tui/src/prompt/ask.ts deleted file mode 100644 index 8cfd2690..00000000 --- a/packages/tui/src/prompt/ask.ts +++ /dev/null @@ -1,13 +0,0 @@ -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 deleted file mode 100644 index a401d2f3..00000000 --- a/packages/tui/src/prompt/cancel.ts +++ /dev/null @@ -1,32 +0,0 @@ -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/fallback.ts b/packages/tui/src/prompt/fallback.ts deleted file mode 100644 index a6897412..00000000 --- a/packages/tui/src/prompt/fallback.ts +++ /dev/null @@ -1,71 +0,0 @@ -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 deleted file mode 100644 index bd08bab6..00000000 --- a/packages/tui/src/prompt/lifecycle.ts +++ /dev/null @@ -1,15 +0,0 @@ -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 deleted file mode 100644 index fa977cbc..00000000 --- a/packages/tui/src/prompt/lifecycle/interactive.ts +++ /dev/null @@ -1,47 +0,0 @@ -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 deleted file mode 100644 index 8de8ff22..00000000 --- a/packages/tui/src/prompt/lifecycle/non-interactive.ts +++ /dev/null @@ -1,16 +0,0 @@ -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/validation.ts b/packages/tui/src/prompt/lifecycle/validation.ts deleted file mode 100644 index 2131455a..00000000 --- a/packages/tui/src/prompt/lifecycle/validation.ts +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index d47cdce1..00000000 --- a/packages/tui/src/prompt/revert.ts +++ /dev/null @@ -1,6 +0,0 @@ -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/validation.ts b/packages/tui/src/prompt/validation.ts deleted file mode 100644 index 081ddae2..00000000 --- a/packages/tui/src/prompt/validation.ts +++ /dev/null @@ -1,26 +0,0 @@ -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/error.ts b/packages/tui/src/prompt/validators/error.ts deleted file mode 100644 index 9d7be16a..00000000 --- a/packages/tui/src/prompt/validators/error.ts +++ /dev/null @@ -1,8 +0,0 @@ -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 deleted file mode 100644 index 3070bb88..00000000 --- a/packages/tui/src/prompt/validators/fallback.ts +++ /dev/null @@ -1,28 +0,0 @@ -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/prompts.ts b/packages/tui/src/prompts.ts deleted file mode 100644 index b8d4e569..00000000 --- a/packages/tui/src/prompts.ts +++ /dev/null @@ -1,3 +0,0 @@ -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 deleted file mode 100644 index 5bf4ecef..00000000 --- a/packages/tui/src/prompts/basic.ts +++ /dev/null @@ -1,4 +0,0 @@ -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 deleted file mode 100644 index 34ae33b4..00000000 --- a/packages/tui/src/prompts/choices.ts +++ /dev/null @@ -1,5 +0,0 @@ -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 deleted file mode 100644 index c8b6bcf3..00000000 --- a/packages/tui/src/prompts/number.ts +++ /dev/null @@ -1,47 +0,0 @@ -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 deleted file mode 100644 index 88984312..00000000 --- a/packages/tui/src/prompts/number/defaults.ts +++ /dev/null @@ -1,35 +0,0 @@ -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 deleted file mode 100644 index 70bd08d5..00000000 --- a/packages/tui/src/prompts/number/fallback.ts +++ /dev/null @@ -1,19 +0,0 @@ -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 deleted file mode 100644 index 2373760c..00000000 --- a/packages/tui/src/prompts/number/input.ts +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index ecd62798..00000000 --- a/packages/tui/src/prompts/number/interactive.ts +++ /dev/null @@ -1,51 +0,0 @@ -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 deleted file mode 100644 index d9587f36..00000000 --- a/packages/tui/src/prompts/number/options.ts +++ /dev/null @@ -1,49 +0,0 @@ -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 deleted file mode 100644 index 5774f253..00000000 --- a/packages/tui/src/prompts/number/render.ts +++ /dev/null @@ -1,49 +0,0 @@ -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 deleted file mode 100644 index 8ba251e6..00000000 --- a/packages/tui/src/prompts/number/run.ts +++ /dev/null @@ -1,75 +0,0 @@ -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 deleted file mode 100644 index 1be619c9..00000000 --- a/packages/tui/src/prompts/number/session.ts +++ /dev/null @@ -1,55 +0,0 @@ -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 deleted file mode 100644 index 81414cdb..00000000 --- a/packages/tui/src/prompts/number/session/state.ts +++ /dev/null @@ -1,43 +0,0 @@ -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 deleted file mode 100644 index d1e3e284..00000000 --- a/packages/tui/src/prompts/number/step.ts +++ /dev/null @@ -1,30 +0,0 @@ -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/validators/value.ts b/packages/tui/src/prompts/number/validators/value.ts deleted file mode 100644 index bcd260e7..00000000 --- a/packages/tui/src/prompts/number/validators/value.ts +++ /dev/null @@ -1,52 +0,0 @@ -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 deleted file mode 100644 index 8d67e82d..00000000 --- a/packages/tui/src/prompts/password.ts +++ /dev/null @@ -1,34 +0,0 @@ -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 deleted file mode 100644 index 9fb8ca71..00000000 --- a/packages/tui/src/prompts/password/fallback.ts +++ /dev/null @@ -1,19 +0,0 @@ -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 deleted file mode 100644 index c8090815..00000000 --- a/packages/tui/src/prompts/password/input.ts +++ /dev/null @@ -1,46 +0,0 @@ -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 deleted file mode 100644 index 6752a3eb..00000000 --- a/packages/tui/src/prompts/password/options.ts +++ /dev/null @@ -1,28 +0,0 @@ -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 deleted file mode 100644 index 2d9bd378..00000000 --- a/packages/tui/src/prompts/password/render.ts +++ /dev/null @@ -1,33 +0,0 @@ -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 deleted file mode 100644 index 6c2c51a5..00000000 --- a/packages/tui/src/prompts/password/run.ts +++ /dev/null @@ -1,50 +0,0 @@ -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 deleted file mode 100644 index 146c5140..00000000 --- a/packages/tui/src/prompts/password/session.ts +++ /dev/null @@ -1,56 +0,0 @@ -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/pause.ts b/packages/tui/src/prompts/pause.ts deleted file mode 100644 index 84533ea7..00000000 --- a/packages/tui/src/prompts/pause.ts +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 6f09a2c9..00000000 --- a/packages/tui/src/prompts/pause/read.ts +++ /dev/null @@ -1,36 +0,0 @@ -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 deleted file mode 100644 index e78fb23b..00000000 --- a/packages/tui/src/prompts/pause/render.ts +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index fc790bbd..00000000 --- a/packages/tui/src/prompts/search/body/render.ts +++ /dev/null @@ -1,24 +0,0 @@ -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 deleted file mode 100644 index c668c977..00000000 --- a/packages/tui/src/prompts/search/body/rows.ts +++ /dev/null @@ -1,44 +0,0 @@ -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 deleted file mode 100644 index 883c18c2..00000000 --- a/packages/tui/src/prompts/search/choices.ts +++ /dev/null @@ -1,23 +0,0 @@ -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 deleted file mode 100644 index d604a929..00000000 --- a/packages/tui/src/prompts/search/defaults.ts +++ /dev/null @@ -1,41 +0,0 @@ -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 deleted file mode 100644 index 9012ff36..00000000 --- a/packages/tui/src/prompts/search/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -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 deleted file mode 100644 index 63e3999a..00000000 --- a/packages/tui/src/prompts/search/keys.ts +++ /dev/null @@ -1,4 +0,0 @@ -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 deleted file mode 100644 index de9a8672..00000000 --- a/packages/tui/src/prompts/search/keys/highlight.ts +++ /dev/null @@ -1,36 +0,0 @@ -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 deleted file mode 100644 index ad1eeff9..00000000 --- a/packages/tui/src/prompts/search/keys/navigation.ts +++ /dev/null @@ -1,36 +0,0 @@ -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 deleted file mode 100644 index a17fb5c9..00000000 --- a/packages/tui/src/prompts/search/line-mode.ts +++ /dev/null @@ -1,56 +0,0 @@ -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 deleted file mode 100644 index ff957983..00000000 --- a/packages/tui/src/prompts/search/multi-options.ts +++ /dev/null @@ -1,29 +0,0 @@ -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 deleted file mode 100644 index 616876f8..00000000 --- a/packages/tui/src/prompts/search/multi-run.ts +++ /dev/null @@ -1,45 +0,0 @@ -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 deleted file mode 100644 index 7f65e15d..00000000 --- a/packages/tui/src/prompts/search/multisearch.ts +++ /dev/null @@ -1,31 +0,0 @@ -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 deleted file mode 100644 index bee523dd..00000000 --- a/packages/tui/src/prompts/search/navigation.ts +++ /dev/null @@ -1,46 +0,0 @@ -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 deleted file mode 100644 index 4d4e966a..00000000 --- a/packages/tui/src/prompts/search/options.ts +++ /dev/null @@ -1,29 +0,0 @@ -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 deleted file mode 100644 index 0c3d9af9..00000000 --- a/packages/tui/src/prompts/search/read-multi.ts +++ /dev/null @@ -1,55 +0,0 @@ -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 deleted file mode 100644 index ab4c19d9..00000000 --- a/packages/tui/src/prompts/search/read-multi/cancel.ts +++ /dev/null @@ -1,21 +0,0 @@ -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 deleted file mode 100644 index 924b6899..00000000 --- a/packages/tui/src/prompts/search/read-multi/choice-query.ts +++ /dev/null @@ -1,45 +0,0 @@ -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 deleted file mode 100644 index 9fb3f17f..00000000 --- a/packages/tui/src/prompts/search/read-multi/frame.ts +++ /dev/null @@ -1,40 +0,0 @@ -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 deleted file mode 100644 index 9c6ffecc..00000000 --- a/packages/tui/src/prompts/search/read-multi/keys.ts +++ /dev/null @@ -1,31 +0,0 @@ -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 deleted file mode 100644 index 8ea86580..00000000 --- a/packages/tui/src/prompts/search/read-multi/result.ts +++ /dev/null @@ -1,33 +0,0 @@ -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 deleted file mode 100644 index 09fde679..00000000 --- a/packages/tui/src/prompts/search/read-multi/session.ts +++ /dev/null @@ -1,63 +0,0 @@ -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 deleted file mode 100644 index 7e156cfd..00000000 --- a/packages/tui/src/prompts/search/read-multi/session/actions.ts +++ /dev/null @@ -1,29 +0,0 @@ -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 deleted file mode 100644 index aa7c12eb..00000000 --- a/packages/tui/src/prompts/search/read-multi/state.ts +++ /dev/null @@ -1,67 +0,0 @@ -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 deleted file mode 100644 index 39961a34..00000000 --- a/packages/tui/src/prompts/search/read-multi/state/context.ts +++ /dev/null @@ -1,26 +0,0 @@ -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 deleted file mode 100644 index 8f6a7bb2..00000000 --- a/packages/tui/src/prompts/search/read-multi/state/displayed.ts +++ /dev/null @@ -1,29 +0,0 @@ -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 deleted file mode 100644 index 0a7abae6..00000000 --- a/packages/tui/src/prompts/search/read-multi/state/highlight.ts +++ /dev/null @@ -1,25 +0,0 @@ -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 deleted file mode 100644 index 9812beb1..00000000 --- a/packages/tui/src/prompts/search/read-multi/state/input.ts +++ /dev/null @@ -1,10 +0,0 @@ -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 deleted file mode 100644 index ae45f121..00000000 --- a/packages/tui/src/prompts/search/read-multi/state/labels.ts +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index b372db2e..00000000 --- a/packages/tui/src/prompts/search/read-multi/state/selection.ts +++ /dev/null @@ -1,24 +0,0 @@ -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 deleted file mode 100644 index a21857af..00000000 --- a/packages/tui/src/prompts/search/read-single.ts +++ /dev/null @@ -1,15 +0,0 @@ -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 deleted file mode 100644 index caa257c9..00000000 --- a/packages/tui/src/prompts/search/read-single/choice-query.ts +++ /dev/null @@ -1,44 +0,0 @@ -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 deleted file mode 100644 index de104b9e..00000000 --- a/packages/tui/src/prompts/search/read-single/interactive.ts +++ /dev/null @@ -1,58 +0,0 @@ -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 deleted file mode 100644 index 20c26473..00000000 --- a/packages/tui/src/prompts/search/read-single/interactive/cancel.ts +++ /dev/null @@ -1,23 +0,0 @@ -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 deleted file mode 100644 index 66d6fc97..00000000 --- a/packages/tui/src/prompts/search/read-single/interactive/result.ts +++ /dev/null @@ -1,37 +0,0 @@ -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 deleted file mode 100644 index 198e0fb2..00000000 --- a/packages/tui/src/prompts/search/read-single/result.ts +++ /dev/null @@ -1,40 +0,0 @@ -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 deleted file mode 100644 index 8994dad9..00000000 --- a/packages/tui/src/prompts/search/read-single/session.ts +++ /dev/null @@ -1,60 +0,0 @@ -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 deleted file mode 100644 index 2d60f6a8..00000000 --- a/packages/tui/src/prompts/search/read-single/session/actions.ts +++ /dev/null @@ -1,24 +0,0 @@ -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 deleted file mode 100644 index 62895009..00000000 --- a/packages/tui/src/prompts/search/read-single/session/frame.ts +++ /dev/null @@ -1,28 +0,0 @@ -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 deleted file mode 100644 index e9ee2506..00000000 --- a/packages/tui/src/prompts/search/read-single/state.ts +++ /dev/null @@ -1,59 +0,0 @@ -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 deleted file mode 100644 index 9e62b368..00000000 --- a/packages/tui/src/prompts/search/read-single/state/highlight.ts +++ /dev/null @@ -1,26 +0,0 @@ -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 deleted file mode 100644 index 390824da..00000000 --- a/packages/tui/src/prompts/search/read-single/state/selection.ts +++ /dev/null @@ -1,25 +0,0 @@ -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 deleted file mode 100644 index 867bde40..00000000 --- a/packages/tui/src/prompts/search/read-single/types.ts +++ /dev/null @@ -1,11 +0,0 @@ -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 deleted file mode 100644 index 71f70664..00000000 --- a/packages/tui/src/prompts/search/render-body.ts +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index fe112f2f..00000000 --- a/packages/tui/src/prompts/search/render.ts +++ /dev/null @@ -1,50 +0,0 @@ -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 deleted file mode 100644 index 5da5bf25..00000000 --- a/packages/tui/src/prompts/search/run.ts +++ /dev/null @@ -1,50 +0,0 @@ -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 deleted file mode 100644 index 8c70533e..00000000 --- a/packages/tui/src/prompts/search/search.ts +++ /dev/null @@ -1,31 +0,0 @@ -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 deleted file mode 100644 index b9906c7b..00000000 --- a/packages/tui/src/prompts/search/selection.ts +++ /dev/null @@ -1,73 +0,0 @@ -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/options.ts b/packages/tui/src/prompts/search/validators/options.ts deleted file mode 100644 index 046e1ff3..00000000 --- a/packages/tui/src/prompts/search/validators/options.ts +++ /dev/null @@ -1,10 +0,0 @@ -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/source.ts b/packages/tui/src/prompts/search/validators/source.ts deleted file mode 100644 index a3c40101..00000000 --- a/packages/tui/src/prompts/search/validators/source.ts +++ /dev/null @@ -1,40 +0,0 @@ -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-run.ts b/packages/tui/src/prompts/select/confirm-run.ts deleted file mode 100644 index bca1688f..00000000 --- a/packages/tui/src/prompts/select/confirm-run.ts +++ /dev/null @@ -1,45 +0,0 @@ -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 deleted file mode 100644 index e95786a7..00000000 --- a/packages/tui/src/prompts/select/confirm.ts +++ /dev/null @@ -1,29 +0,0 @@ -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 deleted file mode 100644 index 39502cca..00000000 --- a/packages/tui/src/prompts/select/defaults.ts +++ /dev/null @@ -1,41 +0,0 @@ -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 deleted file mode 100644 index 9166d706..00000000 --- a/packages/tui/src/prompts/select/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -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 deleted file mode 100644 index 36a12d9f..00000000 --- a/packages/tui/src/prompts/select/keys.ts +++ /dev/null @@ -1,62 +0,0 @@ -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/multiselect.ts b/packages/tui/src/prompts/select/multiselect.ts deleted file mode 100644 index 8de52e50..00000000 --- a/packages/tui/src/prompts/select/multiselect.ts +++ /dev/null @@ -1,31 +0,0 @@ -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 deleted file mode 100644 index 8312d77f..00000000 --- a/packages/tui/src/prompts/select/multiselect/options.ts +++ /dev/null @@ -1,25 +0,0 @@ -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 deleted file mode 100644 index c8e6a01b..00000000 --- a/packages/tui/src/prompts/select/multiselect/run.ts +++ /dev/null @@ -1,46 +0,0 @@ -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 deleted file mode 100644 index f8cf57b3..00000000 --- a/packages/tui/src/prompts/select/navigation.ts +++ /dev/null @@ -1,54 +0,0 @@ -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 deleted file mode 100644 index 083ddddb..00000000 --- a/packages/tui/src/prompts/select/options.ts +++ /dev/null @@ -1,41 +0,0 @@ -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 deleted file mode 100644 index 06ae5201..00000000 --- a/packages/tui/src/prompts/select/read-confirm.ts +++ /dev/null @@ -1,59 +0,0 @@ -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 deleted file mode 100644 index c14bace7..00000000 --- a/packages/tui/src/prompts/select/read-confirm/keys.ts +++ /dev/null @@ -1,37 +0,0 @@ -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 deleted file mode 100644 index 41f49b19..00000000 --- a/packages/tui/src/prompts/select/read-confirm/line-mode.ts +++ /dev/null @@ -1,16 +0,0 @@ -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 deleted file mode 100644 index 91861643..00000000 --- a/packages/tui/src/prompts/select/read-confirm/session.ts +++ /dev/null @@ -1,46 +0,0 @@ -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 deleted file mode 100644 index 55533143..00000000 --- a/packages/tui/src/prompts/select/read-confirm/types.ts +++ /dev/null @@ -1,12 +0,0 @@ -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 deleted file mode 100644 index c97789c0..00000000 --- a/packages/tui/src/prompts/select/read-multiple.ts +++ /dev/null @@ -1,24 +0,0 @@ -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 deleted file mode 100644 index d4b480e3..00000000 --- a/packages/tui/src/prompts/select/read-multiple/cancel.ts +++ /dev/null @@ -1,17 +0,0 @@ -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 deleted file mode 100644 index 1a4852b9..00000000 --- a/packages/tui/src/prompts/select/read-multiple/interactive.ts +++ /dev/null @@ -1,46 +0,0 @@ -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 deleted file mode 100644 index 6d34f8c4..00000000 --- a/packages/tui/src/prompts/select/read-multiple/keys.ts +++ /dev/null @@ -1,51 +0,0 @@ -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 deleted file mode 100644 index d53c9aa0..00000000 --- a/packages/tui/src/prompts/select/read-multiple/line-mode.ts +++ /dev/null @@ -1,16 +0,0 @@ -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 deleted file mode 100644 index d80a0fe0..00000000 --- a/packages/tui/src/prompts/select/read-multiple/result.ts +++ /dev/null @@ -1,32 +0,0 @@ -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 deleted file mode 100644 index d7c6b3f5..00000000 --- a/packages/tui/src/prompts/select/read-multiple/session.ts +++ /dev/null @@ -1,62 +0,0 @@ -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 deleted file mode 100644 index d9788092..00000000 --- a/packages/tui/src/prompts/select/read-multiple/session/frame.ts +++ /dev/null @@ -1,25 +0,0 @@ -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 deleted file mode 100644 index 5cd6dbfb..00000000 --- a/packages/tui/src/prompts/select/read-multiple/session/state.ts +++ /dev/null @@ -1,46 +0,0 @@ -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-selected.ts b/packages/tui/src/prompts/select/read-selected.ts deleted file mode 100644 index 6de56ce6..00000000 --- a/packages/tui/src/prompts/select/read-selected.ts +++ /dev/null @@ -1,25 +0,0 @@ -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 deleted file mode 100644 index 794f8e7f..00000000 --- a/packages/tui/src/prompts/select/read-selected/interactive.ts +++ /dev/null @@ -1,51 +0,0 @@ -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 deleted file mode 100644 index b0cb7cb4..00000000 --- a/packages/tui/src/prompts/select/read-selected/interactive/cancel.ts +++ /dev/null @@ -1,26 +0,0 @@ -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 deleted file mode 100644 index 970ecef1..00000000 --- a/packages/tui/src/prompts/select/read-selected/interactive/result.ts +++ /dev/null @@ -1,22 +0,0 @@ -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 deleted file mode 100644 index 3f98fd77..00000000 --- a/packages/tui/src/prompts/select/read-selected/line-mode.ts +++ /dev/null @@ -1,34 +0,0 @@ -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 deleted file mode 100644 index 4b86ef9a..00000000 --- a/packages/tui/src/prompts/select/read-selected/result.ts +++ /dev/null @@ -1,46 +0,0 @@ -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 deleted file mode 100644 index 648ba064..00000000 --- a/packages/tui/src/prompts/select/read-selected/session.ts +++ /dev/null @@ -1,47 +0,0 @@ -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/render-rows/window.ts b/packages/tui/src/prompts/select/render-rows/window.ts deleted file mode 100644 index 13214a2f..00000000 --- a/packages/tui/src/prompts/select/render-rows/window.ts +++ /dev/null @@ -1,10 +0,0 @@ -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 deleted file mode 100644 index a002fc66..00000000 --- a/packages/tui/src/prompts/select/render.ts +++ /dev/null @@ -1,44 +0,0 @@ -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 deleted file mode 100644 index d4e81847..00000000 --- a/packages/tui/src/prompts/select/run.ts +++ /dev/null @@ -1,60 +0,0 @@ -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 deleted file mode 100644 index 8c1ea79d..00000000 --- a/packages/tui/src/prompts/select/select.ts +++ /dev/null @@ -1,33 +0,0 @@ -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/options.ts b/packages/tui/src/prompts/select/validators/options.ts deleted file mode 100644 index c109c45b..00000000 --- a/packages/tui/src/prompts/select/validators/options.ts +++ /dev/null @@ -1,10 +0,0 @@ -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/suggest/autocomplete-prompt.ts b/packages/tui/src/prompts/suggest/autocomplete-prompt.ts deleted file mode 100644 index 6ad1202f..00000000 --- a/packages/tui/src/prompts/suggest/autocomplete-prompt.ts +++ /dev/null @@ -1,39 +0,0 @@ -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 deleted file mode 100644 index c0c5976e..00000000 --- a/packages/tui/src/prompts/suggest/autocomplete.ts +++ /dev/null @@ -1,32 +0,0 @@ -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/index.ts b/packages/tui/src/prompts/suggest/index.ts deleted file mode 100644 index 6b291309..00000000 --- a/packages/tui/src/prompts/suggest/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -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 deleted file mode 100644 index e9b644fb..00000000 --- a/packages/tui/src/prompts/suggest/keys.ts +++ /dev/null @@ -1,58 +0,0 @@ -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 deleted file mode 100644 index e389e847..00000000 --- a/packages/tui/src/prompts/suggest/lifecycle.ts +++ /dev/null @@ -1,56 +0,0 @@ -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 deleted file mode 100644 index 0394f6a4..00000000 --- a/packages/tui/src/prompts/suggest/navigation.ts +++ /dev/null @@ -1,35 +0,0 @@ -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 deleted file mode 100644 index 0b58028b..00000000 --- a/packages/tui/src/prompts/suggest/options.ts +++ /dev/null @@ -1,27 +0,0 @@ -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 deleted file mode 100644 index e1d8b282..00000000 --- a/packages/tui/src/prompts/suggest/read-autocomplete.ts +++ /dev/null @@ -1,62 +0,0 @@ -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 deleted file mode 100644 index a965c900..00000000 --- a/packages/tui/src/prompts/suggest/read-autocomplete/cancel.ts +++ /dev/null @@ -1,16 +0,0 @@ -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 deleted file mode 100644 index a8848a2c..00000000 --- a/packages/tui/src/prompts/suggest/read-autocomplete/frame.ts +++ /dev/null @@ -1,32 +0,0 @@ -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 deleted file mode 100644 index 9a9529b6..00000000 --- a/packages/tui/src/prompts/suggest/read-autocomplete/session.ts +++ /dev/null @@ -1,54 +0,0 @@ -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 deleted file mode 100644 index e00748c4..00000000 --- a/packages/tui/src/prompts/suggest/read-autocomplete/state.ts +++ /dev/null @@ -1,61 +0,0 @@ -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 deleted file mode 100644 index eb3ca479..00000000 --- a/packages/tui/src/prompts/suggest/read-autocomplete/state/highlight.ts +++ /dev/null @@ -1,23 +0,0 @@ -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 deleted file mode 100644 index a574284c..00000000 --- a/packages/tui/src/prompts/suggest/read-autocomplete/submission.ts +++ /dev/null @@ -1,7 +0,0 @@ -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.ts b/packages/tui/src/prompts/suggest/read.ts deleted file mode 100644 index b02ba5b6..00000000 --- a/packages/tui/src/prompts/suggest/read.ts +++ /dev/null @@ -1,59 +0,0 @@ -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 deleted file mode 100644 index fec4286d..00000000 --- a/packages/tui/src/prompts/suggest/read/cancel.ts +++ /dev/null @@ -1,16 +0,0 @@ -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 deleted file mode 100644 index f77f3aa6..00000000 --- a/packages/tui/src/prompts/suggest/read/frame.ts +++ /dev/null @@ -1,32 +0,0 @@ -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 deleted file mode 100644 index 40282405..00000000 --- a/packages/tui/src/prompts/suggest/read/session.ts +++ /dev/null @@ -1,62 +0,0 @@ -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 deleted file mode 100644 index 7fa27137..00000000 --- a/packages/tui/src/prompts/suggest/read/state.ts +++ /dev/null @@ -1,53 +0,0 @@ -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 deleted file mode 100644 index ddf43524..00000000 --- a/packages/tui/src/prompts/suggest/read/state/highlight.ts +++ /dev/null @@ -1,24 +0,0 @@ -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 deleted file mode 100644 index 64233990..00000000 --- a/packages/tui/src/prompts/suggest/read/submission.ts +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 5634957d..00000000 --- a/packages/tui/src/prompts/suggest/render-autocomplete.ts +++ /dev/null @@ -1,29 +0,0 @@ -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 deleted file mode 100644 index 5213ece6..00000000 --- a/packages/tui/src/prompts/suggest/render.ts +++ /dev/null @@ -1,35 +0,0 @@ -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 deleted file mode 100644 index 089bc87e..00000000 --- a/packages/tui/src/prompts/suggest/render/body.ts +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 4e6748d5..00000000 --- a/packages/tui/src/prompts/suggest/render/rows.ts +++ /dev/null @@ -1,19 +0,0 @@ -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 deleted file mode 100644 index aae7f4b6..00000000 --- a/packages/tui/src/prompts/suggest/resolve.ts +++ /dev/null @@ -1,14 +0,0 @@ -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/suggest.ts b/packages/tui/src/prompts/suggest/suggest.ts deleted file mode 100644 index fe624723..00000000 --- a/packages/tui/src/prompts/suggest/suggest.ts +++ /dev/null @@ -1,38 +0,0 @@ -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/source.ts b/packages/tui/src/prompts/suggest/validators/source.ts deleted file mode 100644 index 2d757c3a..00000000 --- a/packages/tui/src/prompts/suggest/validators/source.ts +++ /dev/null @@ -1,44 +0,0 @@ -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.ts b/packages/tui/src/prompts/text.ts deleted file mode 100644 index 28311c71..00000000 --- a/packages/tui/src/prompts/text.ts +++ /dev/null @@ -1,37 +0,0 @@ -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 deleted file mode 100644 index 18620151..00000000 --- a/packages/tui/src/prompts/text/options.ts +++ /dev/null @@ -1,30 +0,0 @@ -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 deleted file mode 100644 index 80d6094e..00000000 --- a/packages/tui/src/prompts/text/run.ts +++ /dev/null @@ -1,50 +0,0 @@ -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 deleted file mode 100644 index 7b91271a..00000000 --- a/packages/tui/src/prompts/textarea.ts +++ /dev/null @@ -1,40 +0,0 @@ -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 deleted file mode 100644 index 103b1985..00000000 --- a/packages/tui/src/prompts/textarea/options.ts +++ /dev/null @@ -1,35 +0,0 @@ -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 deleted file mode 100644 index 1fd0fbf1..00000000 --- a/packages/tui/src/prompts/textarea/run.ts +++ /dev/null @@ -1,52 +0,0 @@ -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 deleted file mode 100644 index 6defe50c..00000000 --- a/packages/tui/src/status.ts +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 8949913e..00000000 --- a/packages/tui/src/status/frame.ts +++ /dev/null @@ -1,15 +0,0 @@ -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 deleted file mode 100644 index 34c69c71..00000000 --- a/packages/tui/src/status/progress.ts +++ /dev/null @@ -1,35 +0,0 @@ -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 deleted file mode 100644 index 42686c51..00000000 --- a/packages/tui/src/status/progress/controller.ts +++ /dev/null @@ -1,42 +0,0 @@ -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 deleted file mode 100644 index bd1b84a8..00000000 --- a/packages/tui/src/status/progress/labels.ts +++ /dev/null @@ -1,9 +0,0 @@ -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 deleted file mode 100644 index 372c1cbe..00000000 --- a/packages/tui/src/status/progress/progress.ts +++ /dev/null @@ -1,68 +0,0 @@ -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/readback.ts b/packages/tui/src/status/progress/readback.ts deleted file mode 100644 index a658855f..00000000 --- a/packages/tui/src/status/progress/readback.ts +++ /dev/null @@ -1,11 +0,0 @@ -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 deleted file mode 100644 index f6bfe93b..00000000 --- a/packages/tui/src/status/progress/render.ts +++ /dev/null @@ -1,37 +0,0 @@ -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 deleted file mode 100644 index ea413cde..00000000 --- a/packages/tui/src/status/progress/renderer.ts +++ /dev/null @@ -1,16 +0,0 @@ -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 deleted file mode 100644 index 569599d5..00000000 --- a/packages/tui/src/status/progress/run.ts +++ /dev/null @@ -1,34 +0,0 @@ -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 deleted file mode 100644 index 6fd84ea9..00000000 --- a/packages/tui/src/status/progress/state.ts +++ /dev/null @@ -1,59 +0,0 @@ -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/value.ts b/packages/tui/src/status/progress/state/value.ts deleted file mode 100644 index 6f80cf9d..00000000 --- a/packages/tui/src/status/progress/state/value.ts +++ /dev/null @@ -1,17 +0,0 @@ -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/terminal.ts b/packages/tui/src/status/progress/terminal.ts deleted file mode 100644 index f1837e6d..00000000 --- a/packages/tui/src/status/progress/terminal.ts +++ /dev/null @@ -1,55 +0,0 @@ -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 deleted file mode 100644 index fbcfad72..00000000 --- a/packages/tui/src/status/progress/terminal/signals.ts +++ /dev/null @@ -1,41 +0,0 @@ -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/signals.ts b/packages/tui/src/status/signals.ts deleted file mode 100644 index 1190cce3..00000000 --- a/packages/tui/src/status/signals.ts +++ /dev/null @@ -1,57 +0,0 @@ -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 deleted file mode 100644 index d0734fdc..00000000 --- a/packages/tui/src/status/spinner.ts +++ /dev/null @@ -1,13 +0,0 @@ -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 deleted file mode 100644 index fcda53b2..00000000 --- a/packages/tui/src/status/spinner/lifecycle.ts +++ /dev/null @@ -1,40 +0,0 @@ -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 deleted file mode 100644 index be129f74..00000000 --- a/packages/tui/src/status/spinner/options.ts +++ /dev/null @@ -1,21 +0,0 @@ -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/validators/options.ts b/packages/tui/src/status/spinner/validators/options.ts deleted file mode 100644 index 2799c173..00000000 --- a/packages/tui/src/status/spinner/validators/options.ts +++ /dev/null @@ -1,43 +0,0 @@ -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 deleted file mode 100644 index a9d14adb..00000000 --- a/packages/tui/src/status/stream.ts +++ /dev/null @@ -1,13 +0,0 @@ -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 deleted file mode 100644 index 00333915..00000000 --- a/packages/tui/src/status/stream/buffer.ts +++ /dev/null @@ -1,38 +0,0 @@ -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 deleted file mode 100644 index c97ba7dd..00000000 --- a/packages/tui/src/status/stream/fade.ts +++ /dev/null @@ -1,39 +0,0 @@ -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 deleted file mode 100644 index 76e1f324..00000000 --- a/packages/tui/src/status/stream/lifecycle.ts +++ /dev/null @@ -1,42 +0,0 @@ -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 deleted file mode 100644 index bf60b126..00000000 --- a/packages/tui/src/status/stream/output.ts +++ /dev/null @@ -1,78 +0,0 @@ -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 deleted file mode 100644 index bcc4d5a5..00000000 --- a/packages/tui/src/status/stream/output/context.ts +++ /dev/null @@ -1,18 +0,0 @@ -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/readback.ts b/packages/tui/src/status/stream/output/readback.ts deleted file mode 100644 index 577edbea..00000000 --- a/packages/tui/src/status/stream/output/readback.ts +++ /dev/null @@ -1,10 +0,0 @@ -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 deleted file mode 100644 index 643c7efb..00000000 --- a/packages/tui/src/status/stream/output/rendering.ts +++ /dev/null @@ -1,32 +0,0 @@ -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 deleted file mode 100644 index e148d080..00000000 --- a/packages/tui/src/status/stream/render.ts +++ /dev/null @@ -1,29 +0,0 @@ -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 deleted file mode 100644 index 4b56a00b..00000000 --- a/packages/tui/src/status/stream/renderer.ts +++ /dev/null @@ -1,39 +0,0 @@ -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/validators/fade.ts b/packages/tui/src/status/stream/validators/fade.ts deleted file mode 100644 index 449d8f0d..00000000 --- a/packages/tui/src/status/stream/validators/fade.ts +++ /dev/null @@ -1,37 +0,0 @@ -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 deleted file mode 100644 index 787810c4..00000000 --- a/packages/tui/src/status/task.ts +++ /dev/null @@ -1,16 +0,0 @@ -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 deleted file mode 100644 index 367d1b25..00000000 --- a/packages/tui/src/status/task/definition.ts +++ /dev/null @@ -1,47 +0,0 @@ -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 deleted file mode 100644 index 8f6b3175..00000000 --- a/packages/tui/src/status/task/frame.ts +++ /dev/null @@ -1,22 +0,0 @@ -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 deleted file mode 100644 index a641399c..00000000 --- a/packages/tui/src/status/task/lifecycle.ts +++ /dev/null @@ -1,55 +0,0 @@ -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 deleted file mode 100644 index 8903ed3e..00000000 --- a/packages/tui/src/status/task/lifecycle/rendering.ts +++ /dev/null @@ -1,41 +0,0 @@ -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 deleted file mode 100644 index e8b6c3e8..00000000 --- a/packages/tui/src/status/task/logger.ts +++ /dev/null @@ -1 +0,0 @@ -export { Logger } from '#tui/status/task/logger/logger'; diff --git a/packages/tui/src/status/task/logger/lines.ts b/packages/tui/src/status/task/logger/lines.ts deleted file mode 100644 index a00baa6d..00000000 --- a/packages/tui/src/status/task/logger/lines.ts +++ /dev/null @@ -1,46 +0,0 @@ -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 deleted file mode 100644 index 7e79518a..00000000 --- a/packages/tui/src/status/task/logger/logger.ts +++ /dev/null @@ -1,97 +0,0 @@ -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 deleted file mode 100644 index 992be70a..00000000 --- a/packages/tui/src/status/task/logger/methods.ts +++ /dev/null @@ -1,24 +0,0 @@ -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 deleted file mode 100644 index 7cd7b71d..00000000 --- a/packages/tui/src/status/task/logger/output.ts +++ /dev/null @@ -1,11 +0,0 @@ -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 deleted file mode 100644 index 2d7c53d2..00000000 --- a/packages/tui/src/status/task/logger/stable.ts +++ /dev/null @@ -1,12 +0,0 @@ -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/process-output.ts b/packages/tui/src/status/task/process-output.ts deleted file mode 100644 index c37efb38..00000000 --- a/packages/tui/src/status/task/process-output.ts +++ /dev/null @@ -1,34 +0,0 @@ -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 deleted file mode 100644 index 9bef4ae7..00000000 --- a/packages/tui/src/status/task/process-output/buffer.ts +++ /dev/null @@ -1,32 +0,0 @@ -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/writer.ts b/packages/tui/src/status/task/process-output/validators/writer.ts deleted file mode 100644 index 41caf619..00000000 --- a/packages/tui/src/status/task/process-output/validators/writer.ts +++ /dev/null @@ -1,6 +0,0 @@ -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/stream.ts b/packages/tui/src/status/task/process-output/validators/writer/stream.ts deleted file mode 100644 index 9ddef67d..00000000 --- a/packages/tui/src/status/task/process-output/validators/writer/stream.ts +++ /dev/null @@ -1,34 +0,0 @@ -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 deleted file mode 100644 index af13ecd8..00000000 --- a/packages/tui/src/status/task/process-output/writer.ts +++ /dev/null @@ -1,18 +0,0 @@ -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 deleted file mode 100644 index 9cbc33c4..00000000 --- a/packages/tui/src/status/task/render.ts +++ /dev/null @@ -1,64 +0,0 @@ -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/validators/definition.ts b/packages/tui/src/status/task/validators/definition.ts deleted file mode 100644 index c3ff78e6..00000000 --- a/packages/tui/src/status/task/validators/definition.ts +++ /dev/null @@ -1,50 +0,0 @@ -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/progress.ts b/packages/tui/src/status/validators/progress.ts deleted file mode 100644 index 875e9c82..00000000 --- a/packages/tui/src/status/validators/progress.ts +++ /dev/null @@ -1,40 +0,0 @@ -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/string-utils/ansi.ts b/packages/tui/src/string-utils/ansi.ts deleted file mode 100644 index f29f0209..00000000 --- a/packages/tui/src/string-utils/ansi.ts +++ /dev/null @@ -1,7 +0,0 @@ -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/truncate.ts b/packages/tui/src/string-utils/truncate.ts deleted file mode 100644 index 82b4c475..00000000 --- a/packages/tui/src/string-utils/truncate.ts +++ /dev/null @@ -1,66 +0,0 @@ -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/wrap.ts b/packages/tui/src/string-utils/wrap.ts deleted file mode 100644 index ceaef25f..00000000 --- a/packages/tui/src/string-utils/wrap.ts +++ /dev/null @@ -1,7 +0,0 @@ -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 deleted file mode 100644 index 1283703a..00000000 --- a/packages/tui/src/string-utils/wrap/ansi.ts +++ /dev/null @@ -1,57 +0,0 @@ -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/strings.ts b/packages/tui/src/strings.ts deleted file mode 100644 index 5cd4aba0..00000000 --- a/packages/tui/src/strings.ts +++ /dev/null @@ -1,5 +0,0 @@ -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 deleted file mode 100644 index 518bbcd2..00000000 --- a/packages/tui/src/terminal.ts +++ /dev/null @@ -1,61 +0,0 @@ -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/theme.ts b/packages/tui/src/theme.ts deleted file mode 100644 index c397190c..00000000 --- a/packages/tui/src/theme.ts +++ /dev/null @@ -1,5 +0,0 @@ -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/choice-disabled.ts b/packages/tui/src/theme/choice-disabled.ts deleted file mode 100644 index b0a4ecc9..00000000 --- a/packages/tui/src/theme/choice-disabled.ts +++ /dev/null @@ -1,9 +0,0 @@ -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 deleted file mode 100644 index 4855e95e..00000000 --- a/packages/tui/src/theme/choices.ts +++ /dev/null @@ -1,13 +0,0 @@ -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 deleted file mode 100644 index 10b1830e..00000000 --- a/packages/tui/src/theme/error.ts +++ /dev/null @@ -1,4 +0,0 @@ -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/table.ts b/packages/tui/src/theme/table.ts deleted file mode 100644 index 0239ee68..00000000 --- a/packages/tui/src/theme/table.ts +++ /dev/null @@ -1,30 +0,0 @@ -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/typed-value.ts b/packages/tui/src/typed-value.ts deleted file mode 100644 index 68eb83ba..00000000 --- a/packages/tui/src/typed-value.ts +++ /dev/null @@ -1,4 +0,0 @@ -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/cursor.ts b/packages/tui/src/typed-value/cursor.ts deleted file mode 100644 index 97534e28..00000000 --- a/packages/tui/src/typed-value/cursor.ts +++ /dev/null @@ -1,22 +0,0 @@ -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/edit.ts b/packages/tui/src/typed-value/edit.ts deleted file mode 100644 index 568f1814..00000000 --- a/packages/tui/src/typed-value/edit.ts +++ /dev/null @@ -1,60 +0,0 @@ -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/navigation.ts b/packages/tui/src/typed-value/edit/navigation.ts deleted file mode 100644 index 732f12ef..00000000 --- a/packages/tui/src/typed-value/edit/navigation.ts +++ /dev/null @@ -1,30 +0,0 @@ -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 deleted file mode 100644 index 3e4b06b0..00000000 --- a/packages/tui/src/typed-value/edit/result.ts +++ /dev/null @@ -1,14 +0,0 @@ -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/lines.ts b/packages/tui/src/typed-value/lines.ts deleted file mode 100644 index 742c96a6..00000000 --- a/packages/tui/src/typed-value/lines.ts +++ /dev/null @@ -1,42 +0,0 @@ -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/window.ts b/packages/tui/src/typed-value/lines/window.ts deleted file mode 100644 index 2c1726ad..00000000 --- a/packages/tui/src/typed-value/lines/window.ts +++ /dev/null @@ -1,30 +0,0 @@ -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 deleted file mode 100644 index 363f1c75..00000000 --- a/packages/tui/src/typed-value/read.ts +++ /dev/null @@ -1,59 +0,0 @@ -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 deleted file mode 100644 index 067d9cfe..00000000 --- a/packages/tui/src/typed-value/read/cancel.ts +++ /dev/null @@ -1,28 +0,0 @@ -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 deleted file mode 100644 index b07679c6..00000000 --- a/packages/tui/src/typed-value/read/fallback.ts +++ /dev/null @@ -1,18 +0,0 @@ -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 deleted file mode 100644 index ee1b9c68..00000000 --- a/packages/tui/src/typed-value/render.ts +++ /dev/null @@ -1,42 +0,0 @@ -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/types.ts b/packages/tui/src/types.ts deleted file mode 100644 index ce40ef0b..00000000 --- a/packages/tui/src/types.ts +++ /dev/null @@ -1,16 +0,0 @@ -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/result.ts b/packages/tui/src/validators/result.ts deleted file mode 100644 index 5fdd9ac9..00000000 --- a/packages/tui/src/validators/result.ts +++ /dev/null @@ -1,14 +0,0 @@ -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/tsconfig.json b/packages/tui/tsconfig.json deleted file mode 100644 index e222b8e8..00000000 --- a/packages/tui/tsconfig.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "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 deleted file mode 100644 index a5e4b2f4..00000000 --- a/packages/tui/vitest.config.ts +++ /dev/null @@ -1,24 +0,0 @@ -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/pkgs-go/go.mod b/pkgs-go/go.mod new file mode 100644 index 00000000..9818ffb6 --- /dev/null +++ b/pkgs-go/go.mod @@ -0,0 +1,3 @@ +module pkgs-go + +go 1.26.4 diff --git a/pkgs-go/package.json b/pkgs-go/package.json new file mode 100644 index 00000000..51001d31 --- /dev/null +++ b/pkgs-go/package.json @@ -0,0 +1,10 @@ +{ + "name": "@ollin/pkgs-go", + "version": "0.1.0", + "private": true, + "scripts": { + "build": "if find . -name '*.go' -print -quit | grep -q .; then GOCACHE=\"$PWD/../infra/.cache/go-build\" GOMODCACHE=\"$PWD/../infra/.cache/go-mod\" go build ./...; else echo 'pkgs-go: no Go sources yet, skipping build'; fi", + "test": "if find . -name '*.go' -print -quit | grep -q .; then GOCACHE=\"$PWD/../infra/.cache/go-build\" GOMODCACHE=\"$PWD/../infra/.cache/go-mod\" go test ./...; else echo 'pkgs-go: no Go sources yet, skipping test'; fi", + "typecheck": "if find . -name '*.go' -print -quit | grep -q .; then GOCACHE=\"$PWD/../infra/.cache/go-build\" GOMODCACHE=\"$PWD/../infra/.cache/go-mod\" go vet ./...; else echo 'pkgs-go: no Go sources yet, skipping vet'; fi" + } +} diff --git a/pkgs-ts/console/package.json b/pkgs-ts/console/package.json new file mode 100644 index 00000000..484d98d7 --- /dev/null +++ b/pkgs-ts/console/package.json @@ -0,0 +1,208 @@ +{ + "name": "@ollin/console", + "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": { + "#console/*": { + "types": "./dist/*.d.ts", + "default": "./dist/*.js" + }, + "#console/prompts/*": { + "types": "./dist/prompts/*.d.ts", + "default": "./dist/prompts/*.js" + } + }, + "scripts": { + "build": "XDG_CACHE_HOME=../../infra/.cache/xdg PLAYWRIGHT_BROWSERS_PATH=../../infra/.cache/playwright tsc -p tsconfig.build.json", + "prepack": "pnpm build", + "test": "XDG_CACHE_HOME=../../infra/.cache/xdg PLAYWRIGHT_BROWSERS_PATH=../../infra/.cache/playwright vitest run --config vitest.config.ts", + "typecheck": "XDG_CACHE_HOME=../../infra/.cache/xdg PLAYWRIGHT_BROWSERS_PATH=../../infra/.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/console-infra": "workspace:*", + "@types/node": "^25.6.2", + "typescript": "^6.0.3", + "vitest": "^4.1.5" + } +} diff --git a/pkgs-ts/console/src/concerns/choice-label.ts b/pkgs-ts/console/src/concerns/choice-label.ts new file mode 100644 index 00000000..32790007 --- /dev/null +++ b/pkgs-ts/console/src/concerns/choice-label.ts @@ -0,0 +1,9 @@ +import { choiceDisabledSuffix } from '#console/theme/choice-disabled'; +import type { Choice } from '#console/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/pkgs-ts/console/src/concerns/choice-match.ts similarity index 86% rename from packages/tui/src/concerns/choice-match.ts rename to pkgs-ts/console/src/concerns/choice-match.ts index 7617ab3e..9aa2afa3 100644 --- a/packages/tui/src/concerns/choice-match.ts +++ b/pkgs-ts/console/src/concerns/choice-match.ts @@ -1,6 +1,6 @@ import { isDeepStrictEqual } from 'node:util'; -import { answerChoiceIndex } from '#tui/concerns/choice-normalize'; -import type { Choice } from '#tui/types'; +import { answerChoiceIndex } from '#console/concerns/choice-normalize'; +import type { Choice } from '#console/types'; export const choiceValueEquals = (left: unknown, right: unknown): boolean => Object.is(left, right) || isDeepStrictEqual(left, right); diff --git a/packages/tui/src/concerns/choice-navigation.ts b/pkgs-ts/console/src/concerns/choice-navigation.ts similarity index 88% rename from packages/tui/src/concerns/choice-navigation.ts rename to pkgs-ts/console/src/concerns/choice-navigation.ts index 66ba87c7..a81ddf62 100644 --- a/packages/tui/src/concerns/choice-navigation.ts +++ b/pkgs-ts/console/src/concerns/choice-navigation.ts @@ -1,5 +1,5 @@ -import { parseOptionalScrollSize } from '#tui/concerns/validators/scroll'; -import type { Choice } from '#tui/types'; +import { parseOptionalScrollSize } from '#console/concerns/validators/scroll'; +import type { Choice } from '#console/types'; export const firstEnabledIndex = (choices: Array>): number => { const index = choices.findIndex((choice) => !choice.disabled); diff --git a/packages/tui/src/concerns/choice-normalize.ts b/pkgs-ts/console/src/concerns/choice-normalize.ts similarity index 83% rename from packages/tui/src/concerns/choice-normalize.ts rename to pkgs-ts/console/src/concerns/choice-normalize.ts index 05e5c99f..16a40e58 100644 --- a/packages/tui/src/concerns/choice-normalize.ts +++ b/pkgs-ts/console/src/concerns/choice-normalize.ts @@ -1,6 +1,6 @@ -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'; +import { parseChoiceAnswerIndex, parseChoiceRecordValue } from '#console/concerns/validators/choice-answer'; +import { parseChoice, parseChoiceOptions, parseChoiceRecordEntries, parseChoiceValue } from '#console/concerns/validators/choice'; +import type { Choice, ChoiceOptions } from '#console/types'; export const normalizeChoices = (options: ChoiceOptions): Array> => { const parsed = parseChoiceOptions(options); diff --git a/packages/tui/src/concerns/choice-render.ts b/pkgs-ts/console/src/concerns/choice-render.ts similarity index 85% rename from packages/tui/src/concerns/choice-render.ts rename to pkgs-ts/console/src/concerns/choice-render.ts index e69295a1..325071f2 100644 --- a/packages/tui/src/concerns/choice-render.ts +++ b/pkgs-ts/console/src/concerns/choice-render.ts @@ -1,9 +1,9 @@ -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'; +import { promptEnvironment } from '#console/environment'; +import { choiceWindow } from '#console/concerns/choice-navigation'; +import { renderScrollbarRows } from '#console/concerns/scrollbar'; +import { choiceDisabledSuffix } from '#console/theme/choice-disabled'; +import { cyan, dim } from '#console/theme/styles'; +import type { Choice } from '#console/types'; export const renderInteractiveChoices = (message: string, choices: Array>, selected: number, marked: Set = new Set(), scroll?: number): void => { const environment = promptEnvironment(); diff --git a/pkgs-ts/console/src/concerns/choices.ts b/pkgs-ts/console/src/concerns/choices.ts new file mode 100644 index 00000000..5ff3870e --- /dev/null +++ b/pkgs-ts/console/src/concerns/choices.ts @@ -0,0 +1,4 @@ +export { choiceByValue, choiceValueEquals, findChoice } from '#console/concerns/choice-match'; +export { choiceWindow, firstEnabledIndex, nextEnabledIndex } from '#console/concerns/choice-navigation'; +export { normalizeChoices, normalizeSearchChoices } from '#console/concerns/choice-normalize'; +export { renderInteractiveChoices, renderInteractiveChecklist } from '#console/concerns/choice-render'; diff --git a/pkgs-ts/console/src/concerns/info.ts b/pkgs-ts/console/src/concerns/info.ts new file mode 100644 index 00000000..4ef7a1eb --- /dev/null +++ b/pkgs-ts/console/src/concerns/info.ts @@ -0,0 +1,15 @@ +import { isInfoCallback } from '#console/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/pkgs-ts/console/src/concerns/scrollbar.ts b/pkgs-ts/console/src/concerns/scrollbar.ts new file mode 100644 index 00000000..b09c2b22 --- /dev/null +++ b/pkgs-ts/console/src/concerns/scrollbar.ts @@ -0,0 +1,42 @@ +import { parseScrollbarState } from '#console/concerns/validators/scrollbar'; +import { visibleWidth } from '#console/strings'; +import { cyan, dim } from '#console/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/pkgs-ts/console/src/concerns/text-options.ts b/pkgs-ts/console/src/concerns/text-options.ts new file mode 100644 index 00000000..e779ea8e --- /dev/null +++ b/pkgs-ts/console/src/concerns/text-options.ts @@ -0,0 +1,10 @@ +import { isTextPromptLabel } from '#console/concerns/validators/text-options'; +import type { TextPromptOptions } from '#console/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/pkgs-ts/console/src/concerns/validators/choice-answer.ts similarity index 100% rename from packages/tui/src/concerns/validators/choice-answer.ts rename to pkgs-ts/console/src/concerns/validators/choice-answer.ts diff --git a/packages/tui/src/concerns/validators/choice.ts b/pkgs-ts/console/src/concerns/validators/choice.ts similarity index 97% rename from packages/tui/src/concerns/validators/choice.ts rename to pkgs-ts/console/src/concerns/validators/choice.ts index bce1f185..2646f520 100644 --- a/packages/tui/src/concerns/validators/choice.ts +++ b/pkgs-ts/console/src/concerns/validators/choice.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import type { Choice, ChoiceOptions } from '#tui/types'; +import type { Choice, ChoiceOptions } from '#console/types'; const choiceSchema = z .object({ diff --git a/pkgs-ts/console/src/concerns/validators/info.ts b/pkgs-ts/console/src/concerns/validators/info.ts new file mode 100644 index 00000000..30b59634 --- /dev/null +++ b/pkgs-ts/console/src/concerns/validators/info.ts @@ -0,0 +1,10 @@ +import { z } from 'zod'; +import type { InfoResolver } from '#console/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/pkgs-ts/console/src/concerns/validators/scroll.ts similarity index 100% rename from packages/tui/src/concerns/validators/scroll.ts rename to pkgs-ts/console/src/concerns/validators/scroll.ts diff --git a/packages/tui/src/concerns/validators/scrollbar.ts b/pkgs-ts/console/src/concerns/validators/scrollbar.ts similarity index 100% rename from packages/tui/src/concerns/validators/scrollbar.ts rename to pkgs-ts/console/src/concerns/validators/scrollbar.ts diff --git a/packages/tui/src/concerns/validators/text-options.ts b/pkgs-ts/console/src/concerns/validators/text-options.ts similarity index 100% rename from packages/tui/src/concerns/validators/text-options.ts rename to pkgs-ts/console/src/concerns/validators/text-options.ts diff --git a/packages/tui/src/contracts/base.ts b/pkgs-ts/console/src/contracts/base.ts similarity index 100% rename from packages/tui/src/contracts/base.ts rename to pkgs-ts/console/src/contracts/base.ts diff --git a/packages/tui/src/contracts/choices.ts b/pkgs-ts/console/src/contracts/choices.ts similarity index 100% rename from packages/tui/src/contracts/choices.ts rename to pkgs-ts/console/src/contracts/choices.ts diff --git a/pkgs-ts/console/src/contracts/environment.ts b/pkgs-ts/console/src/contracts/environment.ts new file mode 100644 index 00000000..e610fdf5 --- /dev/null +++ b/pkgs-ts/console/src/contracts/environment.ts @@ -0,0 +1,17 @@ +import type { MaybePromise } from '#console/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/pkgs-ts/console/src/contracts/options.ts b/pkgs-ts/console/src/contracts/options.ts new file mode 100644 index 00000000..b2315d00 --- /dev/null +++ b/pkgs-ts/console/src/contracts/options.ts @@ -0,0 +1,9 @@ +import type { Validator } from '#console/contracts/base'; + +export type BasePromptOptions = { + message: string; + default?: T; + required?: boolean | string; + hint?: string; + validate?: Validator; +}; diff --git a/pkgs-ts/console/src/contracts/output.ts b/pkgs-ts/console/src/contracts/output.ts new file mode 100644 index 00000000..79fe59bf --- /dev/null +++ b/pkgs-ts/console/src/contracts/output.ts @@ -0,0 +1,24 @@ +import type { BasePromptOptions } from '#console/contracts/options'; +import type { MaybePromise } from '#console/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/pkgs-ts/console/src/contracts/prompts.ts b/pkgs-ts/console/src/contracts/prompts.ts new file mode 100644 index 00000000..245f71f3 --- /dev/null +++ b/pkgs-ts/console/src/contracts/prompts.ts @@ -0,0 +1,66 @@ +import type { MaybePromise } from '#console/contracts/base'; +import type { ChoiceOptions, PromptInfo } from '#console/contracts/choices'; +import type { BasePromptOptions } from '#console/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/pkgs-ts/console/src/contracts/status.ts similarity index 100% rename from packages/tui/src/contracts/status.ts rename to pkgs-ts/console/src/contracts/status.ts diff --git a/pkgs-ts/console/src/environment.ts b/pkgs-ts/console/src/environment.ts new file mode 100644 index 00000000..f87fa2eb --- /dev/null +++ b/pkgs-ts/console/src/environment.ts @@ -0,0 +1,5 @@ +export { configurePrompts, promptEnvironment, withPromptEnvironment } from '#console/environment/scope'; +export { createMemoryOutput } from '#console/environment/memory-output'; +export { createScriptedInput } from '#console/environment/scripted-input'; +export type { MemoryOutput } from '#console/environment/memory-output'; +export type { RawKeyInput } from '#console/environment/raw-key'; diff --git a/packages/tui/src/environment/default-input.ts b/pkgs-ts/console/src/environment/default-input.ts similarity index 82% rename from packages/tui/src/environment/default-input.ts rename to pkgs-ts/console/src/environment/default-input.ts index 8786924b..a8184d40 100644 --- a/packages/tui/src/environment/default-input.ts +++ b/pkgs-ts/console/src/environment/default-input.ts @@ -1,7 +1,7 @@ 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'; +import { readRawKey } from '#console/environment/raw-key'; +import type { PromptInput } from '#console/types'; export const defaultInput: PromptInput = { async readKey(): Promise { diff --git a/pkgs-ts/console/src/environment/memory-output.ts b/pkgs-ts/console/src/environment/memory-output.ts new file mode 100644 index 00000000..2e8dff10 --- /dev/null +++ b/pkgs-ts/console/src/environment/memory-output.ts @@ -0,0 +1,23 @@ +import { parsePromptOutputContent } from '#console/environment/validators/output'; +import type { PromptOutput } from '#console/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/pkgs-ts/console/src/environment/raw-key.ts b/pkgs-ts/console/src/environment/raw-key.ts new file mode 100644 index 00000000..f9de565e --- /dev/null +++ b/pkgs-ts/console/src/environment/raw-key.ts @@ -0,0 +1,27 @@ +import { createRawKeyInputMode } from '#console/environment/raw-key/mode'; +import { createRawKeySession } from '#console/environment/raw-key/session'; +import type { RawKeyInput } from '#console/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/pkgs-ts/console/src/environment/raw-key/mode.ts similarity index 88% rename from packages/tui/src/environment/raw-key/mode.ts rename to pkgs-ts/console/src/environment/raw-key/mode.ts index fcdc0a8b..34d2aab1 100644 --- a/packages/tui/src/environment/raw-key/mode.ts +++ b/pkgs-ts/console/src/environment/raw-key/mode.ts @@ -1,4 +1,4 @@ -import type { RawKeyInput } from '#tui/environment/raw-key/types'; +import type { RawKeyInput } from '#console/environment/raw-key/types'; export type RawKeyInputMode = { activate(): void; diff --git a/packages/tui/src/environment/raw-key/normalize.ts b/pkgs-ts/console/src/environment/raw-key/normalize.ts similarity index 83% rename from packages/tui/src/environment/raw-key/normalize.ts rename to pkgs-ts/console/src/environment/raw-key/normalize.ts index cfc16a44..66ebafde 100644 --- a/packages/tui/src/environment/raw-key/normalize.ts +++ b/pkgs-ts/console/src/environment/raw-key/normalize.ts @@ -1,5 +1,5 @@ -import { Key } from '#tui/key'; -import { parseKnownKeyValues } from '#tui/key/validators/value'; +import { Key } from '#console/key'; +import { parseKnownKeyValues } from '#console/key/validators/value'; const knownRawKeys = parseKnownKeyValues(); diff --git a/pkgs-ts/console/src/environment/raw-key/session.ts b/pkgs-ts/console/src/environment/raw-key/session.ts new file mode 100644 index 00000000..00f4e810 --- /dev/null +++ b/pkgs-ts/console/src/environment/raw-key/session.ts @@ -0,0 +1,94 @@ +import { isCompleteRawKey, normalizeRawKey } from '#console/environment/raw-key/normalize'; +import { parseRawKeyChunkText } from '#console/environment/raw-key/validators/chunk'; +import type { RawKeyInputMode } from '#console/environment/raw-key/mode'; +import type { RawKeyInput } from '#console/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/pkgs-ts/console/src/environment/raw-key/types.ts similarity index 100% rename from packages/tui/src/environment/raw-key/types.ts rename to pkgs-ts/console/src/environment/raw-key/types.ts diff --git a/packages/tui/src/environment/raw-key/validators/chunk.ts b/pkgs-ts/console/src/environment/raw-key/validators/chunk.ts similarity index 100% rename from packages/tui/src/environment/raw-key/validators/chunk.ts rename to pkgs-ts/console/src/environment/raw-key/validators/chunk.ts diff --git a/pkgs-ts/console/src/environment/runtime.ts b/pkgs-ts/console/src/environment/runtime.ts new file mode 100644 index 00000000..a853a8df --- /dev/null +++ b/pkgs-ts/console/src/environment/runtime.ts @@ -0,0 +1,11 @@ +import { stderr as defaultStderr, stdin as defaultStdin, stdout as defaultStdout } from 'node:process'; +import { defaultInput } from '#console/environment/default-input'; +import { outputFromStream } from '#console/environment/stream-output'; +import type { PromptEnvironment } from '#console/types'; + +export const defaultEnvironment: PromptEnvironment = { + input: defaultInput, + output: outputFromStream(defaultStdout), + error: outputFromStream(defaultStderr), + interactive: Boolean(defaultStdin.isTTY), +}; diff --git a/pkgs-ts/console/src/environment/scope.ts b/pkgs-ts/console/src/environment/scope.ts new file mode 100644 index 00000000..6d2730f3 --- /dev/null +++ b/pkgs-ts/console/src/environment/scope.ts @@ -0,0 +1,32 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; +import { defaultEnvironment } from '#console/environment/runtime'; +import { mergePromptEnvironment } from '#console/environment/scope/merge'; +import { withPromptCancelScope } from '#console/prompt/cancel'; +import { withPromptFallbackScope } from '#console/prompt/fallback'; +import { withPromptGlobalValidationScope } from '#console/prompt/validate-using'; +import type { MaybePromise, PromptEnvironment } from '#console/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/pkgs-ts/console/src/environment/scope/merge.ts b/pkgs-ts/console/src/environment/scope/merge.ts new file mode 100644 index 00000000..e50d0c25 --- /dev/null +++ b/pkgs-ts/console/src/environment/scope/merge.ts @@ -0,0 +1,12 @@ +import { parsePromptEnvironmentPatch } from '#console/environment/scope/validators/patch'; +import type { PromptEnvironment } from '#console/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/pkgs-ts/console/src/environment/scope/validators/patch.ts similarity index 93% rename from packages/tui/src/environment/scope/validators/patch.ts rename to pkgs-ts/console/src/environment/scope/validators/patch.ts index 26eb1052..4d5ff4c6 100644 --- a/packages/tui/src/environment/scope/validators/patch.ts +++ b/pkgs-ts/console/src/environment/scope/validators/patch.ts @@ -1,6 +1,6 @@ import { z } from 'zod'; -import { functionSchema } from '#tui/validators/function'; -import type { PromptEnvironment, PromptInput, PromptOutput } from '#tui/types'; +import { functionSchema } from '#console/validators/function'; +import type { PromptEnvironment, PromptInput, PromptOutput } from '#console/types'; const promptInputPatchSchema = z .object({ diff --git a/pkgs-ts/console/src/environment/scripted-input.ts b/pkgs-ts/console/src/environment/scripted-input.ts new file mode 100644 index 00000000..b8cbaf90 --- /dev/null +++ b/pkgs-ts/console/src/environment/scripted-input.ts @@ -0,0 +1,15 @@ +import { parseScriptedInputLines } from '#console/environment/validators/scripted-input'; +import type { PromptInput } from '#console/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/pkgs-ts/console/src/environment/stream-output.ts b/pkgs-ts/console/src/environment/stream-output.ts new file mode 100644 index 00000000..d0716044 --- /dev/null +++ b/pkgs-ts/console/src/environment/stream-output.ts @@ -0,0 +1,14 @@ +import { parsePromptOutputContent } from '#console/environment/validators/output'; +import { parseWritableOutputStream } from '#console/environment/validators/stream-output'; +import type { PromptOutput } from '#console/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/pkgs-ts/console/src/environment/validators/output.ts similarity index 100% rename from packages/tui/src/environment/validators/output.ts rename to pkgs-ts/console/src/environment/validators/output.ts diff --git a/packages/tui/src/environment/validators/scripted-input.ts b/pkgs-ts/console/src/environment/validators/scripted-input.ts similarity index 100% rename from packages/tui/src/environment/validators/scripted-input.ts rename to pkgs-ts/console/src/environment/validators/scripted-input.ts diff --git a/pkgs-ts/console/src/environment/validators/stream-output.ts b/pkgs-ts/console/src/environment/validators/stream-output.ts new file mode 100644 index 00000000..fa4fe7a5 --- /dev/null +++ b/pkgs-ts/console/src/environment/validators/stream-output.ts @@ -0,0 +1,18 @@ +import { z } from 'zod'; +import { functionSchema } from '#console/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/pkgs-ts/console/src/form.ts b/pkgs-ts/console/src/form.ts new file mode 100644 index 00000000..ef864874 --- /dev/null +++ b/pkgs-ts/console/src/form.ts @@ -0,0 +1,15 @@ +export { FormBuilder } from '#console/form/builder/index'; +export type { FormResponses, FormStep, FormStepCondition } from '#console/form/types'; + +import { FormBuilder } from '#console/form/builder/index'; +import type { MaybePromise } from '#console/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/pkgs-ts/console/src/form/builder/conditions.ts b/pkgs-ts/console/src/form/builder/conditions.ts new file mode 100644 index 00000000..dae7741b --- /dev/null +++ b/pkgs-ts/console/src/form/builder/conditions.ts @@ -0,0 +1,14 @@ +import { resolveFormStepCondition } from '#console/form/builder/validators/conditions'; +import type { FormResponses, FormStep } from '#console/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/pkgs-ts/console/src/form/builder/index.ts b/pkgs-ts/console/src/form/builder/index.ts new file mode 100644 index 00000000..f58d69c4 --- /dev/null +++ b/pkgs-ts/console/src/form/builder/index.ts @@ -0,0 +1,50 @@ +import { createFormResponses } from '#console/form/builder/responses'; +import { sideEffectStep } from '#console/form/builder/step'; +import { submitFormSteps } from '#console/form/builder/submit'; +import { applyFormBuilderMethods, FormBuilderMethodSurface } from '#console/form/builder/methods'; +import type { FormResponses, FormStep, FormStepCondition } from '#console/form/types'; +import type { MaybePromise } from '#console/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/pkgs-ts/console/src/form/builder/methods.ts b/pkgs-ts/console/src/form/builder/methods.ts new file mode 100644 index 00000000..ade72134 --- /dev/null +++ b/pkgs-ts/console/src/form/builder/methods.ts @@ -0,0 +1,52 @@ +import { outputBuilderMethods } from '#console/form/builder/output'; +import { promptBuilderMethods } from '#console/form/builder/prompts'; +import { statusBuilderMethods } from '#console/form/builder/status'; +import type { FormBuilder } from '#console/form/builder/index'; +import type { OutputBuilderMethods } from '#console/form/builder/output'; +import type { PromptBuilderMethods } from '#console/form/builder/prompts'; +import type { StatusBuilderMethods } from '#console/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/pkgs-ts/console/src/form/builder/output.ts b/pkgs-ts/console/src/form/builder/output.ts new file mode 100644 index 00000000..c12a665e --- /dev/null +++ b/pkgs-ts/console/src/form/builder/output.ts @@ -0,0 +1,38 @@ +import type { FormBuilder } from '#console/form/builder/index'; +import type { OutputBuilderMethods } from '#console/form/builder/output/contracts'; + +import { + alertFormStep, + clearFormStep, + dataTableFormStep, + datatableFormStep, + errorFormStep, + gridFormStep, + infoFormStep, + introFormStep, + noteFormStep, + notifyFormStep, + outroFormStep, + tableFormStep, + titleFormStep, + warningFormStep, +} from '#console/form/builder/output/index'; + +export type { OutputBuilderMethods } from '#console/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/pkgs-ts/console/src/form/builder/output/contracts.ts similarity index 95% rename from packages/tui/src/form/builder/output/contracts.ts rename to pkgs-ts/console/src/form/builder/output/contracts.ts index 7a0161c6..730b0d12 100644 --- a/packages/tui/src/form/builder/output/contracts.ts +++ b/pkgs-ts/console/src/form/builder/output/contracts.ts @@ -1,5 +1,5 @@ -import type { FormBuilder } from '#tui/form/builder/index'; -import type { DataTablePromptOptions, DataTableRow, TableOptions } from '#tui/types'; +import type { FormBuilder } from '#console/form/builder/index'; +import type { DataTablePromptOptions, DataTableRow, TableOptions } from '#console/types'; export type OutputBuilderMethods = { alert(this: FormBuilder, message: string, name?: string): FormBuilder; diff --git a/pkgs-ts/console/src/form/builder/output/data-table.ts b/pkgs-ts/console/src/form/builder/output/data-table.ts new file mode 100644 index 00000000..c2b01dce --- /dev/null +++ b/pkgs-ts/console/src/form/builder/output/data-table.ts @@ -0,0 +1,46 @@ +import { datatable } from '#console/output'; +import { previousValue } from '#console/form/builder/previous'; +import { resolveDataTableFormArguments } from '#console/form/builder/validators/output'; +import type { FormBuilder } from '#console/form/builder/index'; +import type { DataTablePromptOptions, DataTableRow } from '#console/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/pkgs-ts/console/src/form/builder/output/display.ts similarity index 94% rename from packages/tui/src/form/builder/output/display.ts rename to pkgs-ts/console/src/form/builder/output/display.ts index 382fa90c..b2b03858 100644 --- a/packages/tui/src/form/builder/output/display.ts +++ b/pkgs-ts/console/src/form/builder/output/display.ts @@ -1,5 +1,5 @@ -import { alert, error, grid, info, intro, note, outro, warning } from '#tui/output'; -import type { FormBuilder } from '#tui/form/builder/index'; +import { alert, error, grid, info, intro, note, outro, warning } from '#console/output'; +import type { FormBuilder } from '#console/form/builder/index'; export const alertFormStep = function alertFormStep(this: FormBuilder, message: string, name?: string): FormBuilder { return this.addSideEffect(() => alert(message), name); diff --git a/pkgs-ts/console/src/form/builder/output/index.ts b/pkgs-ts/console/src/form/builder/output/index.ts new file mode 100644 index 00000000..bb01f0a0 --- /dev/null +++ b/pkgs-ts/console/src/form/builder/output/index.ts @@ -0,0 +1,5 @@ +export { datatableFormStep } from '#console/form/builder/output/data-table'; +export { alertFormStep, errorFormStep, gridFormStep, infoFormStep, introFormStep, noteFormStep, outroFormStep, warningFormStep } from '#console/form/builder/output/display'; +export { notifyFormStep } from '#console/form/builder/output/notification'; +export { clearFormStep, titleFormStep } from '#console/form/builder/output/terminal'; +export { dataTableFormStep, tableFormStep } from '#console/form/builder/output/table'; diff --git a/pkgs-ts/console/src/form/builder/output/notification.ts b/pkgs-ts/console/src/form/builder/output/notification.ts new file mode 100644 index 00000000..ae09d619 --- /dev/null +++ b/pkgs-ts/console/src/form/builder/output/notification.ts @@ -0,0 +1,6 @@ +import { notify } from '#console/output'; +import type { FormBuilder } from '#console/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/pkgs-ts/console/src/form/builder/output/table.ts b/pkgs-ts/console/src/form/builder/output/table.ts new file mode 100644 index 00000000..9cfbf2ef --- /dev/null +++ b/pkgs-ts/console/src/form/builder/output/table.ts @@ -0,0 +1,36 @@ +import { dataTable, table } from '#console/output'; +import { resolveTableFormArguments } from '#console/form/builder/validators/output'; +import type { FormBuilder } from '#console/form/builder/index'; +import type { TableOptions } from '#console/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/pkgs-ts/console/src/form/builder/output/terminal.ts b/pkgs-ts/console/src/form/builder/output/terminal.ts new file mode 100644 index 00000000..9a59533d --- /dev/null +++ b/pkgs-ts/console/src/form/builder/output/terminal.ts @@ -0,0 +1,10 @@ +import { clear, title } from '#console/output'; +import type { FormBuilder } from '#console/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/pkgs-ts/console/src/form/builder/previous.ts similarity index 88% rename from packages/tui/src/form/builder/previous.ts rename to pkgs-ts/console/src/form/builder/previous.ts index 9df58152..1669f9cb 100644 --- a/packages/tui/src/form/builder/previous.ts +++ b/pkgs-ts/console/src/form/builder/previous.ts @@ -1,4 +1,4 @@ -import { parsePreviousArray, parsePreviousBoolean, parsePreviousNumber, parsePreviousString, parsePreviousValue } from '#tui/form/builder/validators/previous'; +import { parsePreviousArray, parsePreviousBoolean, parsePreviousNumber, parsePreviousString, parsePreviousValue } from '#console/form/builder/validators/previous'; export const previousString = (previous: unknown, defaultValue: string): string => { return parsePreviousString(previous, defaultValue); diff --git a/pkgs-ts/console/src/form/builder/prompts.ts b/pkgs-ts/console/src/form/builder/prompts.ts new file mode 100644 index 00000000..38abfda9 --- /dev/null +++ b/pkgs-ts/console/src/form/builder/prompts.ts @@ -0,0 +1,11 @@ +import { basicPromptBuilderMethods } from '#console/form/builder/prompts/basic'; +import { choicePromptBuilderMethods } from '#console/form/builder/prompts/choices'; +import type { FormBuilder } from '#console/form/builder/index'; +import type { PromptBuilderMethods } from '#console/form/builder/prompts/types'; + +export type { PromptBuilderMethods }; + +export const promptBuilderMethods: PromptBuilderMethods & ThisType = { + ...basicPromptBuilderMethods, + ...choicePromptBuilderMethods, +}; diff --git a/pkgs-ts/console/src/form/builder/prompts/basic.ts b/pkgs-ts/console/src/form/builder/prompts/basic.ts new file mode 100644 index 00000000..45c120e9 --- /dev/null +++ b/pkgs-ts/console/src/form/builder/prompts/basic.ts @@ -0,0 +1,10 @@ +import { numberFormStep, passwordFormStep, textFormStep, textareaFormStep } from '#console/form/builder/prompts/basic/index'; +import type { FormBuilder } from '#console/form/builder/index'; +import type { BasicPromptBuilderMethods } from '#console/form/builder/prompts/types'; + +export const basicPromptBuilderMethods: BasicPromptBuilderMethods & ThisType = { + number: numberFormStep, + password: passwordFormStep, + text: textFormStep, + textarea: textareaFormStep, +}; diff --git a/pkgs-ts/console/src/form/builder/prompts/basic/index.ts b/pkgs-ts/console/src/form/builder/prompts/basic/index.ts new file mode 100644 index 00000000..6843e603 --- /dev/null +++ b/pkgs-ts/console/src/form/builder/prompts/basic/index.ts @@ -0,0 +1,4 @@ +export { numberFormStep } from '#console/form/builder/prompts/basic/number'; +export { passwordFormStep } from '#console/form/builder/prompts/basic/password'; +export { textFormStep } from '#console/form/builder/prompts/basic/text'; +export { textareaFormStep } from '#console/form/builder/prompts/basic/textarea'; diff --git a/pkgs-ts/console/src/form/builder/prompts/basic/number.ts b/pkgs-ts/console/src/form/builder/prompts/basic/number.ts new file mode 100644 index 00000000..24e50b66 --- /dev/null +++ b/pkgs-ts/console/src/form/builder/prompts/basic/number.ts @@ -0,0 +1,62 @@ +import { labelNumberOptions } from '#console/form/builder/prompts/basic/number/options'; +import { runLabelNumberFormStep, runObjectNumberFormStep } from '#console/form/builder/prompts/basic/number/step'; +import { resolveNumberFormArguments } from '#console/form/builder/prompts/validators/basic'; +import type { FormBuilder } from '#console/form/builder/index'; +import type { NumberPromptOptions } from '#console/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/pkgs-ts/console/src/form/builder/prompts/basic/number/defaults.ts b/pkgs-ts/console/src/form/builder/prompts/basic/number/defaults.ts new file mode 100644 index 00000000..c19802db --- /dev/null +++ b/pkgs-ts/console/src/form/builder/prompts/basic/number/defaults.ts @@ -0,0 +1,20 @@ +import { previousNumber } from '#console/form/builder/previous'; +import { hasPromptDefault } from '#console/validators/default'; +import { hasPreviousResponse } from '#console/form/builder/validators/previous'; +import type { NumberPromptOptions } from '#console/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/pkgs-ts/console/src/form/builder/prompts/basic/number/options.ts b/pkgs-ts/console/src/form/builder/prompts/basic/number/options.ts new file mode 100644 index 00000000..8e0426fe --- /dev/null +++ b/pkgs-ts/console/src/form/builder/prompts/basic/number/options.ts @@ -0,0 +1,54 @@ +import { previousNumberDefault } from '#console/form/builder/prompts/basic/number/defaults'; +import { hasPreviousResponse } from '#console/form/builder/validators/previous'; +import type { NumberPromptOptions } from '#console/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/pkgs-ts/console/src/form/builder/prompts/basic/number/step.ts b/pkgs-ts/console/src/form/builder/prompts/basic/number/step.ts new file mode 100644 index 00000000..8b17e540 --- /dev/null +++ b/pkgs-ts/console/src/form/builder/prompts/basic/number/step.ts @@ -0,0 +1,11 @@ +import { number } from '#console/prompts/basic'; +import { numberOptionsWithPreviousDefault } from '#console/form/builder/prompts/basic/number/defaults'; +import type { NumberPromptOptions } from '#console/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/pkgs-ts/console/src/form/builder/prompts/basic/password.ts b/pkgs-ts/console/src/form/builder/prompts/basic/password.ts new file mode 100644 index 00000000..ba9c430a --- /dev/null +++ b/pkgs-ts/console/src/form/builder/prompts/basic/password.ts @@ -0,0 +1,33 @@ +import { password } from '#console/prompts/basic'; +import { previousString } from '#console/form/builder/previous'; +import { resolvePasswordFormArguments } from '#console/form/builder/prompts/validators/basic'; +import type { FormBuilder } from '#console/form/builder/index'; +import type { TextPromptOptions } from '#console/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/pkgs-ts/console/src/form/builder/prompts/basic/text.ts b/pkgs-ts/console/src/form/builder/prompts/basic/text.ts new file mode 100644 index 00000000..e9992327 --- /dev/null +++ b/pkgs-ts/console/src/form/builder/prompts/basic/text.ts @@ -0,0 +1,35 @@ +import { text } from '#console/prompts/basic'; +import { previousString } from '#console/form/builder/previous'; +import { resolveTextFormArguments } from '#console/form/builder/prompts/validators/basic'; +import type { FormBuilder } from '#console/form/builder/index'; +import type { TextPromptOptions } from '#console/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/pkgs-ts/console/src/form/builder/prompts/basic/textarea.ts b/pkgs-ts/console/src/form/builder/prompts/basic/textarea.ts new file mode 100644 index 00000000..84e5b1f8 --- /dev/null +++ b/pkgs-ts/console/src/form/builder/prompts/basic/textarea.ts @@ -0,0 +1,37 @@ +import { textarea } from '#console/prompts/basic'; +import { previousString } from '#console/form/builder/previous'; +import { resolveTextareaFormArguments } from '#console/form/builder/prompts/validators/basic'; +import type { FormBuilder } from '#console/form/builder/index'; +import type { TextareaPromptOptions } from '#console/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/pkgs-ts/console/src/form/builder/prompts/choice/autocomplete.ts b/pkgs-ts/console/src/form/builder/prompts/choice/autocomplete.ts new file mode 100644 index 00000000..bf652764 --- /dev/null +++ b/pkgs-ts/console/src/form/builder/prompts/choice/autocomplete.ts @@ -0,0 +1,40 @@ +import { autocomplete } from '#console/prompts/choices'; +import { previousString } from '#console/form/builder/previous'; +import { resolveAutocompleteFormArguments } from '#console/form/builder/prompts/validators/suggest'; +import type { FormBuilder } from '#console/form/builder/index'; +import type { SuggestOptions } from '#console/prompts/choices'; +import type { TextPromptOptions } from '#console/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/pkgs-ts/console/src/form/builder/prompts/choice/confirm.ts b/pkgs-ts/console/src/form/builder/prompts/choice/confirm.ts new file mode 100644 index 00000000..9c70fbbc --- /dev/null +++ b/pkgs-ts/console/src/form/builder/prompts/choice/confirm.ts @@ -0,0 +1,37 @@ +import { confirm } from '#console/prompts/choices'; +import { previousBoolean } from '#console/form/builder/previous'; +import { resolveConfirmFormArguments } from '#console/form/builder/prompts/validators/select'; +import type { FormBuilder } from '#console/form/builder/index'; +import type { ConfirmPromptOptions } from '#console/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/pkgs-ts/console/src/form/builder/prompts/choice/index.ts b/pkgs-ts/console/src/form/builder/prompts/choice/index.ts new file mode 100644 index 00000000..efb28387 --- /dev/null +++ b/pkgs-ts/console/src/form/builder/prompts/choice/index.ts @@ -0,0 +1,8 @@ +export { autocompleteFormStep } from '#console/form/builder/prompts/choice/autocomplete'; +export { confirmFormStep } from '#console/form/builder/prompts/choice/confirm'; +export { multisearchFormStep } from '#console/form/builder/prompts/choice/multisearch'; +export { multiselectFormStep } from '#console/form/builder/prompts/choice/multiselect'; +export { pauseFormStep } from '#console/form/builder/prompts/choice/pause'; +export { searchFormStep } from '#console/form/builder/prompts/choice/search'; +export { selectFormStep } from '#console/form/builder/prompts/choice/select'; +export { suggestFormStep } from '#console/form/builder/prompts/choice/suggest'; diff --git a/pkgs-ts/console/src/form/builder/prompts/choice/multisearch.ts b/pkgs-ts/console/src/form/builder/prompts/choice/multisearch.ts new file mode 100644 index 00000000..ed36f38a --- /dev/null +++ b/pkgs-ts/console/src/form/builder/prompts/choice/multisearch.ts @@ -0,0 +1,39 @@ +import { multisearch } from '#console/prompts/choices'; +import { previousArray } from '#console/form/builder/previous'; +import { resolveMultiSearchFormArguments } from '#console/form/builder/prompts/validators/search'; +import type { FormBuilder } from '#console/form/builder/index'; +import type { MultiSearchPromptOptions } from '#console/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/pkgs-ts/console/src/form/builder/prompts/choice/multiselect.ts b/pkgs-ts/console/src/form/builder/prompts/choice/multiselect.ts new file mode 100644 index 00000000..b27b10f7 --- /dev/null +++ b/pkgs-ts/console/src/form/builder/prompts/choice/multiselect.ts @@ -0,0 +1,39 @@ +import { multiselect } from '#console/prompts/choices'; +import { previousArray } from '#console/form/builder/previous'; +import { resolveMultiSelectFormArguments } from '#console/form/builder/prompts/validators/select'; +import type { FormBuilder } from '#console/form/builder/index'; +import type { ChoiceOptions, MultiSelectPromptOptions } from '#console/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/pkgs-ts/console/src/form/builder/prompts/choice/pause.ts b/pkgs-ts/console/src/form/builder/prompts/choice/pause.ts new file mode 100644 index 00000000..87d1cb3c --- /dev/null +++ b/pkgs-ts/console/src/form/builder/prompts/choice/pause.ts @@ -0,0 +1,6 @@ +import { pause } from '#console/prompts/choices'; +import type { FormBuilder } from '#console/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/pkgs-ts/console/src/form/builder/prompts/choice/search.ts b/pkgs-ts/console/src/form/builder/prompts/choice/search.ts new file mode 100644 index 00000000..a7477752 --- /dev/null +++ b/pkgs-ts/console/src/form/builder/prompts/choice/search.ts @@ -0,0 +1,39 @@ +import { search } from '#console/prompts/choices'; +import { previousValue } from '#console/form/builder/previous'; +import { resolveSearchFormArguments } from '#console/form/builder/prompts/validators/search'; +import type { FormBuilder } from '#console/form/builder/index'; +import type { SearchPromptOptions } from '#console/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/pkgs-ts/console/src/form/builder/prompts/choice/select.ts b/pkgs-ts/console/src/form/builder/prompts/choice/select.ts new file mode 100644 index 00000000..a32f3cb3 --- /dev/null +++ b/pkgs-ts/console/src/form/builder/prompts/choice/select.ts @@ -0,0 +1,39 @@ +import { select } from '#console/prompts/choices'; +import { previousValue } from '#console/form/builder/previous'; +import { resolveSelectFormArguments } from '#console/form/builder/prompts/validators/select'; +import type { FormBuilder } from '#console/form/builder/index'; +import type { ChoiceOptions, SelectPromptOptions } from '#console/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/pkgs-ts/console/src/form/builder/prompts/choice/suggest.ts b/pkgs-ts/console/src/form/builder/prompts/choice/suggest.ts new file mode 100644 index 00000000..909790ef --- /dev/null +++ b/pkgs-ts/console/src/form/builder/prompts/choice/suggest.ts @@ -0,0 +1,42 @@ +import { suggest } from '#console/prompts/choices'; +import { previousString } from '#console/form/builder/previous'; +import { resolveSuggestFormArguments } from '#console/form/builder/prompts/validators/suggest'; +import type { FormBuilder } from '#console/form/builder/index'; +import type { SuggestOptions } from '#console/prompts/choices'; +import type { TextPromptOptions } from '#console/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/pkgs-ts/console/src/form/builder/prompts/choices.ts b/pkgs-ts/console/src/form/builder/prompts/choices.ts new file mode 100644 index 00000000..cc57125e --- /dev/null +++ b/pkgs-ts/console/src/form/builder/prompts/choices.ts @@ -0,0 +1,24 @@ +import type { FormBuilder } from '#console/form/builder/index'; +import type { ChoicePromptBuilderMethods } from '#console/form/builder/prompts/types'; + +import { + autocompleteFormStep, + confirmFormStep, + multisearchFormStep, + multiselectFormStep, + pauseFormStep, + searchFormStep, + selectFormStep, + suggestFormStep, +} from '#console/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/pkgs-ts/console/src/form/builder/prompts/contracts/basic.ts b/pkgs-ts/console/src/form/builder/prompts/contracts/basic.ts new file mode 100644 index 00000000..a41fa5e0 --- /dev/null +++ b/pkgs-ts/console/src/form/builder/prompts/contracts/basic.ts @@ -0,0 +1,57 @@ +import type { FormBuilder } from '#console/form/builder/index'; +import type { NumberPromptOptions, TextareaPromptOptions, TextPromptOptions } from '#console/types'; +import type { MaybePromise } from '#console/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/pkgs-ts/console/src/form/builder/prompts/contracts/choices.ts b/pkgs-ts/console/src/form/builder/prompts/contracts/choices.ts new file mode 100644 index 00000000..6420f3ae --- /dev/null +++ b/pkgs-ts/console/src/form/builder/prompts/contracts/choices.ts @@ -0,0 +1,17 @@ +import type { AutocompletePromptBuilderMethods } from '#console/form/builder/prompts/contracts/choices/autocomplete'; +import type { ConfirmPromptBuilderMethods } from '#console/form/builder/prompts/contracts/choices/confirm'; +import type { MultiSearchPromptBuilderMethods } from '#console/form/builder/prompts/contracts/choices/multisearch'; +import type { MultiSelectPromptBuilderMethods } from '#console/form/builder/prompts/contracts/choices/multiselect'; +import type { PausePromptBuilderMethods } from '#console/form/builder/prompts/contracts/choices/pause'; +import type { SearchPromptBuilderMethods } from '#console/form/builder/prompts/contracts/choices/search'; +import type { SelectPromptBuilderMethods } from '#console/form/builder/prompts/contracts/choices/select'; +import type { SuggestPromptBuilderMethods } from '#console/form/builder/prompts/contracts/choices/suggest'; + +export type ChoicePromptBuilderMethods = AutocompletePromptBuilderMethods & + ConfirmPromptBuilderMethods & + MultiSearchPromptBuilderMethods & + MultiSelectPromptBuilderMethods & + PausePromptBuilderMethods & + SearchPromptBuilderMethods & + SelectPromptBuilderMethods & + SuggestPromptBuilderMethods; diff --git a/pkgs-ts/console/src/form/builder/prompts/contracts/choices/autocomplete.ts b/pkgs-ts/console/src/form/builder/prompts/contracts/choices/autocomplete.ts new file mode 100644 index 00000000..d3f31b51 --- /dev/null +++ b/pkgs-ts/console/src/form/builder/prompts/contracts/choices/autocomplete.ts @@ -0,0 +1,20 @@ +import type { FormBuilder } from '#console/form/builder/index'; +import type { SuggestOptions } from '#console/prompts/choices'; +import type { TextPromptOptions } from '#console/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/pkgs-ts/console/src/form/builder/prompts/contracts/choices/confirm.ts b/pkgs-ts/console/src/form/builder/prompts/contracts/choices/confirm.ts new file mode 100644 index 00000000..b0fd62fe --- /dev/null +++ b/pkgs-ts/console/src/form/builder/prompts/contracts/choices/confirm.ts @@ -0,0 +1,18 @@ +import type { FormBuilder } from '#console/form/builder/index'; +import type { ConfirmPromptOptions, MaybePromise } from '#console/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/pkgs-ts/console/src/form/builder/prompts/contracts/choices/multisearch.ts b/pkgs-ts/console/src/form/builder/prompts/contracts/choices/multisearch.ts new file mode 100644 index 00000000..9d940fd4 --- /dev/null +++ b/pkgs-ts/console/src/form/builder/prompts/contracts/choices/multisearch.ts @@ -0,0 +1,19 @@ +import type { FormBuilder } from '#console/form/builder/index'; +import type { MultiSearchPromptOptions } from '#console/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/pkgs-ts/console/src/form/builder/prompts/contracts/choices/multiselect.ts b/pkgs-ts/console/src/form/builder/prompts/contracts/choices/multiselect.ts new file mode 100644 index 00000000..5d8e46d8 --- /dev/null +++ b/pkgs-ts/console/src/form/builder/prompts/contracts/choices/multiselect.ts @@ -0,0 +1,19 @@ +import type { FormBuilder } from '#console/form/builder/index'; +import type { ChoiceOptions, MaybePromise, MultiSelectPromptOptions } from '#console/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/pkgs-ts/console/src/form/builder/prompts/contracts/choices/pause.ts b/pkgs-ts/console/src/form/builder/prompts/contracts/choices/pause.ts new file mode 100644 index 00000000..64c441d5 --- /dev/null +++ b/pkgs-ts/console/src/form/builder/prompts/contracts/choices/pause.ts @@ -0,0 +1,5 @@ +import type { FormBuilder } from '#console/form/builder/index'; + +export type PausePromptBuilderMethods = { + pause(this: FormBuilder, message?: string, name?: string): FormBuilder; +}; diff --git a/pkgs-ts/console/src/form/builder/prompts/contracts/choices/search.ts b/pkgs-ts/console/src/form/builder/prompts/contracts/choices/search.ts new file mode 100644 index 00000000..fbbe4d90 --- /dev/null +++ b/pkgs-ts/console/src/form/builder/prompts/contracts/choices/search.ts @@ -0,0 +1,19 @@ +import type { FormBuilder } from '#console/form/builder/index'; +import type { SearchPromptOptions } from '#console/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/pkgs-ts/console/src/form/builder/prompts/contracts/choices/select.ts b/pkgs-ts/console/src/form/builder/prompts/contracts/choices/select.ts new file mode 100644 index 00000000..d894c24a --- /dev/null +++ b/pkgs-ts/console/src/form/builder/prompts/contracts/choices/select.ts @@ -0,0 +1,19 @@ +import type { FormBuilder } from '#console/form/builder/index'; +import type { ChoiceOptions, MaybePromise, SelectPromptOptions } from '#console/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/pkgs-ts/console/src/form/builder/prompts/contracts/choices/suggest.ts b/pkgs-ts/console/src/form/builder/prompts/contracts/choices/suggest.ts new file mode 100644 index 00000000..03be03c2 --- /dev/null +++ b/pkgs-ts/console/src/form/builder/prompts/contracts/choices/suggest.ts @@ -0,0 +1,21 @@ +import type { FormBuilder } from '#console/form/builder/index'; +import type { SuggestOptions } from '#console/prompts/choices'; +import type { TextPromptOptions } from '#console/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/pkgs-ts/console/src/form/builder/prompts/contracts/index.ts b/pkgs-ts/console/src/form/builder/prompts/contracts/index.ts new file mode 100644 index 00000000..6eb93c7f --- /dev/null +++ b/pkgs-ts/console/src/form/builder/prompts/contracts/index.ts @@ -0,0 +1,4 @@ +import type { BasicPromptBuilderMethods } from '#console/form/builder/prompts/contracts/basic'; +import type { ChoicePromptBuilderMethods } from '#console/form/builder/prompts/contracts/choices'; + +export type PromptBuilderMethods = BasicPromptBuilderMethods & ChoicePromptBuilderMethods; diff --git a/pkgs-ts/console/src/form/builder/prompts/types.ts b/pkgs-ts/console/src/form/builder/prompts/types.ts new file mode 100644 index 00000000..2297ac95 --- /dev/null +++ b/pkgs-ts/console/src/form/builder/prompts/types.ts @@ -0,0 +1,3 @@ +export type { BasicPromptBuilderMethods } from '#console/form/builder/prompts/contracts/basic'; +export type { ChoicePromptBuilderMethods } from '#console/form/builder/prompts/contracts/choices'; +export type { PromptBuilderMethods } from '#console/form/builder/prompts/contracts/index'; diff --git a/packages/tui/src/form/builder/prompts/validators/basic-label.ts b/pkgs-ts/console/src/form/builder/prompts/validators/basic-label.ts similarity index 100% rename from packages/tui/src/form/builder/prompts/validators/basic-label.ts rename to pkgs-ts/console/src/form/builder/prompts/validators/basic-label.ts diff --git a/packages/tui/src/form/builder/prompts/validators/basic-number.ts b/pkgs-ts/console/src/form/builder/prompts/validators/basic-number.ts similarity index 91% rename from packages/tui/src/form/builder/prompts/validators/basic-number.ts rename to pkgs-ts/console/src/form/builder/prompts/validators/basic-number.ts index 955535c3..8753f58e 100644 --- a/packages/tui/src/form/builder/prompts/validators/basic-number.ts +++ b/pkgs-ts/console/src/form/builder/prompts/validators/basic-number.ts @@ -1,5 +1,5 @@ -import { isBasicPromptLabel, isBasicPromptOptions, parseBasicStepName } from '#tui/form/builder/prompts/validators/basic-label'; -import type { NumberPromptOptions } from '#tui/types'; +import { isBasicPromptLabel, isBasicPromptOptions, parseBasicStepName } from '#console/form/builder/prompts/validators/basic-label'; +import type { NumberPromptOptions } from '#console/types'; export type ResolvedNumberFormArguments = | { diff --git a/packages/tui/src/form/builder/prompts/validators/basic-text.ts b/pkgs-ts/console/src/form/builder/prompts/validators/basic-text.ts similarity index 89% rename from packages/tui/src/form/builder/prompts/validators/basic-text.ts rename to pkgs-ts/console/src/form/builder/prompts/validators/basic-text.ts index 5d8d855f..68890db1 100644 --- a/packages/tui/src/form/builder/prompts/validators/basic-text.ts +++ b/pkgs-ts/console/src/form/builder/prompts/validators/basic-text.ts @@ -1,5 +1,5 @@ -import { isBasicPromptOptions, parseBasicStepName } from '#tui/form/builder/prompts/validators/basic-label'; -import type { TextPromptOptions } from '#tui/types'; +import { isBasicPromptOptions, parseBasicStepName } from '#console/form/builder/prompts/validators/basic-label'; +import type { TextPromptOptions } from '#console/types'; export type ResolvedTextFormArguments = { name?: string; diff --git a/packages/tui/src/form/builder/prompts/validators/basic-textarea.ts b/pkgs-ts/console/src/form/builder/prompts/validators/basic-textarea.ts similarity index 82% rename from packages/tui/src/form/builder/prompts/validators/basic-textarea.ts rename to pkgs-ts/console/src/form/builder/prompts/validators/basic-textarea.ts index dea0b4ab..fc2c5b2a 100644 --- a/packages/tui/src/form/builder/prompts/validators/basic-textarea.ts +++ b/pkgs-ts/console/src/form/builder/prompts/validators/basic-textarea.ts @@ -1,5 +1,5 @@ -import { isBasicPromptOptions, parseBasicStepName } from '#tui/form/builder/prompts/validators/basic-label'; -import type { TextareaPromptOptions } from '#tui/types'; +import { isBasicPromptOptions, parseBasicStepName } from '#console/form/builder/prompts/validators/basic-label'; +import type { TextareaPromptOptions } from '#console/types'; export type ResolvedTextareaFormArguments = { name?: string; diff --git a/pkgs-ts/console/src/form/builder/prompts/validators/basic.ts b/pkgs-ts/console/src/form/builder/prompts/validators/basic.ts new file mode 100644 index 00000000..1b2eb3e9 --- /dev/null +++ b/pkgs-ts/console/src/form/builder/prompts/validators/basic.ts @@ -0,0 +1,7 @@ +export { isBasicPromptLabel, isBasicPromptOptions, parseBasicStepName } from '#console/form/builder/prompts/validators/basic-label'; +export { resolveNumberFormArguments } from '#console/form/builder/prompts/validators/basic-number'; +export type { ResolvedNumberFormArguments } from '#console/form/builder/prompts/validators/basic-number'; +export { resolvePasswordFormArguments, resolveTextFormArguments } from '#console/form/builder/prompts/validators/basic-text'; +export type { ResolvedTextFormArguments } from '#console/form/builder/prompts/validators/basic-text'; +export { resolveTextareaFormArguments } from '#console/form/builder/prompts/validators/basic-textarea'; +export type { ResolvedTextareaFormArguments } from '#console/form/builder/prompts/validators/basic-textarea'; diff --git a/pkgs-ts/console/src/form/builder/prompts/validators/search.ts b/pkgs-ts/console/src/form/builder/prompts/validators/search.ts new file mode 100644 index 00000000..b68e8c12 --- /dev/null +++ b/pkgs-ts/console/src/form/builder/prompts/validators/search.ts @@ -0,0 +1,6 @@ +export { isSearchPromptLabel, isSearchPromptOptions, parseSearchChoiceSource, parseSearchStepName } from '#console/form/builder/prompts/validators/search/common'; +export type { SearchChoiceSourceCallback } from '#console/form/builder/prompts/validators/search/common'; +export { resolveMultiSearchFormArguments } from '#console/form/builder/prompts/validators/search/multisearch'; +export type { ResolvedMultiSearchFormArguments } from '#console/form/builder/prompts/validators/search/multisearch'; +export { resolveSearchFormArguments } from '#console/form/builder/prompts/validators/search/search'; +export type { ResolvedSearchFormArguments } from '#console/form/builder/prompts/validators/search/search'; diff --git a/pkgs-ts/console/src/form/builder/prompts/validators/search/common.ts b/pkgs-ts/console/src/form/builder/prompts/validators/search/common.ts new file mode 100644 index 00000000..db4a41a4 --- /dev/null +++ b/pkgs-ts/console/src/form/builder/prompts/validators/search/common.ts @@ -0,0 +1,36 @@ +import { z } from 'zod'; +import { choiceOptionsSchema } from '#console/concerns/validators/choice'; +import { functionSchema } from '#console/validators/function'; +import type { ChoiceOptions, MaybePromise } from '#console/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/pkgs-ts/console/src/form/builder/prompts/validators/search/multisearch.ts b/pkgs-ts/console/src/form/builder/prompts/validators/search/multisearch.ts new file mode 100644 index 00000000..6a65d3a9 --- /dev/null +++ b/pkgs-ts/console/src/form/builder/prompts/validators/search/multisearch.ts @@ -0,0 +1,43 @@ +import { isSearchPromptOptions, parseSearchChoiceSource, parseSearchStepName } from '#console/form/builder/prompts/validators/search/common'; +import type { MultiSearchPromptOptions } from '#console/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/pkgs-ts/console/src/form/builder/prompts/validators/search/search.ts b/pkgs-ts/console/src/form/builder/prompts/validators/search/search.ts new file mode 100644 index 00000000..cdb3fdeb --- /dev/null +++ b/pkgs-ts/console/src/form/builder/prompts/validators/search/search.ts @@ -0,0 +1,43 @@ +import { isSearchPromptOptions, parseSearchChoiceSource, parseSearchStepName } from '#console/form/builder/prompts/validators/search/common'; +import type { SearchPromptOptions } from '#console/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/pkgs-ts/console/src/form/builder/prompts/validators/select.ts b/pkgs-ts/console/src/form/builder/prompts/validators/select.ts new file mode 100644 index 00000000..43f4cab6 --- /dev/null +++ b/pkgs-ts/console/src/form/builder/prompts/validators/select.ts @@ -0,0 +1,7 @@ +export { isSelectPromptLabel, isSelectPromptOptions, parseConfirmDefault, parseSelectChoiceOptions, parseSelectStepName } from '#console/form/builder/prompts/validators/select/common'; +export { resolveConfirmFormArguments } from '#console/form/builder/prompts/validators/select/confirm'; +export type { ResolvedConfirmFormArguments } from '#console/form/builder/prompts/validators/select/confirm'; +export { resolveMultiSelectFormArguments } from '#console/form/builder/prompts/validators/select/multiselect'; +export type { ResolvedMultiSelectFormArguments } from '#console/form/builder/prompts/validators/select/multiselect'; +export { resolveSelectFormArguments } from '#console/form/builder/prompts/validators/select/select'; +export type { ResolvedSelectFormArguments } from '#console/form/builder/prompts/validators/select/select'; diff --git a/pkgs-ts/console/src/form/builder/prompts/validators/select/common.ts b/pkgs-ts/console/src/form/builder/prompts/validators/select/common.ts new file mode 100644 index 00000000..cc98ca3c --- /dev/null +++ b/pkgs-ts/console/src/form/builder/prompts/validators/select/common.ts @@ -0,0 +1,36 @@ +import { z } from 'zod'; +import { choiceOptionsSchema } from '#console/concerns/validators/choice'; +import type { ChoiceOptions } from '#console/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/pkgs-ts/console/src/form/builder/prompts/validators/select/confirm.ts b/pkgs-ts/console/src/form/builder/prompts/validators/select/confirm.ts new file mode 100644 index 00000000..aa33a37e --- /dev/null +++ b/pkgs-ts/console/src/form/builder/prompts/validators/select/confirm.ts @@ -0,0 +1,41 @@ +import { isSelectPromptOptions, parseConfirmDefault, parseSelectStepName } from '#console/form/builder/prompts/validators/select/common'; +import type { ConfirmPromptOptions } from '#console/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/pkgs-ts/console/src/form/builder/prompts/validators/select/multiselect.ts b/pkgs-ts/console/src/form/builder/prompts/validators/select/multiselect.ts new file mode 100644 index 00000000..ffda8d82 --- /dev/null +++ b/pkgs-ts/console/src/form/builder/prompts/validators/select/multiselect.ts @@ -0,0 +1,42 @@ +import { isSelectPromptOptions, parseSelectChoiceOptions, parseSelectStepName } from '#console/form/builder/prompts/validators/select/common'; +import type { ChoiceOptions, MultiSelectPromptOptions } from '#console/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/pkgs-ts/console/src/form/builder/prompts/validators/select/select.ts b/pkgs-ts/console/src/form/builder/prompts/validators/select/select.ts new file mode 100644 index 00000000..08d6d9ab --- /dev/null +++ b/pkgs-ts/console/src/form/builder/prompts/validators/select/select.ts @@ -0,0 +1,42 @@ +import { isSelectPromptOptions, parseSelectChoiceOptions, parseSelectStepName } from '#console/form/builder/prompts/validators/select/common'; +import type { ChoiceOptions, SelectPromptOptions } from '#console/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/pkgs-ts/console/src/form/builder/prompts/validators/suggest.ts b/pkgs-ts/console/src/form/builder/prompts/validators/suggest.ts new file mode 100644 index 00000000..4f511e51 --- /dev/null +++ b/pkgs-ts/console/src/form/builder/prompts/validators/suggest.ts @@ -0,0 +1,6 @@ +export { resolveAutocompleteFormArguments } from '#console/form/builder/prompts/validators/suggest/autocomplete'; +export type { ResolvedAutocompleteFormArguments } from '#console/form/builder/prompts/validators/suggest/autocomplete'; +export { isSuggestPromptLabel, isSuggestPromptOptions, parseSuggestSource, parseSuggestStepName } from '#console/form/builder/prompts/validators/suggest/common'; +export type { SuggestSourceCallback } from '#console/form/builder/prompts/validators/suggest/common'; +export { resolveSuggestFormArguments } from '#console/form/builder/prompts/validators/suggest/suggest'; +export type { ResolvedSuggestFormArguments } from '#console/form/builder/prompts/validators/suggest/suggest'; diff --git a/pkgs-ts/console/src/form/builder/prompts/validators/suggest/autocomplete.ts b/pkgs-ts/console/src/form/builder/prompts/validators/suggest/autocomplete.ts new file mode 100644 index 00000000..b6f77389 --- /dev/null +++ b/pkgs-ts/console/src/form/builder/prompts/validators/suggest/autocomplete.ts @@ -0,0 +1,44 @@ +import { isSuggestPromptOptions, parseSuggestSource, parseSuggestStepName } from '#console/form/builder/prompts/validators/suggest/common'; +import type { SuggestOptions } from '#console/prompts/suggest/options'; +import type { TextPromptOptions } from '#console/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/pkgs-ts/console/src/form/builder/prompts/validators/suggest/common.ts b/pkgs-ts/console/src/form/builder/prompts/validators/suggest/common.ts new file mode 100644 index 00000000..09b36767 --- /dev/null +++ b/pkgs-ts/console/src/form/builder/prompts/validators/suggest/common.ts @@ -0,0 +1,33 @@ +import { z } from 'zod'; +import { functionSchema } from '#console/validators/function'; +import type { MaybePromise } from '#console/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/pkgs-ts/console/src/form/builder/prompts/validators/suggest/suggest.ts b/pkgs-ts/console/src/form/builder/prompts/validators/suggest/suggest.ts new file mode 100644 index 00000000..8c33c0a5 --- /dev/null +++ b/pkgs-ts/console/src/form/builder/prompts/validators/suggest/suggest.ts @@ -0,0 +1,46 @@ +import { isSuggestPromptOptions, parseSuggestSource, parseSuggestStepName } from '#console/form/builder/prompts/validators/suggest/common'; +import type { SuggestOptions } from '#console/prompts/suggest/options'; +import type { TextPromptOptions } from '#console/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/pkgs-ts/console/src/form/builder/responses.ts similarity index 75% rename from packages/tui/src/form/builder/responses.ts rename to pkgs-ts/console/src/form/builder/responses.ts index ff83a1c5..2da93638 100644 --- a/packages/tui/src/form/builder/responses.ts +++ b/pkgs-ts/console/src/form/builder/responses.ts @@ -1,4 +1,4 @@ -import type { FormResponses } from '#tui/form/types'; +import type { FormResponses } from '#console/form/types'; class FormResponseList extends Array implements FormResponses { [name: string]: unknown; diff --git a/pkgs-ts/console/src/form/builder/revert.ts b/pkgs-ts/console/src/form/builder/revert.ts new file mode 100644 index 00000000..61048c4d --- /dev/null +++ b/pkgs-ts/console/src/form/builder/revert.ts @@ -0,0 +1,42 @@ +import { promptEnvironment, withPromptEnvironment } from '#console/environment'; +import { Key } from '#console/key'; +import { rejectPromptRevert } from '#console/prompt/revert'; +import type { MaybePromise, PromptInput } from '#console/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/pkgs-ts/console/src/form/builder/status.ts b/pkgs-ts/console/src/form/builder/status.ts new file mode 100644 index 00000000..a94fa562 --- /dev/null +++ b/pkgs-ts/console/src/form/builder/status.ts @@ -0,0 +1,23 @@ +import { progressFormStep, spinFormStep, streamFormStep, taskFormStep } from '#console/form/builder/status/index'; +import type { FormBuilder } from '#console/form/builder/index'; +import type { MaybePromise } from '#console/types'; +import type { Logger, Progress, TaskDefinition } from '#console/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/pkgs-ts/console/src/form/builder/status/index.ts b/pkgs-ts/console/src/form/builder/status/index.ts new file mode 100644 index 00000000..6324f6ec --- /dev/null +++ b/pkgs-ts/console/src/form/builder/status/index.ts @@ -0,0 +1,4 @@ +export { progressFormStep } from '#console/form/builder/status/progress'; +export { spinFormStep } from '#console/form/builder/status/spin'; +export { streamFormStep } from '#console/form/builder/status/stream'; +export { taskFormStep } from '#console/form/builder/status/task'; diff --git a/pkgs-ts/console/src/form/builder/status/progress.ts b/pkgs-ts/console/src/form/builder/status/progress.ts new file mode 100644 index 00000000..d64cf6e7 --- /dev/null +++ b/pkgs-ts/console/src/form/builder/status/progress.ts @@ -0,0 +1,38 @@ +import { progress } from '#console/status'; +import { resolveProgressFormArguments } from '#console/form/builder/validators/status'; +import type { FormBuilder } from '#console/form/builder/index'; +import type { MaybePromise } from '#console/types'; +import type { Progress } from '#console/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/pkgs-ts/console/src/form/builder/status/spin.ts b/pkgs-ts/console/src/form/builder/status/spin.ts new file mode 100644 index 00000000..af8813a2 --- /dev/null +++ b/pkgs-ts/console/src/form/builder/status/spin.ts @@ -0,0 +1,18 @@ +import { spin } from '#console/status'; +import { resolveSpinFormArguments } from '#console/form/builder/validators/status'; +import type { FormBuilder } from '#console/form/builder/index'; +import type { MaybePromise } from '#console/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/pkgs-ts/console/src/form/builder/status/stream.ts b/pkgs-ts/console/src/form/builder/status/stream.ts new file mode 100644 index 00000000..d951c416 --- /dev/null +++ b/pkgs-ts/console/src/form/builder/status/stream.ts @@ -0,0 +1,19 @@ +import { stream } from '#console/status'; +import { resolveStreamFormArguments } from '#console/form/builder/validators/status'; +import type { FormBuilder } from '#console/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/pkgs-ts/console/src/form/builder/status/task.ts b/pkgs-ts/console/src/form/builder/status/task.ts new file mode 100644 index 00000000..ad0bfe01 --- /dev/null +++ b/pkgs-ts/console/src/form/builder/status/task.ts @@ -0,0 +1,27 @@ +import { task } from '#console/status'; +import { resolveTaskFormArguments } from '#console/form/builder/validators/status'; +import type { FormBuilder } from '#console/form/builder/index'; +import type { MaybePromise } from '#console/types'; +import type { Logger, TaskDefinition } from '#console/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/pkgs-ts/console/src/form/builder/step.ts b/pkgs-ts/console/src/form/builder/step.ts new file mode 100644 index 00000000..47b8329b --- /dev/null +++ b/pkgs-ts/console/src/form/builder/step.ts @@ -0,0 +1,9 @@ +import type { MaybePromise } from '#console/types'; + +export const sideEffectStep = (callback: () => MaybePromise): (() => MaybePromise) => { + return async () => { + await callback(); + + return null; + }; +}; diff --git a/pkgs-ts/console/src/form/builder/submit.ts b/pkgs-ts/console/src/form/builder/submit.ts new file mode 100644 index 00000000..c96d2ad1 --- /dev/null +++ b/pkgs-ts/console/src/form/builder/submit.ts @@ -0,0 +1,46 @@ +import { shouldIgnoreStepWhenReverting, shouldRunStep } from '#console/form/builder/conditions'; +import { runWithFormRevert } from '#console/form/builder/revert'; +import { isFormRevertedError } from '#console/form/builder/validators/revert'; +import type { FormResponses, FormStep } from '#console/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/pkgs-ts/console/src/form/builder/validators/conditions.ts b/pkgs-ts/console/src/form/builder/validators/conditions.ts new file mode 100644 index 00000000..21c8d96b --- /dev/null +++ b/pkgs-ts/console/src/form/builder/validators/conditions.ts @@ -0,0 +1,15 @@ +import { z } from 'zod'; +import type { FormResponses, FormStepCondition } from '#console/form/types'; +import type { MaybePromise } from '#console/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/pkgs-ts/console/src/form/builder/validators/output.ts b/pkgs-ts/console/src/form/builder/validators/output.ts new file mode 100644 index 00000000..f425e743 --- /dev/null +++ b/pkgs-ts/console/src/form/builder/validators/output.ts @@ -0,0 +1,5 @@ +export { isNullOutputRows, parseOutputScroll, parseOutputStepName } from '#console/form/builder/validators/output/common'; +export { parseOutputDataTableRows, resolveDataTableFormArguments } from '#console/form/builder/validators/output/data-table'; +export type { ResolvedDataTableFormArguments } from '#console/form/builder/validators/output/data-table'; +export { parseOutputTableRows, resolveTableFormArguments } from '#console/form/builder/validators/output/table'; +export type { ResolvedTableFormArguments } from '#console/form/builder/validators/output/table'; diff --git a/packages/tui/src/form/builder/validators/output/common.ts b/pkgs-ts/console/src/form/builder/validators/output/common.ts similarity index 100% rename from packages/tui/src/form/builder/validators/output/common.ts rename to pkgs-ts/console/src/form/builder/validators/output/common.ts diff --git a/pkgs-ts/console/src/form/builder/validators/output/data-table.ts b/pkgs-ts/console/src/form/builder/validators/output/data-table.ts new file mode 100644 index 00000000..13e53578 --- /dev/null +++ b/pkgs-ts/console/src/form/builder/validators/output/data-table.ts @@ -0,0 +1,61 @@ +import { isNullOutputRows, parseOutputScroll, parseOutputStepName } from '#console/form/builder/validators/output/common'; +import { dataTableStepName, isDataTablePromptOptions } from '#console/output/validators/data-table'; +import { dataTableRowsSchema } from '#console/output/validators/data-table/schemas'; +import type { z } from 'zod'; +import type { DataTablePromptOptions, DataTableRow } from '#console/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/pkgs-ts/console/src/form/builder/validators/output/table.ts b/pkgs-ts/console/src/form/builder/validators/output/table.ts new file mode 100644 index 00000000..cee4144a --- /dev/null +++ b/pkgs-ts/console/src/form/builder/validators/output/table.ts @@ -0,0 +1,59 @@ +import { isNullOutputRows } from '#console/form/builder/validators/output/common'; +import { isTableOptions, tableStepName } from '#console/output/validators/table'; +import { tableRowsSchema } from '#console/output/validators/table/schemas'; +import type { TableOptions } from '#console/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/pkgs-ts/console/src/form/builder/validators/previous.ts similarity index 100% rename from packages/tui/src/form/builder/validators/previous.ts rename to pkgs-ts/console/src/form/builder/validators/previous.ts diff --git a/pkgs-ts/console/src/form/builder/validators/revert.ts b/pkgs-ts/console/src/form/builder/validators/revert.ts new file mode 100644 index 00000000..2100e093 --- /dev/null +++ b/pkgs-ts/console/src/form/builder/validators/revert.ts @@ -0,0 +1,8 @@ +import { z } from 'zod'; +import { FormRevertedError } from '#console/form/builder/revert'; + +const formRevertedErrorSchema = z.instanceof(FormRevertedError); + +export const isFormRevertedError = (error: unknown): error is FormRevertedError => { + return formRevertedErrorSchema.safeParse(error).success; +}; diff --git a/pkgs-ts/console/src/form/builder/validators/status.ts b/pkgs-ts/console/src/form/builder/validators/status.ts new file mode 100644 index 00000000..6bd81d0d --- /dev/null +++ b/pkgs-ts/console/src/form/builder/validators/status.ts @@ -0,0 +1,9 @@ +export { isProgressTotal, isStatusLabel, parseProgressCallback, parseStatusCallback, parseStatusLabel } from '#console/form/builder/validators/status/common'; +export { resolveProgressFormArguments } from '#console/form/builder/validators/status/progress'; +export type { ResolvedProgressFormArguments } from '#console/form/builder/validators/status/progress'; +export { resolveSpinFormArguments } from '#console/form/builder/validators/status/spin'; +export type { ResolvedSpinFormArguments } from '#console/form/builder/validators/status/spin'; +export { isStreamSource, resolveStreamFormArguments } from '#console/form/builder/validators/status/stream'; +export type { ResolvedStreamFormArguments } from '#console/form/builder/validators/status/stream'; +export { resolveTaskFormArguments } from '#console/form/builder/validators/status/task'; +export type { ResolvedTaskFormArguments } from '#console/form/builder/validators/status/task'; diff --git a/pkgs-ts/console/src/form/builder/validators/status/common.ts b/pkgs-ts/console/src/form/builder/validators/status/common.ts new file mode 100644 index 00000000..5aac4b81 --- /dev/null +++ b/pkgs-ts/console/src/form/builder/validators/status/common.ts @@ -0,0 +1,50 @@ +import { z } from 'zod'; +import { functionSchema } from '#console/validators/function'; +import type { MaybePromise } from '#console/types'; +import type { Progress } from '#console/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/pkgs-ts/console/src/form/builder/validators/status/progress.ts b/pkgs-ts/console/src/form/builder/validators/status/progress.ts new file mode 100644 index 00000000..ecf54885 --- /dev/null +++ b/pkgs-ts/console/src/form/builder/validators/status/progress.ts @@ -0,0 +1,46 @@ +import { isProgressTotal, isStatusLabel, parseRequiredProgressCallback, parseStatusLabel } from '#console/form/builder/validators/status/common'; +import { progressStepsArgument } from '#console/status/progress/validators/arguments'; +import type { MaybePromise } from '#console/types'; +import type { Progress } from '#console/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/pkgs-ts/console/src/form/builder/validators/status/spin.ts b/pkgs-ts/console/src/form/builder/validators/status/spin.ts new file mode 100644 index 00000000..69654850 --- /dev/null +++ b/pkgs-ts/console/src/form/builder/validators/status/spin.ts @@ -0,0 +1,38 @@ +import { isStatusLabel, parseStatusCallback, parseStatusLabel } from '#console/form/builder/validators/status/common'; +import type { MaybePromise } from '#console/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/pkgs-ts/console/src/form/builder/validators/status/stream.ts b/pkgs-ts/console/src/form/builder/validators/status/stream.ts new file mode 100644 index 00000000..025a6d2d --- /dev/null +++ b/pkgs-ts/console/src/form/builder/validators/status/stream.ts @@ -0,0 +1,32 @@ +import { z } from 'zod'; +import { isStatusLabel } from '#console/form/builder/validators/status/common'; +import { asyncIterableSchema, iterableSchema } from '#console/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/pkgs-ts/console/src/form/builder/validators/status/task.ts b/pkgs-ts/console/src/form/builder/validators/status/task.ts new file mode 100644 index 00000000..6f2bd362 --- /dev/null +++ b/pkgs-ts/console/src/form/builder/validators/status/task.ts @@ -0,0 +1,48 @@ +import { isStatusLabel, parseStatusLabel } from '#console/form/builder/validators/status/common'; +import { parseTaskCallback } from '#console/status/task/validators/definition'; +import type { MaybePromise } from '#console/types'; +import type { TaskDefinition } from '#console/status'; +import type { Logger } from '#console/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/pkgs-ts/console/src/form/types.ts b/pkgs-ts/console/src/form/types.ts new file mode 100644 index 00000000..5ae26a6d --- /dev/null +++ b/pkgs-ts/console/src/form/types.ts @@ -0,0 +1,14 @@ +import type { MaybePromise } from '#console/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/pkgs-ts/console/src/index.ts b/pkgs-ts/console/src/index.ts new file mode 100644 index 00000000..052fda94 --- /dev/null +++ b/pkgs-ts/console/src/index.ts @@ -0,0 +1,83 @@ +export { configurePrompts, createMemoryOutput, createScriptedInput, promptEnvironment, withPromptEnvironment } from '#console/environment'; +export type { MemoryOutput, RawKeyInput } from '#console/environment'; +export { FormBuilder, form } from '#console/form'; +export type { FormResponses, FormStep, FormStepCondition } from '#console/form'; +export { Key, keyFromEvent, oneOf } from '#console/key'; +export type { KeyboardEventLike, KeyName, KeyValue } from '#console/key'; +export { renderOpenTuiTextFrame } from '#console/opentui'; +export type { OpenTuiFrame } from '#console/opentui'; +export { confirm, select, multiselect, suggest, search, multisearch, autocomplete, pause } from '#console/prompts/choices'; +export type { SuggestOptions } from '#console/prompts/choices'; +export { number, password, text, textarea } from '#console/prompts/basic'; +export { + alert, + clear, + commandExists, + dataTable, + datatable, + error, + executeNotificationCommand, + grid, + info, + intro, + note, + notificationCommand, + notificationCommands, + notify, + notifyForPlatform, + outro, + table, + title, + warning, +} from '#console/output'; +export type { NoteType, NotificationCommand, NotificationOptions, NotificationPlatform, NotificationRuntime } from '#console/output'; +export { cancelUsing, fallbackUsing, fallbackWhen, PromptValidationError, validateUsing } from '#console/prompt'; +export type { PromptCancelHandler, PromptFallbackCondition, PromptFallbackHandler, PromptFallbackKind, PromptGlobalValidator } from '#console/prompt'; +export { Logger, Progress, Stream, progress, spin, stream, task } from '#console/status'; +export type { ProgressSignalTarget, TaskDefinition } from '#console/status'; +export { + backgroundColor, + clearTerminal, + cursorToStart, + eraseLine, + erasePreviousLines, + foregroundColor, + hideCursor, + setTerminalTitle, + showCursor, + supportsTrueColor, + terminalSize, +} from '#console/terminal'; +export type { TerminalSize } from '#console/terminal'; +export { applyTypedKey, readTypedValue } from '#console/typed-value'; +export type { TypedValueOptions, TypedValueReadResult, TypedValueState } from '#console/typed-value'; +export { parseAnsiSegments, parseAnsiText, truncate, visibleWidth, wrap } from '#console/strings'; +export type { AnsiSegment } from '#console/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 '#console/types'; diff --git a/pkgs-ts/console/src/key.ts b/pkgs-ts/console/src/key.ts new file mode 100644 index 00000000..a90d06db --- /dev/null +++ b/pkgs-ts/console/src/key.ts @@ -0,0 +1,4 @@ +export { Key } from '#console/key/constants'; +export { keyFromEvent } from '#console/key/event'; +export { oneOf } from '#console/key/match'; +export type { KeyboardEventLike, KeyName, KeyValue } from '#console/key/types'; diff --git a/packages/tui/src/key/constants.ts b/pkgs-ts/console/src/key/constants.ts similarity index 100% rename from packages/tui/src/key/constants.ts rename to pkgs-ts/console/src/key/constants.ts diff --git a/pkgs-ts/console/src/key/event.ts b/pkgs-ts/console/src/key/event.ts new file mode 100644 index 00000000..3b8a06b3 --- /dev/null +++ b/pkgs-ts/console/src/key/event.ts @@ -0,0 +1,59 @@ +import { Key } from '#console/key/constants'; +import { ctrlKeyMap, namedKeyMap, shiftedKeyMap } from '#console/key/event/maps'; +import { firstKeyValue, keyValueFromName } from '#console/key/event/value'; +import { parseKeyboardEvent } from '#console/key/validators/event'; +import type { KeyboardEventLike, KeyName } from '#console/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/pkgs-ts/console/src/key/event/maps.ts similarity index 81% rename from packages/tui/src/key/event/maps.ts rename to pkgs-ts/console/src/key/event/maps.ts index da2aa2fd..1db15d74 100644 --- a/packages/tui/src/key/event/maps.ts +++ b/pkgs-ts/console/src/key/event/maps.ts @@ -1,5 +1,5 @@ -import { Key } from '#tui/key/constants'; -import type { KeyName } from '#tui/key/types'; +import { Key } from '#console/key/constants'; +import type { KeyName } from '#console/key/types'; export const ctrlKeyMap: Record = { a: Key.ctrlA, diff --git a/pkgs-ts/console/src/key/event/value.ts b/pkgs-ts/console/src/key/event/value.ts new file mode 100644 index 00000000..8e5281d2 --- /dev/null +++ b/pkgs-ts/console/src/key/event/value.ts @@ -0,0 +1,15 @@ +import { Key } from '#console/key/constants'; +import { parseFirstKeyValue, parseKeyLookupName } from '#console/key/validators/value'; +import type { KeyValue } from '#console/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/pkgs-ts/console/src/key/match.ts b/pkgs-ts/console/src/key/match.ts new file mode 100644 index 00000000..81eef303 --- /dev/null +++ b/pkgs-ts/console/src/key/match.ts @@ -0,0 +1,6 @@ +import { parseKeyMatchValues } from '#console/key/validators/match'; +import type { KeyValue } from '#console/key/types'; + +export const oneOf = (keys: KeyValue[], match: string): string | undefined => { + return parseKeyMatchValues(keys).includes(match) ? match : undefined; +}; diff --git a/pkgs-ts/console/src/key/types.ts b/pkgs-ts/console/src/key/types.ts new file mode 100644 index 00000000..827cac74 --- /dev/null +++ b/pkgs-ts/console/src/key/types.ts @@ -0,0 +1,13 @@ +import type { Key } from '#console/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/pkgs-ts/console/src/key/validators/event.ts b/pkgs-ts/console/src/key/validators/event.ts new file mode 100644 index 00000000..a40e1456 --- /dev/null +++ b/pkgs-ts/console/src/key/validators/event.ts @@ -0,0 +1,18 @@ +import { z } from 'zod'; +import type { KeyboardEventLike } from '#console/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/pkgs-ts/console/src/key/validators/match.ts b/pkgs-ts/console/src/key/validators/match.ts new file mode 100644 index 00000000..d9db546e --- /dev/null +++ b/pkgs-ts/console/src/key/validators/match.ts @@ -0,0 +1,16 @@ +import { z } from 'zod'; +import type { KeyValue } from '#console/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/pkgs-ts/console/src/key/validators/value.ts b/pkgs-ts/console/src/key/validators/value.ts new file mode 100644 index 00000000..2d810a25 --- /dev/null +++ b/pkgs-ts/console/src/key/validators/value.ts @@ -0,0 +1,26 @@ +import { z } from 'zod'; +import { Key } from '#console/key/constants'; +import type { KeyValue } from '#console/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/pkgs-ts/console/src/opentui.ts similarity index 100% rename from packages/tui/src/opentui.ts rename to pkgs-ts/console/src/opentui.ts diff --git a/pkgs-ts/console/src/output.ts b/pkgs-ts/console/src/output.ts new file mode 100644 index 00000000..7d0a52d5 --- /dev/null +++ b/pkgs-ts/console/src/output.ts @@ -0,0 +1,7 @@ +export { alert, error, info, intro, note, outro, warning } from '#console/output/notes'; +export type { NoteType } from '#console/output/notes'; +export { commandExists, executeNotificationCommand, notificationCommand, notificationCommands, notify, notifyForPlatform } from '#console/output/notify'; +export type { NotificationCommand, NotificationOptions, NotificationPlatform, NotificationRuntime } from '#console/output/notify'; +export { datatable } from '#console/output/data-table'; +export { dataTable, grid, table } from '#console/output/table'; +export { clear, title } from '#console/output/terminal'; diff --git a/pkgs-ts/console/src/output/data-table.ts b/pkgs-ts/console/src/output/data-table.ts new file mode 100644 index 00000000..6e3fd006 --- /dev/null +++ b/pkgs-ts/console/src/output/data-table.ts @@ -0,0 +1,35 @@ +import { normalizeDataTablePromptOptions } from '#console/output/data-table/options'; +import { runDataTablePrompt } from '#console/output/data-table/run'; +import { parseDataTablePromptOptions } from '#console/output/validators/data-table'; +import type { DataTablePromptOptions, DataTableRow } from '#console/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/pkgs-ts/console/src/output/data-table/body/active.ts b/pkgs-ts/console/src/output/data-table/body/active.ts new file mode 100644 index 00000000..531a1bb9 --- /dev/null +++ b/pkgs-ts/console/src/output/data-table/body/active.ts @@ -0,0 +1,38 @@ +import { renderTable } from '#console/theme'; +import { expandMultilineDataTableRows } from '#console/output/data-table/multiline'; +import { dataTableRowCells } from '#console/output/data-table/rows'; +import { renderDataTableHeaders } from '#console/output/data-table/body/headers'; +import { renderScrollableDataTable } from '#console/output/data-table/scrollbar'; +import { dataTableRowWindow } from '#console/output/data-table/selection'; +import { fixedVisualDataTableRows } from '#console/output/data-table/visual-window'; +import { fitDataTableColumns } from '#console/output/data-table/widths'; +import type { DataTableBodyOptions } from '#console/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/pkgs-ts/console/src/output/data-table/body/cancelled.ts b/pkgs-ts/console/src/output/data-table/body/cancelled.ts new file mode 100644 index 00000000..f7bbce49 --- /dev/null +++ b/pkgs-ts/console/src/output/data-table/body/cancelled.ts @@ -0,0 +1,20 @@ +import { expandMultilineDataTableRows } from '#console/output/data-table/multiline'; +import { dataTableRowCells } from '#console/output/data-table/rows'; +import { dim, strikethrough } from '#console/theme/styles'; +import type { VisibleDataTableRow } from '#console/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/pkgs-ts/console/src/output/data-table/body/headers.ts similarity index 100% rename from packages/tui/src/output/data-table/body/headers.ts rename to pkgs-ts/console/src/output/data-table/body/headers.ts diff --git a/pkgs-ts/console/src/output/data-table/body/submitted.ts b/pkgs-ts/console/src/output/data-table/body/submitted.ts new file mode 100644 index 00000000..0de1f371 --- /dev/null +++ b/pkgs-ts/console/src/output/data-table/body/submitted.ts @@ -0,0 +1,8 @@ +import { dataTableRowCells } from '#console/output/data-table/rows'; +import type { VisibleDataTableRow } from '#console/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/pkgs-ts/console/src/output/data-table/body/types.ts b/pkgs-ts/console/src/output/data-table/body/types.ts new file mode 100644 index 00000000..4c61ae11 --- /dev/null +++ b/pkgs-ts/console/src/output/data-table/body/types.ts @@ -0,0 +1,11 @@ +import type { VisibleDataTableRow } from '#console/output/data-table/types'; +import type { DataTableRow } from '#console/types'; + +export type DataTableBodyOptions = { + allRows: Array>; + headers: string[]; + query: string; + rows: Array>; + scroll?: number; + selected: number; +}; diff --git a/pkgs-ts/console/src/output/data-table/keys.ts b/pkgs-ts/console/src/output/data-table/keys.ts new file mode 100644 index 00000000..746bdf89 --- /dev/null +++ b/pkgs-ts/console/src/output/data-table/keys.ts @@ -0,0 +1,33 @@ +import { Key, oneOf } from '#console/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/pkgs-ts/console/src/output/data-table/multiline.ts similarity index 100% rename from packages/tui/src/output/data-table/multiline.ts rename to pkgs-ts/console/src/output/data-table/multiline.ts diff --git a/pkgs-ts/console/src/output/data-table/navigation.ts b/pkgs-ts/console/src/output/data-table/navigation.ts new file mode 100644 index 00000000..f74a9829 --- /dev/null +++ b/pkgs-ts/console/src/output/data-table/navigation.ts @@ -0,0 +1,53 @@ +import { parseScrollSize } from '#console/concerns/validators/scroll'; +import { parseDataTableRowTotal, parseDataTableSelectedIndex } from '#console/output/data-table/validators/selection'; +import type { DataTableNavigationAction } from '#console/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/pkgs-ts/console/src/output/data-table/options.ts b/pkgs-ts/console/src/output/data-table/options.ts new file mode 100644 index 00000000..1d545116 --- /dev/null +++ b/pkgs-ts/console/src/output/data-table/options.ts @@ -0,0 +1,45 @@ +import { parseDataTableDefault, parseInitialDataTableRow } from '#console/output/validators/data-table'; +import { dataTableRowValue } from '#console/output/data-table/rows'; +import { hasPromptDefault } from '#console/validators/default'; +import type { DataTablePromptOptions, DataTableRow } from '#console/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/pkgs-ts/console/src/output/data-table/read.ts b/pkgs-ts/console/src/output/data-table/read.ts new file mode 100644 index 00000000..ce8aa6ad --- /dev/null +++ b/pkgs-ts/console/src/output/data-table/read.ts @@ -0,0 +1,15 @@ +import { promptEnvironment } from '#console/environment'; +import { readDataTableFallbackSelection } from '#console/output/data-table/reader/fallback'; +import { readDataTableSelectionInteractive } from '#console/output/data-table/reader/interactive'; +import type { DataTableReadOptions } from '#console/output/data-table/reader/types'; +import type { DataTableSelectionReadResult } from '#console/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/pkgs-ts/console/src/output/data-table/reader/cancel.ts b/pkgs-ts/console/src/output/data-table/reader/cancel.ts new file mode 100644 index 00000000..b51714ba --- /dev/null +++ b/pkgs-ts/console/src/output/data-table/reader/cancel.ts @@ -0,0 +1,43 @@ +import { cancelPrompt } from '#console/prompt'; +import { eraseRenderedFrame } from '#console/status/frame'; +import { renderCancelledDataTableFrame } from '#console/output/data-table/render'; +import { dataTableRowValue } from '#console/output/data-table/rows'; +import { selectedDataTableValue } from '#console/output/data-table/reader/result'; +import type { DataTableReadOptions } from '#console/output/data-table/reader/types'; +import type { DataTableSearchMode } from '#console/output/data-table/search'; +import type { DataTableSelectionReadResult, VisibleDataTableRow } from '#console/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/pkgs-ts/console/src/output/data-table/reader/fallback.ts b/pkgs-ts/console/src/output/data-table/reader/fallback.ts new file mode 100644 index 00000000..d1a9ab29 --- /dev/null +++ b/pkgs-ts/console/src/output/data-table/reader/fallback.ts @@ -0,0 +1,21 @@ +import { ask } from '#console/prompt/ask'; +import { promptEnvironment } from '#console/environment'; +import { visibleDataTableRows } from '#console/output/data-table/rows'; +import { assertSelectedDataTableRow, dataTableSelectionResult, initialDataTableSelection } from '#console/output/data-table/reader/result'; +import type { DataTableReadOptions } from '#console/output/data-table/reader/types'; +import type { DataTableSelectionReadResult } from '#console/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/pkgs-ts/console/src/output/data-table/reader/interactive.ts b/pkgs-ts/console/src/output/data-table/reader/interactive.ts new file mode 100644 index 00000000..8519c1a8 --- /dev/null +++ b/pkgs-ts/console/src/output/data-table/reader/interactive.ts @@ -0,0 +1,58 @@ +import { Key } from '#console/key'; +import { dataTableNavigationAction, startsDataTableSearch } from '#console/output/data-table/keys'; +import { cancelDataTableSelection } from '#console/output/data-table/reader/cancel'; +import { assertSelectedDataTableRow, dataTableSelectionResult } from '#console/output/data-table/reader/result'; +import { createDataTableReaderSession } from '#console/output/data-table/reader/session'; +import type { DataTableReadOptions } from '#console/output/data-table/reader/types'; +import type { DataTableSelectionReadResult } from '#console/output/data-table/types'; +import type { PromptInput } from '#console/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/pkgs-ts/console/src/output/data-table/reader/result.ts b/pkgs-ts/console/src/output/data-table/reader/result.ts new file mode 100644 index 00000000..4948f18b --- /dev/null +++ b/pkgs-ts/console/src/output/data-table/reader/result.ts @@ -0,0 +1,48 @@ +import { PromptValidationError } from '#console/prompt'; +import { choiceValueEquals } from '#console/concerns/choices'; +import { eraseRenderedFrame } from '#console/status/frame'; +import { dataTableRowValue } from '#console/output/data-table/rows'; +import type { DataTableSelectionReadResult, VisibleDataTableRow } from '#console/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/pkgs-ts/console/src/output/data-table/reader/session.ts b/pkgs-ts/console/src/output/data-table/reader/session.ts new file mode 100644 index 00000000..d8ecfffa --- /dev/null +++ b/pkgs-ts/console/src/output/data-table/reader/session.ts @@ -0,0 +1,62 @@ +import { renderDataTableReaderSessionFrame } from '#console/output/data-table/reader/session/render'; +import { createDataTableReaderState } from '#console/output/data-table/reader/state'; +import type { DataTableNavigationAction } from '#console/output/data-table/keys'; +import type { DataTableReadOptions } from '#console/output/data-table/reader/types'; +import type { DataTableSearchMode } from '#console/output/data-table/search'; +import type { VisibleDataTableRow } from '#console/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/pkgs-ts/console/src/output/data-table/reader/session/render.ts b/pkgs-ts/console/src/output/data-table/reader/session/render.ts new file mode 100644 index 00000000..463c12cd --- /dev/null +++ b/pkgs-ts/console/src/output/data-table/reader/session/render.ts @@ -0,0 +1,25 @@ +import { renderDataTableFrame } from '#console/output/data-table/render'; +import { eraseRenderedFrame } from '#console/status/frame'; +import type { DataTableReaderState } from '#console/output/data-table/reader/state'; +import type { DataTableReadOptions } from '#console/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/pkgs-ts/console/src/output/data-table/reader/state.ts b/pkgs-ts/console/src/output/data-table/reader/state.ts new file mode 100644 index 00000000..6c2f017f --- /dev/null +++ b/pkgs-ts/console/src/output/data-table/reader/state.ts @@ -0,0 +1,70 @@ +import { createDataTableReaderSelectionState } from '#console/output/data-table/reader/state/selection'; +import { applyDataTableReaderSearchKey, beginDataTableReaderSearch } from '#console/output/data-table/reader/state/search'; +import { visibleDataTableRows } from '#console/output/data-table/rows'; +import { initialDataTableSearchState } from '#console/output/data-table/search'; +import type { DataTableNavigationAction } from '#console/output/data-table/keys'; +import type { DataTableSearchState } from '#console/output/data-table/search'; +import type { VisibleDataTableRow } from '#console/output/data-table/types'; +import type { DataTableReadOptions } from '#console/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/pkgs-ts/console/src/output/data-table/reader/state/search.ts b/pkgs-ts/console/src/output/data-table/reader/state/search.ts new file mode 100644 index 00000000..78bd1d28 --- /dev/null +++ b/pkgs-ts/console/src/output/data-table/reader/state/search.ts @@ -0,0 +1,12 @@ +import { applyDataTableSearchKey, startDataTableSearch } from '#console/output/data-table/search'; +import type { DataTableSearchChange, DataTableSearchState } from '#console/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/pkgs-ts/console/src/output/data-table/reader/state/selection.ts b/pkgs-ts/console/src/output/data-table/reader/state/selection.ts new file mode 100644 index 00000000..b9bd3808 --- /dev/null +++ b/pkgs-ts/console/src/output/data-table/reader/state/selection.ts @@ -0,0 +1,31 @@ +import { moveDataTableSelection } from '#console/output/data-table/navigation'; +import { initialDataTableSelection } from '#console/output/data-table/reader/result'; +import type { DataTableNavigationAction } from '#console/output/data-table/keys'; +import type { DataTableReadOptions } from '#console/output/data-table/reader/types'; +import type { VisibleDataTableRow } from '#console/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/pkgs-ts/console/src/output/data-table/reader/types.ts b/pkgs-ts/console/src/output/data-table/reader/types.ts new file mode 100644 index 00000000..a1d534db --- /dev/null +++ b/pkgs-ts/console/src/output/data-table/reader/types.ts @@ -0,0 +1,5 @@ +import type { DataTablePromptOptions } from '#console/types'; + +export type DataTableReadOptions = DataTablePromptOptions & { + hasDefault?: boolean; +}; diff --git a/pkgs-ts/console/src/output/data-table/render-body.ts b/pkgs-ts/console/src/output/data-table/render-body.ts new file mode 100644 index 00000000..7f7e68fb --- /dev/null +++ b/pkgs-ts/console/src/output/data-table/render-body.ts @@ -0,0 +1,5 @@ +export { renderDataTableBody } from '#console/output/data-table/body/active'; +export { renderCancelledDataTableRows } from '#console/output/data-table/body/cancelled'; +export { renderDataTableHeaders } from '#console/output/data-table/body/headers'; +export { renderSubmittedDataTableRow } from '#console/output/data-table/body/submitted'; +export type { DataTableBodyOptions } from '#console/output/data-table/body/types'; diff --git a/pkgs-ts/console/src/output/data-table/render.ts b/pkgs-ts/console/src/output/data-table/render.ts new file mode 100644 index 00000000..44a63a7d --- /dev/null +++ b/pkgs-ts/console/src/output/data-table/render.ts @@ -0,0 +1,61 @@ +import { promptEnvironment } from '#console/environment'; +import { renderTable } from '#console/theme'; +import { renderCancelledDataTableRows, renderDataTableBody, renderDataTableHeaders, renderSubmittedDataTableRow } from '#console/output/data-table/render-body'; +import { clampDataTableSelection } from '#console/output/data-table/selection'; +import { dim, red } from '#console/theme/styles'; +import type { DataTableRow } from '#console/types'; +import type { DataTableSearchMode } from '#console/output/data-table/search'; +import type { VisibleDataTableRow } from '#console/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/pkgs-ts/console/src/output/data-table/rows.ts b/pkgs-ts/console/src/output/data-table/rows.ts new file mode 100644 index 00000000..b0383cb6 --- /dev/null +++ b/pkgs-ts/console/src/output/data-table/rows.ts @@ -0,0 +1,4 @@ +export { stringifyDataTableCell } from '#console/output/data-table/rows/cells'; +export { dataTableRowCells, dataTableRowFields, dataTableRowLabel, dataTableRowValue } from '#console/output/data-table/rows/fields'; +export { deriveDataTableHeaders } from '#console/output/data-table/rows/headers'; +export { visibleDataTableRows } from '#console/output/data-table/rows/visible'; diff --git a/pkgs-ts/console/src/output/data-table/rows/cells.ts b/pkgs-ts/console/src/output/data-table/rows/cells.ts new file mode 100644 index 00000000..66b81327 --- /dev/null +++ b/pkgs-ts/console/src/output/data-table/rows/cells.ts @@ -0,0 +1,5 @@ +import type { TableCell } from '#console/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/pkgs-ts/console/src/output/data-table/rows/fields.ts similarity index 85% rename from packages/tui/src/output/data-table/rows/fields.ts rename to pkgs-ts/console/src/output/data-table/rows/fields.ts index f6c9bd9f..b02879c6 100644 --- a/packages/tui/src/output/data-table/rows/fields.ts +++ b/pkgs-ts/console/src/output/data-table/rows/fields.ts @@ -1,6 +1,6 @@ -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'; +import { parseDataTableArrayRowFields, parseDataTableRowShape, parseDataTableRowValue } from '#console/output/validators/data-table'; +import { stringifyDataTableCell } from '#console/output/data-table/rows/cells'; +import type { DataTableRow, TableCell } from '#console/types'; export const dataTableRowFields = (row: DataTableRow): Record => { const shape = parseDataTableRowShape(row); diff --git a/pkgs-ts/console/src/output/data-table/rows/headers.ts b/pkgs-ts/console/src/output/data-table/rows/headers.ts new file mode 100644 index 00000000..f44359a5 --- /dev/null +++ b/pkgs-ts/console/src/output/data-table/rows/headers.ts @@ -0,0 +1,19 @@ +import { parseDataTableArrayHeaders, parseDataTableRecordHeaders, parseDataTableRowShape } from '#console/output/validators/data-table'; +import { dataTableRowFields } from '#console/output/data-table/rows/fields'; +import type { DataTableRow } from '#console/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/pkgs-ts/console/src/output/data-table/rows/visible.ts b/pkgs-ts/console/src/output/data-table/rows/visible.ts new file mode 100644 index 00000000..96d6df5e --- /dev/null +++ b/pkgs-ts/console/src/output/data-table/rows/visible.ts @@ -0,0 +1,15 @@ +import { dataTableRowLabel } from '#console/output/data-table/rows/fields'; +import type { VisibleDataTableRow } from '#console/output/data-table/types'; +import type { DataTablePromptOptions } from '#console/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/pkgs-ts/console/src/output/data-table/run.ts b/pkgs-ts/console/src/output/data-table/run.ts new file mode 100644 index 00000000..3b3e09f7 --- /dev/null +++ b/pkgs-ts/console/src/output/data-table/run.ts @@ -0,0 +1,48 @@ +import { promptUntilValid, promptWithFallback } from '#console/prompt'; +import { promptEnvironment } from '#console/environment'; +import { activePromptFrame } from '#console/prompt/active-frame'; +import { createPromptSubmissionState } from '#console/prompt/submission'; +import { dataTableValidationOptions, preserveDataTableRetryDefault, transformDataTableValue } from '#console/output/data-table/options'; +import { readDataTableSelection } from '#console/output/data-table/read'; +import { renderSubmittedDataTableFrame } from '#console/output/data-table/render'; +import { deriveDataTableHeaders } from '#console/output/data-table/rows'; +import type { DataTableSelectionReadResult } from '#console/output/data-table/types'; +import type { NormalizedDataTablePromptOptions } from '#console/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/pkgs-ts/console/src/output/data-table/scrollbar.ts b/pkgs-ts/console/src/output/data-table/scrollbar.ts new file mode 100644 index 00000000..996ec68a --- /dev/null +++ b/pkgs-ts/console/src/output/data-table/scrollbar.ts @@ -0,0 +1,11 @@ +import { renderScrollbarRows } from '#console/concerns/scrollbar'; +import { renderTable } from '#console/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/pkgs-ts/console/src/output/data-table/search.ts b/pkgs-ts/console/src/output/data-table/search.ts new file mode 100644 index 00000000..d4167d84 --- /dev/null +++ b/pkgs-ts/console/src/output/data-table/search.ts @@ -0,0 +1,65 @@ +import { Key } from '#console/key'; +import { applyTypedKey } from '#console/typed-value'; +import type { TypedValueState } from '#console/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/pkgs-ts/console/src/output/data-table/selection.ts b/pkgs-ts/console/src/output/data-table/selection.ts new file mode 100644 index 00000000..0e1d9913 --- /dev/null +++ b/pkgs-ts/console/src/output/data-table/selection.ts @@ -0,0 +1,22 @@ +import { parseOptionalScrollSize } from '#console/concerns/validators/scroll'; +import { parseDataTableRowTotal, parseDataTableSelectedIndex } from '#console/output/data-table/validators/selection'; +import type { VisibleDataTableRow } from '#console/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/pkgs-ts/console/src/output/data-table/types.ts b/pkgs-ts/console/src/output/data-table/types.ts new file mode 100644 index 00000000..4d3f81b0 --- /dev/null +++ b/pkgs-ts/console/src/output/data-table/types.ts @@ -0,0 +1,15 @@ +import type { DataTableRow } from '#console/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/pkgs-ts/console/src/output/data-table/validators/selection.ts similarity index 100% rename from packages/tui/src/output/data-table/validators/selection.ts rename to pkgs-ts/console/src/output/data-table/validators/selection.ts diff --git a/packages/tui/src/output/data-table/visual-window.ts b/pkgs-ts/console/src/output/data-table/visual-window.ts similarity index 85% rename from packages/tui/src/output/data-table/visual-window.ts rename to pkgs-ts/console/src/output/data-table/visual-window.ts index 3787a519..af1984d3 100644 --- a/packages/tui/src/output/data-table/visual-window.ts +++ b/pkgs-ts/console/src/output/data-table/visual-window.ts @@ -1,4 +1,4 @@ -import { parseOptionalScrollSize } from '#tui/concerns/validators/scroll'; +import { parseOptionalScrollSize } from '#console/concerns/validators/scroll'; export const fixedVisualDataTableRows = (rows: string[][], scroll?: number): string[][] => { const height = parseOptionalScrollSize(scroll); diff --git a/pkgs-ts/console/src/output/data-table/widths.ts b/pkgs-ts/console/src/output/data-table/widths.ts new file mode 100644 index 00000000..49fb2901 --- /dev/null +++ b/pkgs-ts/console/src/output/data-table/widths.ts @@ -0,0 +1,17 @@ +import { terminalSize } from '#console/terminal'; +import { truncateDataTableColumns } from '#console/output/data-table/widths/truncate'; +import { dataTableColumnWidths } from '#console/output/data-table/widths/columns'; +import { parseDataTableWidth } from '#console/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/pkgs-ts/console/src/output/data-table/widths/available.ts similarity index 89% rename from packages/tui/src/output/data-table/widths/available.ts rename to pkgs-ts/console/src/output/data-table/widths/available.ts index 35443034..04e50230 100644 --- a/packages/tui/src/output/data-table/widths/available.ts +++ b/pkgs-ts/console/src/output/data-table/widths/available.ts @@ -1,4 +1,4 @@ -import { parseDataTableColumnCount, parseDataTableWidth } from '#tui/output/data-table/widths/validators/dimensions'; +import { parseDataTableColumnCount, parseDataTableWidth } from '#console/output/data-table/widths/validators/dimensions'; export const availableDataTableCellWidth = (columnCount: number, maxWidth: number): number => { const columns = parseDataTableColumnCount(columnCount); diff --git a/pkgs-ts/console/src/output/data-table/widths/columns.ts b/pkgs-ts/console/src/output/data-table/widths/columns.ts new file mode 100644 index 00000000..335d0c0b --- /dev/null +++ b/pkgs-ts/console/src/output/data-table/widths/columns.ts @@ -0,0 +1,22 @@ +import { visibleWidth } from '#console/strings'; +import { availableDataTableCellWidth } from '#console/output/data-table/widths/available'; +import { naturalDataTableColumnWidth } from '#console/output/data-table/widths/natural'; +import { shrinkDataTableColumnWidths } from '#console/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/pkgs-ts/console/src/output/data-table/widths/natural.ts similarity index 79% rename from packages/tui/src/output/data-table/widths/natural.ts rename to pkgs-ts/console/src/output/data-table/widths/natural.ts index 667db77c..e254bc1a 100644 --- a/packages/tui/src/output/data-table/widths/natural.ts +++ b/pkgs-ts/console/src/output/data-table/widths/natural.ts @@ -1,5 +1,5 @@ -import { visibleWidth } from '#tui/strings'; -import { splitMultilineDataTableCell } from '#tui/output/data-table/multiline'; +import { visibleWidth } from '#console/strings'; +import { splitMultilineDataTableCell } from '#console/output/data-table/multiline'; export const naturalDataTableColumnWidth = (rows: string[][], column: number): number => { const widths = rows diff --git a/packages/tui/src/output/data-table/widths/shrink.ts b/pkgs-ts/console/src/output/data-table/widths/shrink.ts similarity index 100% rename from packages/tui/src/output/data-table/widths/shrink.ts rename to pkgs-ts/console/src/output/data-table/widths/shrink.ts diff --git a/pkgs-ts/console/src/output/data-table/widths/truncate.ts b/pkgs-ts/console/src/output/data-table/widths/truncate.ts new file mode 100644 index 00000000..015588dd --- /dev/null +++ b/pkgs-ts/console/src/output/data-table/widths/truncate.ts @@ -0,0 +1,18 @@ +import { truncate, visibleWidth } from '#console/strings'; +import { splitMultilineDataTableCell } from '#console/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/pkgs-ts/console/src/output/data-table/widths/validators/dimensions.ts similarity index 100% rename from packages/tui/src/output/data-table/widths/validators/dimensions.ts rename to pkgs-ts/console/src/output/data-table/widths/validators/dimensions.ts diff --git a/pkgs-ts/console/src/output/grid-output.ts b/pkgs-ts/console/src/output/grid-output.ts new file mode 100644 index 00000000..5ef89c67 --- /dev/null +++ b/pkgs-ts/console/src/output/grid-output.ts @@ -0,0 +1,12 @@ +import { promptEnvironment } from '#console/environment'; +import { renderGrid } from '#console/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/pkgs-ts/console/src/output/grid.ts b/pkgs-ts/console/src/output/grid.ts new file mode 100644 index 00000000..3ca33b61 --- /dev/null +++ b/pkgs-ts/console/src/output/grid.ts @@ -0,0 +1,19 @@ +import { createGridLayout } from '#console/output/grid/layout'; +import { boxedGridLines } from '#console/output/grid/lines'; +import { parseGridWidth } from '#console/output/grid/validators/layout'; +import { terminalSize } from '#console/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/pkgs-ts/console/src/output/grid/layout.ts similarity index 92% rename from packages/tui/src/output/grid/layout.ts rename to pkgs-ts/console/src/output/grid/layout.ts index f11d182d..7e5a454a 100644 --- a/packages/tui/src/output/grid/layout.ts +++ b/pkgs-ts/console/src/output/grid/layout.ts @@ -1,5 +1,5 @@ -import { visibleWidth } from '#tui/strings'; -import { parseGridWidth } from '#tui/output/grid/validators/layout'; +import { visibleWidth } from '#console/strings'; +import { parseGridWidth } from '#console/output/grid/validators/layout'; export type GridLayout = { rows: string[][]; diff --git a/pkgs-ts/console/src/output/grid/lines.ts b/pkgs-ts/console/src/output/grid/lines.ts new file mode 100644 index 00000000..2530c405 --- /dev/null +++ b/pkgs-ts/console/src/output/grid/lines.ts @@ -0,0 +1,24 @@ +import { visibleWidth } from '#console/strings'; +import type { GridLayout } from '#console/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/pkgs-ts/console/src/output/grid/validators/layout.ts similarity index 100% rename from packages/tui/src/output/grid/validators/layout.ts rename to pkgs-ts/console/src/output/grid/validators/layout.ts diff --git a/pkgs-ts/console/src/output/notes.ts b/pkgs-ts/console/src/output/notes.ts new file mode 100644 index 00000000..cabc2638 --- /dev/null +++ b/pkgs-ts/console/src/output/notes.ts @@ -0,0 +1,21 @@ +import { promptEnvironment } from '#console/environment'; +import { renderNoteLines } from '#console/output/notes/render'; +import type { NoteType } from '#console/output/notes/types'; + +export type { NoteType } from '#console/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/pkgs-ts/console/src/output/notes/render.ts b/pkgs-ts/console/src/output/notes/render.ts new file mode 100644 index 00000000..1550b190 --- /dev/null +++ b/pkgs-ts/console/src/output/notes/render.ts @@ -0,0 +1,49 @@ +import { visibleWidth } from '#console/strings'; +import { parseNoteMessageLines } from '#console/output/notes/validators/message'; +import { backgroundCyan, backgroundRed, black, green, red, white, yellow } from '#console/theme/styles'; +import type { NoteType } from '#console/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/pkgs-ts/console/src/output/notes/types.ts similarity index 100% rename from packages/tui/src/output/notes/types.ts rename to pkgs-ts/console/src/output/notes/types.ts diff --git a/packages/tui/src/output/notes/validators/message.ts b/pkgs-ts/console/src/output/notes/validators/message.ts similarity index 100% rename from packages/tui/src/output/notes/validators/message.ts rename to pkgs-ts/console/src/output/notes/validators/message.ts diff --git a/packages/tui/src/output/notify.ts b/pkgs-ts/console/src/output/notify.ts similarity index 76% rename from packages/tui/src/output/notify.ts rename to pkgs-ts/console/src/output/notify.ts index 7c327b9d..38170856 100644 --- a/packages/tui/src/output/notify.ts +++ b/pkgs-ts/console/src/output/notify.ts @@ -1,15 +1,15 @@ 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'; +import { note } from '#console/output/notes'; +import { notificationCommands } from '#console/output/notify/commands'; +import { availableNotificationCommands, commandExists, executeNotificationCommand } from '#console/output/notify/executor'; +import { parseNotificationPlatform } from '#console/output/notify/validators/platform'; +import type { NotificationCommand, NotificationPlatform } from '#console/output/notify/commands'; +import type { NotificationRuntime } from '#console/output/notify/executor'; export type { NotificationRuntime }; export type { NotificationCommand }; export type { NotificationPlatform }; -export type { NotificationOptions } from '#tui/output/notify/validators/options'; +export type { NotificationOptions } from '#console/output/notify/validators/options'; export { commandExists, executeNotificationCommand, notificationCommands }; export const notificationCommand = (targetPlatform: NotificationPlatform, title: string, body = '', subtitle = '', sound = '', icon = ''): NotificationCommand | null => { diff --git a/packages/tui/src/output/notify/commands.ts b/pkgs-ts/console/src/output/notify/commands.ts similarity index 80% rename from packages/tui/src/output/notify/commands.ts rename to pkgs-ts/console/src/output/notify/commands.ts index fc75860c..3eb11acc 100644 --- a/packages/tui/src/output/notify/commands.ts +++ b/pkgs-ts/console/src/output/notify/commands.ts @@ -1,13 +1,13 @@ -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'; +import { parseNotificationOptions } from '#console/output/notify/validators/options'; +import type { NotificationPlatform } from '#console/output/notify/validators/platform'; +import type { ResolvedNotificationOptions } from '#console/output/notify/validators/options'; export type NotificationCommand = { args: string[]; bin: string; }; -export type { NotificationPlatform } from '#tui/output/notify/validators/platform'; +export type { NotificationPlatform } from '#console/output/notify/validators/platform'; const macOsNotificationCommand = (options: ResolvedNotificationOptions): NotificationCommand => { const script = [ diff --git a/packages/tui/src/output/notify/executor.ts b/pkgs-ts/console/src/output/notify/executor.ts similarity index 92% rename from packages/tui/src/output/notify/executor.ts rename to pkgs-ts/console/src/output/notify/executor.ts index f504f20e..df75b7b8 100644 --- a/packages/tui/src/output/notify/executor.ts +++ b/pkgs-ts/console/src/output/notify/executor.ts @@ -1,6 +1,6 @@ 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'; +import { parseNotificationCommand, parseNotificationCommandBin, parseNotificationCommands } from '#console/output/notify/validators/command'; +import type { NotificationCommand, NotificationPlatform } from '#console/output/notify/commands'; export type NotificationRuntime = { commandExists?: (bin: string) => boolean; diff --git a/packages/tui/src/output/notify/validators/command.ts b/pkgs-ts/console/src/output/notify/validators/command.ts similarity index 92% rename from packages/tui/src/output/notify/validators/command.ts rename to pkgs-ts/console/src/output/notify/validators/command.ts index 36133c62..82a0a8e0 100644 --- a/packages/tui/src/output/notify/validators/command.ts +++ b/pkgs-ts/console/src/output/notify/validators/command.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import type { NotificationCommand } from '#tui/output/notify/commands'; +import type { NotificationCommand } from '#console/output/notify/commands'; const notificationCommandBinSchema = z .string() diff --git a/packages/tui/src/output/notify/validators/options.ts b/pkgs-ts/console/src/output/notify/validators/options.ts similarity index 100% rename from packages/tui/src/output/notify/validators/options.ts rename to pkgs-ts/console/src/output/notify/validators/options.ts diff --git a/packages/tui/src/output/notify/validators/platform.ts b/pkgs-ts/console/src/output/notify/validators/platform.ts similarity index 100% rename from packages/tui/src/output/notify/validators/platform.ts rename to pkgs-ts/console/src/output/notify/validators/platform.ts diff --git a/pkgs-ts/console/src/output/table-output.ts b/pkgs-ts/console/src/output/table-output.ts new file mode 100644 index 00000000..8f7086fb --- /dev/null +++ b/pkgs-ts/console/src/output/table-output.ts @@ -0,0 +1,13 @@ +import { promptEnvironment } from '#console/environment'; +import { parseTableOptions, tableRowCells } from '#console/output/validators/table'; +import { renderTable } from '#console/theme'; +import type { TableOptions } from '#console/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/pkgs-ts/console/src/output/table.ts b/pkgs-ts/console/src/output/table.ts new file mode 100644 index 00000000..a16543fb --- /dev/null +++ b/pkgs-ts/console/src/output/table.ts @@ -0,0 +1,2 @@ +export { grid } from '#console/output/grid-output'; +export { dataTable, table } from '#console/output/table-output'; diff --git a/pkgs-ts/console/src/output/terminal.ts b/pkgs-ts/console/src/output/terminal.ts new file mode 100644 index 00000000..e7a2d40b --- /dev/null +++ b/pkgs-ts/console/src/output/terminal.ts @@ -0,0 +1,9 @@ +import { clearTerminal, setTerminalTitle } from '#console/terminal'; + +export const title = (value: string): void => { + setTerminalTitle(value); +}; + +export const clear = (): void => { + clearTerminal(); +}; diff --git a/pkgs-ts/console/src/output/validators/cells.ts b/pkgs-ts/console/src/output/validators/cells.ts new file mode 100644 index 00000000..bd38ffdb --- /dev/null +++ b/pkgs-ts/console/src/output/validators/cells.ts @@ -0,0 +1,8 @@ +import { z } from 'zod'; +import type { TableCell } from '#console/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/pkgs-ts/console/src/output/validators/data-table.ts b/pkgs-ts/console/src/output/validators/data-table.ts new file mode 100644 index 00000000..41680a7a --- /dev/null +++ b/pkgs-ts/console/src/output/validators/data-table.ts @@ -0,0 +1,10 @@ +export { dataTableStepName, isDataTablePromptOptions, parseDataTableDefault, parseDataTablePromptOptions, parseInitialDataTableRow } from '#console/output/validators/data-table/options'; +export { + isDataObjectRow, + parseDataTableArrayHeaders, + parseDataTableArrayRowFields, + parseDataTableRecordHeaders, + parseDataTableRowShape, + parseDataTableRowValue, +} from '#console/output/validators/data-table/row-shape'; +export type { DataTableRowShape } from '#console/output/validators/data-table/row-shape'; diff --git a/pkgs-ts/console/src/output/validators/data-table/options.ts b/pkgs-ts/console/src/output/validators/data-table/options.ts new file mode 100644 index 00000000..28101af1 --- /dev/null +++ b/pkgs-ts/console/src/output/validators/data-table/options.ts @@ -0,0 +1,83 @@ +import { z } from 'zod'; +import { dataTableHeadersSchema, dataTablePromptOptionsSchema, dataTableRowsSchema, dataTableStepNameSchema } from '#console/output/validators/data-table/schemas'; +import type { DataTablePromptOptions, DataTableRow } from '#console/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/pkgs-ts/console/src/output/validators/data-table/row-shape.ts similarity index 95% rename from packages/tui/src/output/validators/data-table/row-shape.ts rename to pkgs-ts/console/src/output/validators/data-table/row-shape.ts index e249e91c..d9ba128f 100644 --- a/packages/tui/src/output/validators/data-table/row-shape.ts +++ b/pkgs-ts/console/src/output/validators/data-table/row-shape.ts @@ -1,6 +1,6 @@ import { z } from 'zod'; -import { dataObjectRowSchema, dataTableArrayRowSchema, dataTableRecordRowSchema } from '#tui/output/validators/data-table/schemas'; -import type { DataTableObjectRow, DataTableRow, TableCell } from '#tui/types'; +import { dataObjectRowSchema, dataTableArrayRowSchema, dataTableRecordRowSchema } from '#console/output/validators/data-table/schemas'; +import type { DataTableObjectRow, DataTableRow, TableCell } from '#console/types'; const typedDataObjectRowSchema = (): z.ZodType> => dataObjectRowSchema as z.ZodType>; const dataTableRowValueSchema = (): z.ZodType => z.unknown() as z.ZodType; diff --git a/pkgs-ts/console/src/output/validators/data-table/schemas.ts b/pkgs-ts/console/src/output/validators/data-table/schemas.ts new file mode 100644 index 00000000..cf80052d --- /dev/null +++ b/pkgs-ts/console/src/output/validators/data-table/schemas.ts @@ -0,0 +1,31 @@ +import { z } from 'zod'; +import { tableCellArraySchema, tableCellRecordSchema } from '#console/output/validators/cells'; +import type { DataTableObjectRow } from '#console/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/pkgs-ts/console/src/output/validators/table.ts b/pkgs-ts/console/src/output/validators/table.ts new file mode 100644 index 00000000..a6f60786 --- /dev/null +++ b/pkgs-ts/console/src/output/validators/table.ts @@ -0,0 +1,58 @@ +import { tableHeadersSchema, tableOptionsSchema, tableRowsSchema, tableStepNameSchema } from '#console/output/validators/table/schemas'; +import { inferredTableHeaders } from '#console/output/validators/table/rows'; +import type { TableOptions } from '#console/types'; + +export { tableRowCells } from '#console/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/pkgs-ts/console/src/output/validators/table/rows.ts b/pkgs-ts/console/src/output/validators/table/rows.ts new file mode 100644 index 00000000..7b9b78c9 --- /dev/null +++ b/pkgs-ts/console/src/output/validators/table/rows.ts @@ -0,0 +1,34 @@ +import { tableCellArraySchema, tableCellRecordSchema } from '#console/output/validators/cells'; +import type { TableCell, TableOptions } from '#console/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/pkgs-ts/console/src/output/validators/table/schemas.ts b/pkgs-ts/console/src/output/validators/table/schemas.ts new file mode 100644 index 00000000..b72580ab --- /dev/null +++ b/pkgs-ts/console/src/output/validators/table/schemas.ts @@ -0,0 +1,18 @@ +import { z } from 'zod'; +import { tableCellArraySchema, tableCellRecordSchema } from '#console/output/validators/cells'; +import type { TableOptions } from '#console/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/pkgs-ts/console/src/prompt.ts b/pkgs-ts/console/src/prompt.ts new file mode 100644 index 00000000..af782341 --- /dev/null +++ b/pkgs-ts/console/src/prompt.ts @@ -0,0 +1,10 @@ +export { ask } from '#console/prompt/ask'; +export { cancelPrompt, cancelUsing } from '#console/prompt/cancel'; +export type { PromptCancelHandler } from '#console/prompt/cancel'; +export { PromptValidationError } from '#console/prompt/error'; +export { fallbackUsing, fallbackWhen, promptWithFallback, shouldFallback } from '#console/prompt/fallback'; +export type { PromptFallbackCondition, PromptFallbackHandler, PromptFallbackKind } from '#console/prompt/fallback'; +export { promptUntilValid } from '#console/prompt/lifecycle'; +export { validateUsing } from '#console/prompt/validate-using'; +export type { PromptGlobalValidator } from '#console/prompt/validate-using'; +export { ensureRequired, validationMessage } from '#console/prompt/validation'; diff --git a/packages/tui/src/prompt/active-frame.ts b/pkgs-ts/console/src/prompt/active-frame.ts similarity index 87% rename from packages/tui/src/prompt/active-frame.ts rename to pkgs-ts/console/src/prompt/active-frame.ts index d628b33a..8104476d 100644 --- a/packages/tui/src/prompt/active-frame.ts +++ b/pkgs-ts/console/src/prompt/active-frame.ts @@ -1,4 +1,4 @@ -import { eraseRenderedFrame } from '#tui/status/frame'; +import { eraseRenderedFrame } from '#console/status/frame'; export type ActivePromptFrame = { clear: () => void; diff --git a/pkgs-ts/console/src/prompt/ask.ts b/pkgs-ts/console/src/prompt/ask.ts new file mode 100644 index 00000000..75fe1e15 --- /dev/null +++ b/pkgs-ts/console/src/prompt/ask.ts @@ -0,0 +1,13 @@ +import { promptEnvironment } from '#console/environment'; +import { PromptValidationError } from '#console/prompt/error'; +import { renderQuestion } from '#console/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/pkgs-ts/console/src/prompt/cancel.ts b/pkgs-ts/console/src/prompt/cancel.ts new file mode 100644 index 00000000..110bcc9f --- /dev/null +++ b/pkgs-ts/console/src/prompt/cancel.ts @@ -0,0 +1,32 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; +import { parseCancelValue } from '#console/prompt/validators/cancel'; +import type { MaybePromise } from '#console/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/pkgs-ts/console/src/prompt/error.ts similarity index 100% rename from packages/tui/src/prompt/error.ts rename to pkgs-ts/console/src/prompt/error.ts diff --git a/pkgs-ts/console/src/prompt/fallback.ts b/pkgs-ts/console/src/prompt/fallback.ts new file mode 100644 index 00000000..541ce420 --- /dev/null +++ b/pkgs-ts/console/src/prompt/fallback.ts @@ -0,0 +1,71 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; +import { parseFallbackHandler, resolveFallbackCondition } from '#console/prompt/validators/fallback'; +import type { MaybePromise } from '#console/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/pkgs-ts/console/src/prompt/lifecycle.ts b/pkgs-ts/console/src/prompt/lifecycle.ts new file mode 100644 index 00000000..946ad0c8 --- /dev/null +++ b/pkgs-ts/console/src/prompt/lifecycle.ts @@ -0,0 +1,15 @@ +import { promptEnvironment } from '#console/environment'; +import { resolveInteractivePrompt } from '#console/prompt/lifecycle/interactive'; +import { resolveNonInteractivePrompt } from '#console/prompt/lifecycle/non-interactive'; +import type { PromptInvalidHandler, PromptReader, PromptValidHandler } from '#console/prompt/lifecycle/types'; +import type { BasePromptOptions } from '#console/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/pkgs-ts/console/src/prompt/lifecycle/interactive.ts b/pkgs-ts/console/src/prompt/lifecycle/interactive.ts new file mode 100644 index 00000000..f1c2f904 --- /dev/null +++ b/pkgs-ts/console/src/prompt/lifecycle/interactive.ts @@ -0,0 +1,47 @@ +import { promptEnvironment } from '#console/environment'; +import { validatedPromptValue } from '#console/prompt/lifecycle/validation'; +import { isPromptValidationError } from '#console/prompt/validators/error'; +import { parseInvalidPromptValue } from '#console/prompt/validators/invalid'; +import { renderError } from '#console/theme'; +import type { PromptInvalidHandler, PromptReader, PromptValidHandler } from '#console/prompt/lifecycle/types'; +import type { BasePromptOptions } from '#console/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/pkgs-ts/console/src/prompt/lifecycle/non-interactive.ts b/pkgs-ts/console/src/prompt/lifecycle/non-interactive.ts new file mode 100644 index 00000000..6eb279ed --- /dev/null +++ b/pkgs-ts/console/src/prompt/lifecycle/non-interactive.ts @@ -0,0 +1,16 @@ +import { PromptValidationError } from '#console/prompt/error'; +import { validatedPromptValue } from '#console/prompt/lifecycle/validation'; +import { parsePromptDefault } from '#console/validators/default'; +import type { BasePromptOptions } from '#console/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/pkgs-ts/console/src/prompt/lifecycle/types.ts similarity index 100% rename from packages/tui/src/prompt/lifecycle/types.ts rename to pkgs-ts/console/src/prompt/lifecycle/types.ts diff --git a/pkgs-ts/console/src/prompt/lifecycle/validation.ts b/pkgs-ts/console/src/prompt/lifecycle/validation.ts new file mode 100644 index 00000000..bd5270b5 --- /dev/null +++ b/pkgs-ts/console/src/prompt/lifecycle/validation.ts @@ -0,0 +1,6 @@ +import { ensureRequired, validationMessage } from '#console/prompt/validation'; +import type { BasePromptOptions } from '#console/types'; + +export const validatedPromptValue = async (options: BasePromptOptions, value: T): Promise => { + return ensureRequired(value, options.required) ?? (await validationMessage(value, options.validate, options)); +}; diff --git a/pkgs-ts/console/src/prompt/revert.ts b/pkgs-ts/console/src/prompt/revert.ts new file mode 100644 index 00000000..8cc67692 --- /dev/null +++ b/pkgs-ts/console/src/prompt/revert.ts @@ -0,0 +1,6 @@ +import { promptEnvironment } from '#console/environment'; +import { renderError } from '#console/theme'; + +export const rejectPromptRevert = (): void => { + promptEnvironment().error.write(renderError('This cannot be reverted.')); +}; diff --git a/packages/tui/src/prompt/submission.ts b/pkgs-ts/console/src/prompt/submission.ts similarity index 100% rename from packages/tui/src/prompt/submission.ts rename to pkgs-ts/console/src/prompt/submission.ts diff --git a/packages/tui/src/prompt/validate-using.ts b/pkgs-ts/console/src/prompt/validate-using.ts similarity index 91% rename from packages/tui/src/prompt/validate-using.ts rename to pkgs-ts/console/src/prompt/validate-using.ts index 1bcf7e97..63a42bcb 100644 --- a/packages/tui/src/prompt/validate-using.ts +++ b/pkgs-ts/console/src/prompt/validate-using.ts @@ -1,6 +1,6 @@ import { AsyncLocalStorage } from 'node:async_hooks'; -import { parseGlobalValidationOptions } from '#tui/prompt/validators/global-validation'; -import type { BasePromptOptions, MaybePromise, ValidationResult } from '#tui/types'; +import { parseGlobalValidationOptions } from '#console/prompt/validators/global-validation'; +import type { BasePromptOptions, MaybePromise, ValidationResult } from '#console/types'; export type PromptGlobalValidator = (value: T, options: BasePromptOptions) => MaybePromise; diff --git a/pkgs-ts/console/src/prompt/validation.ts b/pkgs-ts/console/src/prompt/validation.ts new file mode 100644 index 00000000..ccc4a380 --- /dev/null +++ b/pkgs-ts/console/src/prompt/validation.ts @@ -0,0 +1,26 @@ +import { requiredMessage } from '#console/validators/required'; +import { parseValidationResult } from '#console/validators/result'; +import { globalValidationMessage } from '#console/prompt/validate-using'; +import type { BasePromptOptions, Validator } from '#console/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/pkgs-ts/console/src/prompt/validators/cancel.ts similarity index 100% rename from packages/tui/src/prompt/validators/cancel.ts rename to pkgs-ts/console/src/prompt/validators/cancel.ts diff --git a/pkgs-ts/console/src/prompt/validators/error.ts b/pkgs-ts/console/src/prompt/validators/error.ts new file mode 100644 index 00000000..282d19a7 --- /dev/null +++ b/pkgs-ts/console/src/prompt/validators/error.ts @@ -0,0 +1,8 @@ +import { z } from 'zod'; +import { PromptValidationError } from '#console/prompt/error'; + +const promptValidationErrorSchema = z.instanceof(PromptValidationError); + +export const isPromptValidationError = (error: unknown): error is PromptValidationError => { + return promptValidationErrorSchema.safeParse(error).success; +}; diff --git a/pkgs-ts/console/src/prompt/validators/fallback.ts b/pkgs-ts/console/src/prompt/validators/fallback.ts new file mode 100644 index 00000000..e69a12e2 --- /dev/null +++ b/pkgs-ts/console/src/prompt/validators/fallback.ts @@ -0,0 +1,28 @@ +import { functionSchema } from '#console/validators/function'; +import type { z } from 'zod'; +import type { PromptFallbackCondition } from '#console/prompt/fallback'; +import type { PromptFallbackHandler } from '#console/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/pkgs-ts/console/src/prompt/validators/global-validation.ts similarity index 88% rename from packages/tui/src/prompt/validators/global-validation.ts rename to pkgs-ts/console/src/prompt/validators/global-validation.ts index c4b8767c..688ee3ac 100644 --- a/packages/tui/src/prompt/validators/global-validation.ts +++ b/pkgs-ts/console/src/prompt/validators/global-validation.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import type { BasePromptOptions } from '#tui/types'; +import type { BasePromptOptions } from '#console/types'; const globalValidationOptionsSchema = z.unknown() as z.ZodType>; diff --git a/packages/tui/src/prompt/validators/invalid.ts b/pkgs-ts/console/src/prompt/validators/invalid.ts similarity index 100% rename from packages/tui/src/prompt/validators/invalid.ts rename to pkgs-ts/console/src/prompt/validators/invalid.ts diff --git a/pkgs-ts/console/src/prompts.ts b/pkgs-ts/console/src/prompts.ts new file mode 100644 index 00000000..b3352d71 --- /dev/null +++ b/pkgs-ts/console/src/prompts.ts @@ -0,0 +1,3 @@ +export { number, password, text, textarea } from '#console/prompts/basic'; +export { autocomplete, confirm, multisearch, multiselect, pause, search, select, suggest } from '#console/prompts/choices'; +export type { SuggestOptions } from '#console/prompts/choices'; diff --git a/pkgs-ts/console/src/prompts/basic.ts b/pkgs-ts/console/src/prompts/basic.ts new file mode 100644 index 00000000..22380979 --- /dev/null +++ b/pkgs-ts/console/src/prompts/basic.ts @@ -0,0 +1,4 @@ +export { text } from '#console/prompts/text'; +export { textarea } from '#console/prompts/textarea'; +export { password } from '#console/prompts/password'; +export { number } from '#console/prompts/number'; diff --git a/pkgs-ts/console/src/prompts/choices.ts b/pkgs-ts/console/src/prompts/choices.ts new file mode 100644 index 00000000..5119553d --- /dev/null +++ b/pkgs-ts/console/src/prompts/choices.ts @@ -0,0 +1,5 @@ +export { confirm, multiselect, select } from '#console/prompts/select/index'; +export { autocomplete, suggest } from '#console/prompts/suggest/index'; +export { multisearch, search } from '#console/prompts/search/index'; +export { pause } from '#console/prompts/pause'; +export type { SuggestOptions } from '#console/prompts/suggest/index'; diff --git a/pkgs-ts/console/src/prompts/number.ts b/pkgs-ts/console/src/prompts/number.ts new file mode 100644 index 00000000..06aca5ea --- /dev/null +++ b/pkgs-ts/console/src/prompts/number.ts @@ -0,0 +1,47 @@ +import { normalizeNumberPromptOptions } from '#console/prompts/number/options'; +import { runNumberPrompt } from '#console/prompts/number/run'; +import type { NumberPromptOptions } from '#console/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/pkgs-ts/console/src/prompts/number/defaults.ts b/pkgs-ts/console/src/prompts/number/defaults.ts new file mode 100644 index 00000000..0627c1be --- /dev/null +++ b/pkgs-ts/console/src/prompts/number/defaults.ts @@ -0,0 +1,35 @@ +import { PromptValidationError } from '#console/prompt'; +import { parseNumberInput } from '#console/prompts/number/validators/value'; +import type { NumberPromptOptions } from '#console/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/pkgs-ts/console/src/prompts/number/fallback.ts b/pkgs-ts/console/src/prompts/number/fallback.ts new file mode 100644 index 00000000..9b08ac5f --- /dev/null +++ b/pkgs-ts/console/src/prompts/number/fallback.ts @@ -0,0 +1,19 @@ +import { promptEnvironment } from '#console/environment'; +import { PromptValidationError } from '#console/prompt'; +import { renderQuestion } from '#console/theme'; +import type { NumberInputOptions, NumberReadResult } from '#console/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/pkgs-ts/console/src/prompts/number/input.ts b/pkgs-ts/console/src/prompts/number/input.ts new file mode 100644 index 00000000..953717b5 --- /dev/null +++ b/pkgs-ts/console/src/prompts/number/input.ts @@ -0,0 +1,14 @@ +import { promptEnvironment } from '#console/environment'; +import { readNumberFallbackValue } from '#console/prompts/number/fallback'; +import { readInteractiveNumberValue } from '#console/prompts/number/interactive'; +import type { NumberInputOptions, NumberReadResult } from '#console/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/pkgs-ts/console/src/prompts/number/interactive.ts b/pkgs-ts/console/src/prompts/number/interactive.ts new file mode 100644 index 00000000..c33560bf --- /dev/null +++ b/pkgs-ts/console/src/prompts/number/interactive.ts @@ -0,0 +1,51 @@ +import { promptEnvironment } from '#console/environment'; +import { Key } from '#console/key'; +import { cancelPrompt } from '#console/prompt'; +import { createNumberReaderSession } from '#console/prompts/number/session'; +import type { NumberInputOptions, NumberReadResult } from '#console/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/pkgs-ts/console/src/prompts/number/options.ts b/pkgs-ts/console/src/prompts/number/options.ts new file mode 100644 index 00000000..7a5ee9b5 --- /dev/null +++ b/pkgs-ts/console/src/prompts/number/options.ts @@ -0,0 +1,49 @@ +import { hasNumberDefaultArgument, isNumberPromptLabel, parseNumberDefault } from '#console/prompts/number/validators/overload'; +import { hasPromptDefault } from '#console/validators/default'; +import type { NormalizedNumberPromptOptions } from '#console/prompts/number/defaults'; +import type { NumberPromptOptions } from '#console/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/pkgs-ts/console/src/prompts/number/render.ts b/pkgs-ts/console/src/prompts/number/render.ts new file mode 100644 index 00000000..01df69d8 --- /dev/null +++ b/pkgs-ts/console/src/prompts/number/render.ts @@ -0,0 +1,49 @@ +import { promptEnvironment } from '#console/environment'; +import { parseNumericValue } from '#console/prompts/number/validators/value'; +import { renderBox } from '#console/theme/box'; +import { cyan, dim, red, strikethrough } from '#console/theme/styles'; +import { placeholderWithCursor, valueWithCursor } from '#console/typed-value/cursor'; +import type { NumberInputOptions } from '#console/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/pkgs-ts/console/src/prompts/number/run.ts b/pkgs-ts/console/src/prompts/number/run.ts new file mode 100644 index 00000000..e48c6b43 --- /dev/null +++ b/pkgs-ts/console/src/prompts/number/run.ts @@ -0,0 +1,75 @@ +import { promptUntilValid, promptWithFallback, PromptValidationError } from '#console/prompt'; +import { activePromptFrame } from '#console/prompt/active-frame'; +import { createPromptSubmissionState } from '#console/prompt/submission'; +import { numberDefault, preserveNumberRetryDefault, transformNumberValue } from '#console/prompts/number/defaults'; +import { readNumberValue } from '#console/prompts/number/input'; +import { renderSubmittedNumberValue } from '#console/prompts/number/render'; +import { parseNumberInput } from '#console/prompts/number/validators/value'; +import type { NormalizedNumberPromptOptions } from '#console/prompts/number/defaults'; +import type { NumberPromptOptions } from '#console/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/pkgs-ts/console/src/prompts/number/session.ts b/pkgs-ts/console/src/prompts/number/session.ts new file mode 100644 index 00000000..0aa1284d --- /dev/null +++ b/pkgs-ts/console/src/prompts/number/session.ts @@ -0,0 +1,55 @@ +import { eraseRenderedFrame } from '#console/status/frame'; +import { renderCancelledNumberValue, renderNumberValue } from '#console/prompts/number/render'; +import { applyNumberReaderInput, initialNumberReaderState, steppedNumberReaderState } from '#console/prompts/number/session/state'; +import type { NumberInputOptions } from '#console/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/pkgs-ts/console/src/prompts/number/session/state.ts b/pkgs-ts/console/src/prompts/number/session/state.ts new file mode 100644 index 00000000..f60eb23c --- /dev/null +++ b/pkgs-ts/console/src/prompts/number/session/state.ts @@ -0,0 +1,43 @@ +import { applyTypedKey } from '#console/typed-value'; +import { steppedNumberValue } from '#console/prompts/number/step'; +import type { NumberInputOptions } from '#console/prompts/number/types'; +import type { TypedValueState } from '#console/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/pkgs-ts/console/src/prompts/number/step.ts b/pkgs-ts/console/src/prompts/number/step.ts new file mode 100644 index 00000000..83a09565 --- /dev/null +++ b/pkgs-ts/console/src/prompts/number/step.ts @@ -0,0 +1,30 @@ +import { parseNumberStep } from '#console/prompts/number/validators/step'; +import { parseNumericValue } from '#console/prompts/number/validators/value'; +import type { NumberInputOptions } from '#console/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/pkgs-ts/console/src/prompts/number/types.ts similarity index 100% rename from packages/tui/src/prompts/number/types.ts rename to pkgs-ts/console/src/prompts/number/types.ts diff --git a/packages/tui/src/prompts/number/validators/overload.ts b/pkgs-ts/console/src/prompts/number/validators/overload.ts similarity index 100% rename from packages/tui/src/prompts/number/validators/overload.ts rename to pkgs-ts/console/src/prompts/number/validators/overload.ts diff --git a/packages/tui/src/prompts/number/validators/step.ts b/pkgs-ts/console/src/prompts/number/validators/step.ts similarity index 100% rename from packages/tui/src/prompts/number/validators/step.ts rename to pkgs-ts/console/src/prompts/number/validators/step.ts diff --git a/pkgs-ts/console/src/prompts/number/validators/value.ts b/pkgs-ts/console/src/prompts/number/validators/value.ts new file mode 100644 index 00000000..c14e1f5c --- /dev/null +++ b/pkgs-ts/console/src/prompts/number/validators/value.ts @@ -0,0 +1,52 @@ +import { z } from 'zod'; +import type { NumberPromptOptions } from '#console/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/pkgs-ts/console/src/prompts/password.ts b/pkgs-ts/console/src/prompts/password.ts new file mode 100644 index 00000000..8c681c36 --- /dev/null +++ b/pkgs-ts/console/src/prompts/password.ts @@ -0,0 +1,34 @@ +import { normalizePasswordPromptOptions } from '#console/prompts/password/options'; +import { runPasswordPrompt } from '#console/prompts/password/run'; +import type { TextPromptOptions } from '#console/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/pkgs-ts/console/src/prompts/password/fallback.ts b/pkgs-ts/console/src/prompts/password/fallback.ts new file mode 100644 index 00000000..ba875371 --- /dev/null +++ b/pkgs-ts/console/src/prompts/password/fallback.ts @@ -0,0 +1,19 @@ +import { promptEnvironment } from '#console/environment'; +import { PromptValidationError } from '#console/prompt'; +import { renderQuestion } from '#console/theme'; +import type { PasswordInputOptions, PasswordReadResult } from '#console/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/pkgs-ts/console/src/prompts/password/input.ts b/pkgs-ts/console/src/prompts/password/input.ts new file mode 100644 index 00000000..fbbbfc35 --- /dev/null +++ b/pkgs-ts/console/src/prompts/password/input.ts @@ -0,0 +1,46 @@ +import { promptEnvironment } from '#console/environment'; +import { cancelPrompt } from '#console/prompt'; +import { readPasswordFallbackValue } from '#console/prompts/password/fallback'; +import { createPasswordReaderSession } from '#console/prompts/password/session'; +import type { PasswordInputOptions, PasswordReadResult } from '#console/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/pkgs-ts/console/src/prompts/password/options.ts b/pkgs-ts/console/src/prompts/password/options.ts new file mode 100644 index 00000000..3f4fe4e3 --- /dev/null +++ b/pkgs-ts/console/src/prompts/password/options.ts @@ -0,0 +1,28 @@ +import { textOptions } from '#console/concerns/text-options'; +import { isTextPromptLabel } from '#console/concerns/validators/text-options'; +import type { TextPromptOptions } from '#console/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/pkgs-ts/console/src/prompts/password/render.ts b/pkgs-ts/console/src/prompts/password/render.ts new file mode 100644 index 00000000..ba80c3fa --- /dev/null +++ b/pkgs-ts/console/src/prompts/password/render.ts @@ -0,0 +1,33 @@ +import { promptEnvironment } from '#console/environment'; +import { renderBox } from '#console/theme/box'; +import { cyan, dim, red, strikethrough } from '#console/theme/styles'; +import { valueWithCursor } from '#console/typed-value/cursor'; +import type { PasswordInputOptions } from '#console/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/pkgs-ts/console/src/prompts/password/run.ts b/pkgs-ts/console/src/prompts/password/run.ts new file mode 100644 index 00000000..52ed5b65 --- /dev/null +++ b/pkgs-ts/console/src/prompts/password/run.ts @@ -0,0 +1,50 @@ +import { promptUntilValid, promptWithFallback } from '#console/prompt'; +import { activePromptFrame } from '#console/prompt/active-frame'; +import { createPromptSubmissionState } from '#console/prompt/submission'; +import { readPasswordValue } from '#console/prompts/password/input'; +import { renderSubmittedPasswordValue } from '#console/prompts/password/render'; +import { transformedTextDefault } from '#console/prompts/text-default'; +import type { TextPromptOptions } from '#console/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/pkgs-ts/console/src/prompts/password/session.ts b/pkgs-ts/console/src/prompts/password/session.ts new file mode 100644 index 00000000..3cd3530b --- /dev/null +++ b/pkgs-ts/console/src/prompts/password/session.ts @@ -0,0 +1,56 @@ +import { eraseRenderedFrame } from '#console/status/frame'; +import { applyTypedKey } from '#console/typed-value'; +import { passwordLength, renderCancelledPasswordValue, renderPasswordValue } from '#console/prompts/password/render'; +import type { PasswordInputOptions } from '#console/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/pkgs-ts/console/src/prompts/password/types.ts similarity index 100% rename from packages/tui/src/prompts/password/types.ts rename to pkgs-ts/console/src/prompts/password/types.ts diff --git a/pkgs-ts/console/src/prompts/pause.ts b/pkgs-ts/console/src/prompts/pause.ts new file mode 100644 index 00000000..fb718bc6 --- /dev/null +++ b/pkgs-ts/console/src/prompts/pause.ts @@ -0,0 +1,6 @@ +import { promptWithFallback } from '#console/prompt'; +import { readPauseConfirmation } from '#console/prompts/pause/read'; + +export const pause = async (message = 'Press enter to continue...'): Promise => { + return promptWithFallback('pause', { message }, async () => readPauseConfirmation(message)); +}; diff --git a/pkgs-ts/console/src/prompts/pause/read.ts b/pkgs-ts/console/src/prompts/pause/read.ts new file mode 100644 index 00000000..8d5cbcbb --- /dev/null +++ b/pkgs-ts/console/src/prompts/pause/read.ts @@ -0,0 +1,36 @@ +import { promptEnvironment } from '#console/environment'; +import { Key } from '#console/key'; +import { ask, cancelPrompt } from '#console/prompt'; +import { renderPause } from '#console/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/pkgs-ts/console/src/prompts/pause/render.ts b/pkgs-ts/console/src/prompts/pause/render.ts new file mode 100644 index 00000000..a6ec7832 --- /dev/null +++ b/pkgs-ts/console/src/prompts/pause/render.ts @@ -0,0 +1,6 @@ +import { renderBox } from '#console/theme/box'; +import { cyan } from '#console/theme/styles'; + +export const renderPause = (message: string): string => { + return renderBox({ body: '', borderStyle: cyan, title: cyan(message) }); +}; diff --git a/pkgs-ts/console/src/prompts/search/body/render.ts b/pkgs-ts/console/src/prompts/search/body/render.ts new file mode 100644 index 00000000..35d134b2 --- /dev/null +++ b/pkgs-ts/console/src/prompts/search/body/render.ts @@ -0,0 +1,24 @@ +import { renderSearchRows } from '#console/prompts/search/body/rows'; +import { dim } from '#console/theme/styles'; +import { placeholderWithCursor, valueWithCursor } from '#console/typed-value/cursor'; +import type { Choice } from '#console/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/pkgs-ts/console/src/prompts/search/body/rows.ts b/pkgs-ts/console/src/prompts/search/body/rows.ts new file mode 100644 index 00000000..51dbe4ce --- /dev/null +++ b/pkgs-ts/console/src/prompts/search/body/rows.ts @@ -0,0 +1,44 @@ +import { choiceWindow } from '#console/concerns/choices'; +import { choiceLabel } from '#console/concerns/choice-label'; +import { renderScrollbarRows } from '#console/concerns/scrollbar'; +import { cyan, dim } from '#console/theme/styles'; +import type { Choice } from '#console/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/pkgs-ts/console/src/prompts/search/choices.ts b/pkgs-ts/console/src/prompts/search/choices.ts new file mode 100644 index 00000000..b836fcfc --- /dev/null +++ b/pkgs-ts/console/src/prompts/search/choices.ts @@ -0,0 +1,23 @@ +import { normalizeSearchChoices } from '#console/concerns/choices'; +import { resolveSearchChoiceSourceOptions } from '#console/prompts/search/validators/source'; +import type { Choice, SearchPromptOptions } from '#console/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/pkgs-ts/console/src/prompts/search/defaults.ts b/pkgs-ts/console/src/prompts/search/defaults.ts new file mode 100644 index 00000000..812cc074 --- /dev/null +++ b/pkgs-ts/console/src/prompts/search/defaults.ts @@ -0,0 +1,41 @@ +import { parsePromptDefault } from '#console/validators/default'; +import type { MultiSearchPromptOptions, SearchPromptOptions } from '#console/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/pkgs-ts/console/src/prompts/search/index.ts b/pkgs-ts/console/src/prompts/search/index.ts new file mode 100644 index 00000000..ba6bb9c9 --- /dev/null +++ b/pkgs-ts/console/src/prompts/search/index.ts @@ -0,0 +1,2 @@ +export { multisearch } from '#console/prompts/search/multisearch'; +export { search } from '#console/prompts/search/search'; diff --git a/pkgs-ts/console/src/prompts/search/keys.ts b/pkgs-ts/console/src/prompts/search/keys.ts new file mode 100644 index 00000000..e3926ebe --- /dev/null +++ b/pkgs-ts/console/src/prompts/search/keys.ts @@ -0,0 +1,4 @@ +export { clearsSearchHighlight, moveSearchHighlight } from '#console/prompts/search/keys/highlight'; +export type { MoveSearchHighlightOptions } from '#console/prompts/search/keys/highlight'; +export { searchNavigationAction } from '#console/prompts/search/keys/navigation'; +export type { SearchNavigationAction, SearchNavigationOptions } from '#console/prompts/search/keys/navigation'; diff --git a/pkgs-ts/console/src/prompts/search/keys/highlight.ts b/pkgs-ts/console/src/prompts/search/keys/highlight.ts new file mode 100644 index 00000000..a1587738 --- /dev/null +++ b/pkgs-ts/console/src/prompts/search/keys/highlight.ts @@ -0,0 +1,36 @@ +import { Key, oneOf } from '#console/key'; +import { firstSearchHighlight, lastSearchHighlight, nextRetriedSearchHighlight, nextSearchHighlight, pageSearchHighlight } from '#console/prompts/search/navigation'; +import type { SearchNavigationAction } from '#console/prompts/search/keys/navigation'; +import type { Choice } from '#console/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/pkgs-ts/console/src/prompts/search/keys/navigation.ts b/pkgs-ts/console/src/prompts/search/keys/navigation.ts new file mode 100644 index 00000000..026fcb3b --- /dev/null +++ b/pkgs-ts/console/src/prompts/search/keys/navigation.ts @@ -0,0 +1,36 @@ +import { Key, oneOf } from '#console/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/pkgs-ts/console/src/prompts/search/line-mode.ts b/pkgs-ts/console/src/prompts/search/line-mode.ts new file mode 100644 index 00000000..2a7e1433 --- /dev/null +++ b/pkgs-ts/console/src/prompts/search/line-mode.ts @@ -0,0 +1,56 @@ +import { choiceByValue, choiceValueEquals, findChoice } from '#console/concerns/choices'; +import { parseChoiceAnswerList } from '#console/concerns/validators/choice-answer'; +import { PromptValidationError } from '#console/prompt'; +import { resolveSearchChoices } from '#console/prompts/search/choices'; +import type { Choice, MultiSearchPromptOptions, SearchPromptOptions } from '#console/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/pkgs-ts/console/src/prompts/search/multi-options.ts b/pkgs-ts/console/src/prompts/search/multi-options.ts new file mode 100644 index 00000000..eb098e90 --- /dev/null +++ b/pkgs-ts/console/src/prompts/search/multi-options.ts @@ -0,0 +1,29 @@ +import { isSearchPromptLabel } from '#console/prompts/search/validators/overload'; +import { parseMultiSearchDefault } from '#console/prompts/search/validators/defaults'; +import { parseSearchChoiceSource } from '#console/prompts/search/validators/source'; +import type { ChoiceOptions, MultiSearchPromptOptions } from '#console/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/pkgs-ts/console/src/prompts/search/multi-run.ts b/pkgs-ts/console/src/prompts/search/multi-run.ts new file mode 100644 index 00000000..7cc4b890 --- /dev/null +++ b/pkgs-ts/console/src/prompts/search/multi-run.ts @@ -0,0 +1,45 @@ +import { promptUntilValid, promptWithFallback } from '#console/prompt'; +import { activePromptFrame } from '#console/prompt/active-frame'; +import { createPromptSubmissionState } from '#console/prompt/submission'; +import { preserveMultiSearchRetryDefault, transformedMultiSearchDefault } from '#console/prompts/search/defaults'; +import { readMultiSearchChoices } from '#console/prompts/search/read-multi'; +import { renderSubmittedSearchChoices } from '#console/prompts/search/render'; +import type { NormalizedMultiSearchPromptOptions } from '#console/prompts/search/multi-options'; +import type { MultiSearchPromptOptions } from '#console/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/pkgs-ts/console/src/prompts/search/multisearch.ts b/pkgs-ts/console/src/prompts/search/multisearch.ts new file mode 100644 index 00000000..a9134338 --- /dev/null +++ b/pkgs-ts/console/src/prompts/search/multisearch.ts @@ -0,0 +1,31 @@ +import { normalizeMultiSearchPromptOptions } from '#console/prompts/search/multi-options'; +import { runMultiSearchPrompt } from '#console/prompts/search/multi-run'; +import type { ChoiceOptions, MultiSearchPromptOptions } from '#console/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/pkgs-ts/console/src/prompts/search/navigation.ts b/pkgs-ts/console/src/prompts/search/navigation.ts new file mode 100644 index 00000000..f2551e17 --- /dev/null +++ b/pkgs-ts/console/src/prompts/search/navigation.ts @@ -0,0 +1,46 @@ +import { firstEnabledIndex, nextEnabledIndex } from '#console/concerns/choices'; +import { lastEnabledIndex } from '#console/prompts/search/choices'; +import { pageEnabledChoiceIndex } from '#console/prompts/select/navigation'; +import type { Choice } from '#console/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/pkgs-ts/console/src/prompts/search/options.ts b/pkgs-ts/console/src/prompts/search/options.ts new file mode 100644 index 00000000..db463e9f --- /dev/null +++ b/pkgs-ts/console/src/prompts/search/options.ts @@ -0,0 +1,29 @@ +import { assertSearchOptions } from '#console/prompts/search/validators/options'; +import { isSearchPromptLabel } from '#console/prompts/search/validators/overload'; +import { parseSearchChoiceSource } from '#console/prompts/search/validators/source'; +import { hasPromptDefault } from '#console/validators/default'; +import type { NormalizedSearchPromptOptions } from '#console/prompts/search/defaults'; +import type { ChoiceOptions, SearchPromptOptions } from '#console/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/pkgs-ts/console/src/prompts/search/read-multi.ts b/pkgs-ts/console/src/prompts/search/read-multi.ts new file mode 100644 index 00000000..893dc9b5 --- /dev/null +++ b/pkgs-ts/console/src/prompts/search/read-multi.ts @@ -0,0 +1,55 @@ +import { promptEnvironment } from '#console/environment'; +import { Key } from '#console/key'; +import { cancelMultiSearchChoices } from '#console/prompts/search/read-multi/cancel'; +import { applyMultiSearchKey } from '#console/prompts/search/read-multi/keys'; +import { lineMultiSearchValues, selectedSearchValues } from '#console/prompts/search/read-multi/result'; +import type { MultiSearchChoicesReadResult } from '#console/prompts/search/read-multi/result'; +import { createMultiSearchReaderSession } from '#console/prompts/search/read-multi/session'; +import type { MultiSearchReaderSession } from '#console/prompts/search/read-multi/session'; +import type { MultiSearchPromptOptions } from '#console/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/pkgs-ts/console/src/prompts/search/read-multi/cancel.ts b/pkgs-ts/console/src/prompts/search/read-multi/cancel.ts new file mode 100644 index 00000000..ba222d06 --- /dev/null +++ b/pkgs-ts/console/src/prompts/search/read-multi/cancel.ts @@ -0,0 +1,21 @@ +import { cancelPrompt } from '#console/prompt'; +import { eraseRenderedFrame } from '#console/status/frame'; +import { renderCancelledSearch } from '#console/prompts/search/render'; +import { selectedSearchValues } from '#console/prompts/search/read-multi/result'; +import type { MultiSearchChoicesReadResult } from '#console/prompts/search/read-multi/result'; +import type { MultiSearchReaderSession } from '#console/prompts/search/read-multi/session'; +import type { MultiSearchPromptOptions } from '#console/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/pkgs-ts/console/src/prompts/search/read-multi/choice-query.ts b/pkgs-ts/console/src/prompts/search/read-multi/choice-query.ts new file mode 100644 index 00000000..1ce970b8 --- /dev/null +++ b/pkgs-ts/console/src/prompts/search/read-multi/choice-query.ts @@ -0,0 +1,45 @@ +import { applyTypedKey } from '#console/typed-value'; +import { resolveSearchChoices } from '#console/prompts/search/choices'; +import { displayedSearchChoices } from '#console/prompts/search/selection'; +import type { SearchSelection } from '#console/prompts/search/selection'; +import type { TypedValueState } from '#console/typed-value/types'; +import type { Choice, MultiSearchPromptOptions } from '#console/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/pkgs-ts/console/src/prompts/search/read-multi/frame.ts b/pkgs-ts/console/src/prompts/search/read-multi/frame.ts new file mode 100644 index 00000000..8a7d4b87 --- /dev/null +++ b/pkgs-ts/console/src/prompts/search/read-multi/frame.ts @@ -0,0 +1,40 @@ +import { eraseRenderedFrame } from '#console/status/frame'; +import { renderSearchChoices } from '#console/prompts/search/render'; +import type { MultiSearchReaderState } from '#console/prompts/search/read-multi/state'; +import type { MultiSearchPromptOptions } from '#console/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/pkgs-ts/console/src/prompts/search/read-multi/keys.ts b/pkgs-ts/console/src/prompts/search/read-multi/keys.ts new file mode 100644 index 00000000..f76ba4ef --- /dev/null +++ b/pkgs-ts/console/src/prompts/search/read-multi/keys.ts @@ -0,0 +1,31 @@ +import { Key } from '#console/key'; +import { searchNavigationAction } from '#console/prompts/search/keys'; +import type { MultiSearchReaderSession } from '#console/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/pkgs-ts/console/src/prompts/search/read-multi/result.ts b/pkgs-ts/console/src/prompts/search/read-multi/result.ts new file mode 100644 index 00000000..147418fc --- /dev/null +++ b/pkgs-ts/console/src/prompts/search/read-multi/result.ts @@ -0,0 +1,33 @@ +import { ask } from '#console/prompt'; +import { resolveLineMultiSearchChoices } from '#console/prompts/search/line-mode'; +import { toggleSearchChoice } from '#console/prompts/search/selection'; +import type { SearchSelection } from '#console/prompts/search/selection'; +import type { Choice, MultiSearchPromptOptions } from '#console/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/pkgs-ts/console/src/prompts/search/read-multi/session.ts b/pkgs-ts/console/src/prompts/search/read-multi/session.ts new file mode 100644 index 00000000..9a41635f --- /dev/null +++ b/pkgs-ts/console/src/prompts/search/read-multi/session.ts @@ -0,0 +1,63 @@ +import { createMultiSearchFrameRenderer } from '#console/prompts/search/read-multi/frame'; +import { createMultiSearchReaderState } from '#console/prompts/search/read-multi/state'; +import type { SearchNavigationAction } from '#console/prompts/search/keys'; +import type { SearchSelection } from '#console/prompts/search/selection'; +import type { TypedValueState } from '#console/typed-value/types'; +import type { MultiSearchPromptOptions } from '#console/types'; + +import { + applyMultiSearchSessionTypedInput, + moveMultiSearchSessionHighlight, + toggleAllMultiSearchSessionDisplayed, + toggleMultiSearchSessionHighlighted, +} from '#console/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/pkgs-ts/console/src/prompts/search/read-multi/session/actions.ts b/pkgs-ts/console/src/prompts/search/read-multi/session/actions.ts new file mode 100644 index 00000000..ed418f76 --- /dev/null +++ b/pkgs-ts/console/src/prompts/search/read-multi/session/actions.ts @@ -0,0 +1,29 @@ +import type { SearchNavigationAction } from '#console/prompts/search/keys'; +import type { MultiSearchFrameRenderer } from '#console/prompts/search/read-multi/frame'; +import type { MultiSearchReaderState } from '#console/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/pkgs-ts/console/src/prompts/search/read-multi/state.ts b/pkgs-ts/console/src/prompts/search/read-multi/state.ts new file mode 100644 index 00000000..1bb30f89 --- /dev/null +++ b/pkgs-ts/console/src/prompts/search/read-multi/state.ts @@ -0,0 +1,67 @@ +import { createMultiSearchReaderStateContext } from '#console/prompts/search/read-multi/state/context'; +import { applyMultiSearchTypedInput } from '#console/prompts/search/read-multi/state/input'; +import { selectedMultiSearchLabels } from '#console/prompts/search/read-multi/state/labels'; +import type { SearchNavigationAction } from '#console/prompts/search/keys'; +import type { SearchSelection } from '#console/prompts/search/selection'; +import type { TypedValueState } from '#console/typed-value/types'; +import type { Choice, MultiSearchPromptOptions } from '#console/types'; + +import { + displayedMultiSearchChoices, + markedDisplayedMultiSearchChoiceIndexes, + moveDisplayedMultiSearchHighlight, + toggleAllDisplayedMultiSearchSelection, + toggleHighlightedDisplayedMultiSearchSelection, +} from '#console/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/pkgs-ts/console/src/prompts/search/read-multi/state/context.ts b/pkgs-ts/console/src/prompts/search/read-multi/state/context.ts new file mode 100644 index 00000000..70f3366f --- /dev/null +++ b/pkgs-ts/console/src/prompts/search/read-multi/state/context.ts @@ -0,0 +1,26 @@ +import { resolveSearchChoices } from '#console/prompts/search/choices'; +import { createMultiSearchChoiceQuery } from '#console/prompts/search/read-multi/choice-query'; +import { createMultiSearchHighlightState } from '#console/prompts/search/read-multi/state/highlight'; +import { createMultiSearchSelection } from '#console/prompts/search/read-multi/state/selection'; +import type { MultiSearchChoiceQuery } from '#console/prompts/search/read-multi/choice-query'; +import type { MultiSearchHighlightState } from '#console/prompts/search/read-multi/state/highlight'; +import type { SearchSelection } from '#console/prompts/search/selection'; +import type { MultiSearchPromptOptions } from '#console/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/pkgs-ts/console/src/prompts/search/read-multi/state/displayed.ts b/pkgs-ts/console/src/prompts/search/read-multi/state/displayed.ts new file mode 100644 index 00000000..c22d5bc8 --- /dev/null +++ b/pkgs-ts/console/src/prompts/search/read-multi/state/displayed.ts @@ -0,0 +1,29 @@ +import type { SearchNavigationAction } from '#console/prompts/search/keys'; +import type { MultiSearchChoiceQuery } from '#console/prompts/search/read-multi/choice-query'; +import type { MultiSearchHighlightState } from '#console/prompts/search/read-multi/state/highlight'; +import type { SearchSelection } from '#console/prompts/search/selection'; +import type { Choice } from '#console/types'; + +import { markedMultiSearchChoiceIndexes, toggleAllDisplayedMultiSearchChoices, toggleHighlightedMultiSearchChoice } from '#console/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/pkgs-ts/console/src/prompts/search/read-multi/state/highlight.ts b/pkgs-ts/console/src/prompts/search/read-multi/state/highlight.ts new file mode 100644 index 00000000..aef6d94d --- /dev/null +++ b/pkgs-ts/console/src/prompts/search/read-multi/state/highlight.ts @@ -0,0 +1,25 @@ +import { moveSearchHighlight } from '#console/prompts/search/keys'; +import type { SearchNavigationAction } from '#console/prompts/search/keys'; +import type { Choice, MultiSearchPromptOptions } from '#console/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/pkgs-ts/console/src/prompts/search/read-multi/state/input.ts b/pkgs-ts/console/src/prompts/search/read-multi/state/input.ts new file mode 100644 index 00000000..4aea86bf --- /dev/null +++ b/pkgs-ts/console/src/prompts/search/read-multi/state/input.ts @@ -0,0 +1,10 @@ +import type { MultiSearchChoiceQuery } from '#console/prompts/search/read-multi/choice-query'; +import type { MultiSearchHighlightState } from '#console/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/pkgs-ts/console/src/prompts/search/read-multi/state/labels.ts b/pkgs-ts/console/src/prompts/search/read-multi/state/labels.ts new file mode 100644 index 00000000..3845465c --- /dev/null +++ b/pkgs-ts/console/src/prompts/search/read-multi/state/labels.ts @@ -0,0 +1,6 @@ +import { multiSearchSelectedLabels } from '#console/prompts/search/read-multi/state/selection'; +import type { SearchSelection } from '#console/prompts/search/selection'; + +export const selectedMultiSearchLabels = (selected: SearchSelection): string[] => { + return multiSearchSelectedLabels(selected); +}; diff --git a/pkgs-ts/console/src/prompts/search/read-multi/state/selection.ts b/pkgs-ts/console/src/prompts/search/read-multi/state/selection.ts new file mode 100644 index 00000000..62d6d745 --- /dev/null +++ b/pkgs-ts/console/src/prompts/search/read-multi/state/selection.ts @@ -0,0 +1,24 @@ +import { createInitialSearchSelection, markedSearchChoiceIndexes, toggleSearchChoices } from '#console/prompts/search/selection'; +import { toggleHighlightedSearchChoice } from '#console/prompts/search/read-multi/result'; +import type { SearchSelection } from '#console/prompts/search/selection'; +import type { Choice } from '#console/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/pkgs-ts/console/src/prompts/search/read-single.ts b/pkgs-ts/console/src/prompts/search/read-single.ts new file mode 100644 index 00000000..6dfddcdd --- /dev/null +++ b/pkgs-ts/console/src/prompts/search/read-single.ts @@ -0,0 +1,15 @@ +import { promptEnvironment } from '#console/environment'; +import { readSearchChoiceInteractive } from '#console/prompts/search/read-single/interactive'; +import { lineSearchValue } from '#console/prompts/search/read-single/result'; +import type { SearchChoiceReadResult } from '#console/prompts/search/read-single/result'; +import type { SearchReadOptions } from '#console/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/pkgs-ts/console/src/prompts/search/read-single/choice-query.ts b/pkgs-ts/console/src/prompts/search/read-single/choice-query.ts new file mode 100644 index 00000000..c6f5f5e5 --- /dev/null +++ b/pkgs-ts/console/src/prompts/search/read-single/choice-query.ts @@ -0,0 +1,44 @@ +import { applyTypedKey } from '#console/typed-value'; +import { resolveSearchChoices } from '#console/prompts/search/choices'; +import type { SearchReadOptions } from '#console/prompts/search/read-single/types'; +import type { TypedValueState } from '#console/typed-value/types'; +import type { Choice } from '#console/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/pkgs-ts/console/src/prompts/search/read-single/interactive.ts b/pkgs-ts/console/src/prompts/search/read-single/interactive.ts new file mode 100644 index 00000000..aa5d4c7a --- /dev/null +++ b/pkgs-ts/console/src/prompts/search/read-single/interactive.ts @@ -0,0 +1,58 @@ +import { Key } from '#console/key'; +import { clearsSearchHighlight, searchNavigationAction } from '#console/prompts/search/keys'; +import { cancelInteractiveSearchChoice, cancelInteractiveSearchInput } from '#console/prompts/search/read-single/interactive/cancel'; +import { defaultInteractiveSearchChoice, exhaustedInteractiveSearchChoice, highlightedInteractiveSearchChoice } from '#console/prompts/search/read-single/interactive/result'; +import { createSearchReaderSession } from '#console/prompts/search/read-single/session'; +import type { SearchChoiceReadResult } from '#console/prompts/search/read-single/result'; +import type { SearchReadOptions } from '#console/prompts/search/read-single/types'; +import type { PromptInput } from '#console/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/pkgs-ts/console/src/prompts/search/read-single/interactive/cancel.ts b/pkgs-ts/console/src/prompts/search/read-single/interactive/cancel.ts new file mode 100644 index 00000000..2f00116b --- /dev/null +++ b/pkgs-ts/console/src/prompts/search/read-single/interactive/cancel.ts @@ -0,0 +1,23 @@ +import { cancelPrompt } from '#console/prompt'; +import { eraseRenderedFrame } from '#console/status/frame'; +import { renderCancelledSearch } from '#console/prompts/search/render'; +import { cancelledSearchValue } from '#console/prompts/search/read-single/result'; +import { fallbackSearchDefault } from '#console/prompts/search/read-single/interactive/result'; +import type { SearchReaderSession } from '#console/prompts/search/read-single/session'; +import type { SearchChoiceReadResult } from '#console/prompts/search/read-single/result'; +import type { SearchReadOptions } from '#console/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/pkgs-ts/console/src/prompts/search/read-single/interactive/result.ts b/pkgs-ts/console/src/prompts/search/read-single/interactive/result.ts new file mode 100644 index 00000000..8eff4394 --- /dev/null +++ b/pkgs-ts/console/src/prompts/search/read-single/interactive/result.ts @@ -0,0 +1,37 @@ +import type { SearchReaderSession } from '#console/prompts/search/read-single/session'; +import type { SearchChoiceReadResult } from '#console/prompts/search/read-single/result'; +import type { SearchReadOptions } from '#console/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/pkgs-ts/console/src/prompts/search/read-single/result.ts b/pkgs-ts/console/src/prompts/search/read-single/result.ts new file mode 100644 index 00000000..0cf0fd23 --- /dev/null +++ b/pkgs-ts/console/src/prompts/search/read-single/result.ts @@ -0,0 +1,40 @@ +import { ask } from '#console/prompt'; +import { choiceByValue } from '#console/concerns/choices'; +import { resolveLineSearchChoice } from '#console/prompts/search/line-mode'; +import type { Choice, SearchPromptOptions } from '#console/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/pkgs-ts/console/src/prompts/search/read-single/session.ts b/pkgs-ts/console/src/prompts/search/read-single/session.ts new file mode 100644 index 00000000..67e19a80 --- /dev/null +++ b/pkgs-ts/console/src/prompts/search/read-single/session.ts @@ -0,0 +1,60 @@ +import { createSearchReaderSessionFrame } from '#console/prompts/search/read-single/session/frame'; +import { createSingleSearchReaderState } from '#console/prompts/search/read-single/state'; +import type { SearchNavigationAction } from '#console/prompts/search/keys'; +import type { SearchReaderSelection, SearchReadOptions } from '#console/prompts/search/read-single/types'; +import type { TypedValueState } from '#console/typed-value/types'; +import type { Choice } from '#console/types'; + +import { applySearchSessionTypedInput, clearSearchSessionHighlight, moveSearchSessionHighlight } from '#console/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/pkgs-ts/console/src/prompts/search/read-single/session/actions.ts b/pkgs-ts/console/src/prompts/search/read-single/session/actions.ts new file mode 100644 index 00000000..fe05d32f --- /dev/null +++ b/pkgs-ts/console/src/prompts/search/read-single/session/actions.ts @@ -0,0 +1,24 @@ +import type { SearchNavigationAction } from '#console/prompts/search/keys'; +import type { SearchReaderSessionFrame } from '#console/prompts/search/read-single/session/frame'; +import type { SingleSearchReaderState } from '#console/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/pkgs-ts/console/src/prompts/search/read-single/session/frame.ts b/pkgs-ts/console/src/prompts/search/read-single/session/frame.ts new file mode 100644 index 00000000..beacb8f5 --- /dev/null +++ b/pkgs-ts/console/src/prompts/search/read-single/session/frame.ts @@ -0,0 +1,28 @@ +import { eraseRenderedFrame } from '#console/status/frame'; +import { renderSearchChoices } from '#console/prompts/search/render'; +import type { SingleSearchReaderState } from '#console/prompts/search/read-single/state'; +import type { SearchReadOptions } from '#console/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/pkgs-ts/console/src/prompts/search/read-single/state.ts b/pkgs-ts/console/src/prompts/search/read-single/state.ts new file mode 100644 index 00000000..5bd16454 --- /dev/null +++ b/pkgs-ts/console/src/prompts/search/read-single/state.ts @@ -0,0 +1,59 @@ +import { resolveSearchChoices } from '#console/prompts/search/choices'; +import { createSingleSearchChoiceQuery } from '#console/prompts/search/read-single/choice-query'; +import { createSingleSearchHighlightState } from '#console/prompts/search/read-single/state/highlight'; +import { defaultSingleSearchSelection, selectedSingleSearchSelection } from '#console/prompts/search/read-single/state/selection'; +import type { SearchNavigationAction } from '#console/prompts/search/keys'; +import type { SearchReaderSelection, SearchReadOptions } from '#console/prompts/search/read-single/types'; +import type { TypedValueState } from '#console/typed-value/types'; +import type { Choice } from '#console/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/pkgs-ts/console/src/prompts/search/read-single/state/highlight.ts b/pkgs-ts/console/src/prompts/search/read-single/state/highlight.ts new file mode 100644 index 00000000..0e43aff0 --- /dev/null +++ b/pkgs-ts/console/src/prompts/search/read-single/state/highlight.ts @@ -0,0 +1,26 @@ +import { moveSearchHighlight } from '#console/prompts/search/keys'; +import { initialRetriedSearchHighlight } from '#console/prompts/search/navigation'; +import type { SearchNavigationAction } from '#console/prompts/search/keys'; +import type { Choice } from '#console/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/pkgs-ts/console/src/prompts/search/read-single/state/selection.ts b/pkgs-ts/console/src/prompts/search/read-single/state/selection.ts new file mode 100644 index 00000000..9d4bc3ba --- /dev/null +++ b/pkgs-ts/console/src/prompts/search/read-single/state/selection.ts @@ -0,0 +1,25 @@ +import { choiceValueEquals } from '#console/concerns/choices'; +import { defaultSearchChoice, selectedSearchValue } from '#console/prompts/search/read-single/result'; +import type { SingleSearchChoiceQuery } from '#console/prompts/search/read-single/choice-query'; +import type { SearchReaderSelection, SearchReadOptions } from '#console/prompts/search/read-single/types'; +import type { Choice } from '#console/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/pkgs-ts/console/src/prompts/search/read-single/types.ts b/pkgs-ts/console/src/prompts/search/read-single/types.ts new file mode 100644 index 00000000..95ec11eb --- /dev/null +++ b/pkgs-ts/console/src/prompts/search/read-single/types.ts @@ -0,0 +1,11 @@ +import type { SearchPromptOptions } from '#console/types'; + +export type SearchReadOptions = SearchPromptOptions & { + hasDefault?: boolean; +}; + +export type SearchReaderSelection = { + label: string; + submitted: boolean; + value: T | undefined; +}; diff --git a/pkgs-ts/console/src/prompts/search/render-body.ts b/pkgs-ts/console/src/prompts/search/render-body.ts new file mode 100644 index 00000000..26c6864c --- /dev/null +++ b/pkgs-ts/console/src/prompts/search/render-body.ts @@ -0,0 +1 @@ +export { renderSearchBody } from '#console/prompts/search/body/render'; diff --git a/pkgs-ts/console/src/prompts/search/render.ts b/pkgs-ts/console/src/prompts/search/render.ts new file mode 100644 index 00000000..f74683d7 --- /dev/null +++ b/pkgs-ts/console/src/prompts/search/render.ts @@ -0,0 +1,50 @@ +import { promptEnvironment } from '#console/environment'; +import { joinedInfoDetails, resolveInfo } from '#console/concerns/info'; +import { renderSearchBody } from '#console/prompts/search/render-body'; +import { renderBox } from '#console/theme/box'; +import { cyan, dim, red, strikethrough } from '#console/theme/styles'; +import type { Choice, MultiSearchPromptOptions, SearchPromptOptions } from '#console/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/pkgs-ts/console/src/prompts/search/run.ts b/pkgs-ts/console/src/prompts/search/run.ts new file mode 100644 index 00000000..0ca7ba89 --- /dev/null +++ b/pkgs-ts/console/src/prompts/search/run.ts @@ -0,0 +1,50 @@ +import { promptUntilValid, promptWithFallback, PromptValidationError } from '#console/prompt'; +import { activePromptFrame } from '#console/prompt/active-frame'; +import { createPromptSubmissionState } from '#console/prompt/submission'; +import { preserveSearchRetryDefault, transformSearchValue, transformedSearchDefault } from '#console/prompts/search/defaults'; +import { readSearchChoice } from '#console/prompts/search/read-single'; +import { renderSubmittedSearchChoice } from '#console/prompts/search/render'; +import type { NormalizedSearchPromptOptions } from '#console/prompts/search/defaults'; +import type { SearchPromptOptions } from '#console/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/pkgs-ts/console/src/prompts/search/search.ts b/pkgs-ts/console/src/prompts/search/search.ts new file mode 100644 index 00000000..09e98b56 --- /dev/null +++ b/pkgs-ts/console/src/prompts/search/search.ts @@ -0,0 +1,31 @@ +import { normalizeSearchPromptOptions } from '#console/prompts/search/options'; +import { runSearchPrompt } from '#console/prompts/search/run'; +import type { ChoiceOptions, SearchPromptOptions } from '#console/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/pkgs-ts/console/src/prompts/search/selection.ts b/pkgs-ts/console/src/prompts/search/selection.ts new file mode 100644 index 00000000..cdc92835 --- /dev/null +++ b/pkgs-ts/console/src/prompts/search/selection.ts @@ -0,0 +1,73 @@ +import { choiceValueEquals } from '#console/concerns/choices'; +import type { Choice } from '#console/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/pkgs-ts/console/src/prompts/search/validators/defaults.ts similarity index 100% rename from packages/tui/src/prompts/search/validators/defaults.ts rename to pkgs-ts/console/src/prompts/search/validators/defaults.ts diff --git a/pkgs-ts/console/src/prompts/search/validators/options.ts b/pkgs-ts/console/src/prompts/search/validators/options.ts new file mode 100644 index 00000000..f5f6be9f --- /dev/null +++ b/pkgs-ts/console/src/prompts/search/validators/options.ts @@ -0,0 +1,10 @@ +import { z } from 'zod'; +import type { SearchPromptOptions } from '#console/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/pkgs-ts/console/src/prompts/search/validators/overload.ts similarity index 100% rename from packages/tui/src/prompts/search/validators/overload.ts rename to pkgs-ts/console/src/prompts/search/validators/overload.ts diff --git a/pkgs-ts/console/src/prompts/search/validators/source.ts b/pkgs-ts/console/src/prompts/search/validators/source.ts new file mode 100644 index 00000000..d053d8ab --- /dev/null +++ b/pkgs-ts/console/src/prompts/search/validators/source.ts @@ -0,0 +1,40 @@ +import { z } from 'zod'; +import { choiceOptionsSchema } from '#console/concerns/validators/choice'; +import { functionSchema } from '#console/validators/function'; +import type { ChoiceOptions, MaybePromise, SearchPromptOptions } from '#console/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/pkgs-ts/console/src/prompts/select/confirm-options.ts similarity index 90% rename from packages/tui/src/prompts/select/confirm-options.ts rename to pkgs-ts/console/src/prompts/select/confirm-options.ts index f60f9e74..709e02e2 100644 --- a/packages/tui/src/prompts/select/confirm-options.ts +++ b/pkgs-ts/console/src/prompts/select/confirm-options.ts @@ -1,6 +1,6 @@ -import { hasConfirmDefaultArgument, isSelectPromptLabel, parseConfirmDefault } from '#tui/prompts/select/validators/overload'; -import { hasPromptDefault } from '#tui/validators/default'; -import type { ConfirmPromptOptions } from '#tui/types'; +import { hasConfirmDefaultArgument, isSelectPromptLabel, parseConfirmDefault } from '#console/prompts/select/validators/overload'; +import { hasPromptDefault } from '#console/validators/default'; +import type { ConfirmPromptOptions } from '#console/types'; export type NormalizedConfirmPromptOptions = ConfirmPromptOptions & { default: boolean; diff --git a/pkgs-ts/console/src/prompts/select/confirm-run.ts b/pkgs-ts/console/src/prompts/select/confirm-run.ts new file mode 100644 index 00000000..4c1d5230 --- /dev/null +++ b/pkgs-ts/console/src/prompts/select/confirm-run.ts @@ -0,0 +1,45 @@ +import { promptUntilValid, promptWithFallback } from '#console/prompt'; +import { activePromptFrame } from '#console/prompt/active-frame'; +import { createPromptSubmissionState } from '#console/prompt/submission'; +import { preserveConfirmRetryDefault, transformedConfirmDefault, transformConfirmValue } from '#console/prompts/select/confirm-options'; +import { readConfirm } from '#console/prompts/select/read-confirm'; +import { renderSubmittedConfirm } from '#console/prompts/select/render-confirm'; +import type { NormalizedConfirmPromptOptions } from '#console/prompts/select/confirm-options'; +import type { ConfirmPromptOptions } from '#console/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/pkgs-ts/console/src/prompts/select/confirm.ts b/pkgs-ts/console/src/prompts/select/confirm.ts new file mode 100644 index 00000000..3287bb3d --- /dev/null +++ b/pkgs-ts/console/src/prompts/select/confirm.ts @@ -0,0 +1,29 @@ +import { confirmHasDefault, normalizeConfirmPromptOptions } from '#console/prompts/select/confirm-options'; +import { runConfirmPrompt } from '#console/prompts/select/confirm-run'; +import type { ConfirmPromptOptions } from '#console/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/pkgs-ts/console/src/prompts/select/defaults.ts b/pkgs-ts/console/src/prompts/select/defaults.ts new file mode 100644 index 00000000..52ecc424 --- /dev/null +++ b/pkgs-ts/console/src/prompts/select/defaults.ts @@ -0,0 +1,41 @@ +import { parsePromptDefault } from '#console/validators/default'; +import type { MultiSelectPromptOptions, SelectPromptOptions } from '#console/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/pkgs-ts/console/src/prompts/select/index.ts b/pkgs-ts/console/src/prompts/select/index.ts new file mode 100644 index 00000000..e635f558 --- /dev/null +++ b/pkgs-ts/console/src/prompts/select/index.ts @@ -0,0 +1,3 @@ +export { confirm } from '#console/prompts/select/confirm'; +export { multiselect } from '#console/prompts/select/multiselect'; +export { select } from '#console/prompts/select/select'; diff --git a/pkgs-ts/console/src/prompts/select/keys.ts b/pkgs-ts/console/src/prompts/select/keys.ts new file mode 100644 index 00000000..ba82017a --- /dev/null +++ b/pkgs-ts/console/src/prompts/select/keys.ts @@ -0,0 +1,62 @@ +import { Key, oneOf } from '#console/key'; +import { firstEnabledIndex, nextEnabledIndex } from '#console/concerns/choices'; +import { lastEnabledChoiceIndex, nextChoiceKeys, pageEnabledChoiceIndex, previousChoiceKeys } from '#console/prompts/select/navigation'; +import type { Choice } from '#console/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/pkgs-ts/console/src/prompts/select/multiple.ts similarity index 87% rename from packages/tui/src/prompts/select/multiple.ts rename to pkgs-ts/console/src/prompts/select/multiple.ts index 20d20943..4fbd3f5e 100644 --- a/packages/tui/src/prompts/select/multiple.ts +++ b/pkgs-ts/console/src/prompts/select/multiple.ts @@ -1,7 +1,7 @@ -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'; +import { choiceValueEquals, findChoice } from '#console/concerns/choices'; +import { parseChoiceAnswerList } from '#console/concerns/validators/choice-answer'; +import { PromptValidationError } from '#console/prompt'; +import type { Choice } from '#console/types'; export const choicesFromCommaSeparated = (choices: Array>, answer: string): T[] => { const parts = parseChoiceAnswerList(answer); diff --git a/pkgs-ts/console/src/prompts/select/multiselect.ts b/pkgs-ts/console/src/prompts/select/multiselect.ts new file mode 100644 index 00000000..cf14c7fd --- /dev/null +++ b/pkgs-ts/console/src/prompts/select/multiselect.ts @@ -0,0 +1,31 @@ +import { normalizeMultiSelectPromptOptions } from '#console/prompts/select/multiselect/options'; +import { runMultiSelectPrompt } from '#console/prompts/select/multiselect/run'; +import type { ChoiceOptions, MultiSelectPromptOptions } from '#console/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/pkgs-ts/console/src/prompts/select/multiselect/options.ts b/pkgs-ts/console/src/prompts/select/multiselect/options.ts new file mode 100644 index 00000000..a6d1c7b4 --- /dev/null +++ b/pkgs-ts/console/src/prompts/select/multiselect/options.ts @@ -0,0 +1,25 @@ +import { isSelectPromptLabel, parseSelectChoiceOptions } from '#console/prompts/select/validators/overload'; +import { parseMultiSelectDefault } from '#console/prompts/select/validators/defaults'; +import type { ChoiceOptions, MultiSelectPromptOptions } from '#console/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/pkgs-ts/console/src/prompts/select/multiselect/run.ts b/pkgs-ts/console/src/prompts/select/multiselect/run.ts new file mode 100644 index 00000000..392b0e5e --- /dev/null +++ b/pkgs-ts/console/src/prompts/select/multiselect/run.ts @@ -0,0 +1,46 @@ +import { normalizeChoices } from '#console/concerns/choices'; +import { promptUntilValid, promptWithFallback } from '#console/prompt'; +import { activePromptFrame } from '#console/prompt/active-frame'; +import { createPromptSubmissionState } from '#console/prompt/submission'; +import { preserveMultiSelectRetryDefault, transformedMultiSelectDefault } from '#console/prompts/select/defaults'; +import { readMultipleChoices } from '#console/prompts/select/read-multiple'; +import { renderSubmittedChoices } from '#console/prompts/select/render'; +import type { NormalizedMultiSelectPromptOptions } from '#console/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/pkgs-ts/console/src/prompts/select/navigation.ts b/pkgs-ts/console/src/prompts/select/navigation.ts new file mode 100644 index 00000000..f2f45847 --- /dev/null +++ b/pkgs-ts/console/src/prompts/select/navigation.ts @@ -0,0 +1,54 @@ +import { parseChoiceAnswerIndex } from '#console/concerns/validators/choice-answer'; +import { Key } from '#console/key'; +import { parseScrollSize } from '#console/concerns/validators/scroll'; +import type { Choice } from '#console/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/pkgs-ts/console/src/prompts/select/options.ts b/pkgs-ts/console/src/prompts/select/options.ts new file mode 100644 index 00000000..76e6f8f0 --- /dev/null +++ b/pkgs-ts/console/src/prompts/select/options.ts @@ -0,0 +1,41 @@ +import { hasSelectDefaultArgument, isSelectPromptLabel, parseSelectChoiceOptions } from '#console/prompts/select/validators/overload'; +import { hasPromptDefault } from '#console/validators/default'; +import type { NormalizedSelectPromptOptions } from '#console/prompts/select/defaults'; +import type { ChoiceOptions, SelectPromptOptions } from '#console/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/pkgs-ts/console/src/prompts/select/read-confirm.ts b/pkgs-ts/console/src/prompts/select/read-confirm.ts new file mode 100644 index 00000000..86a7213f --- /dev/null +++ b/pkgs-ts/console/src/prompts/select/read-confirm.ts @@ -0,0 +1,59 @@ +import { promptEnvironment } from '#console/environment'; +import { Key } from '#console/key'; +import { cancelPrompt } from '#console/prompt'; +import { rejectPromptRevert } from '#console/prompt/revert'; +import { confirmDirectValue, isConfirmToggleKey } from '#console/prompts/select/read-confirm/keys'; +import { readLineConfirm } from '#console/prompts/select/read-confirm/line-mode'; +import { createConfirmReaderSession } from '#console/prompts/select/read-confirm/session'; +import type { ConfirmReadOptions, ConfirmReadResult } from '#console/prompts/select/read-confirm/types'; + +export type { ConfirmReadOptions, ConfirmReadResult } from '#console/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/pkgs-ts/console/src/prompts/select/read-confirm/keys.ts b/pkgs-ts/console/src/prompts/select/read-confirm/keys.ts new file mode 100644 index 00000000..dd8f391c --- /dev/null +++ b/pkgs-ts/console/src/prompts/select/read-confirm/keys.ts @@ -0,0 +1,37 @@ +import { Key } from '#console/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/pkgs-ts/console/src/prompts/select/read-confirm/line-mode.ts b/pkgs-ts/console/src/prompts/select/read-confirm/line-mode.ts new file mode 100644 index 00000000..07825b60 --- /dev/null +++ b/pkgs-ts/console/src/prompts/select/read-confirm/line-mode.ts @@ -0,0 +1,16 @@ +import { ask } from '#console/prompt'; +import type { ConfirmReadOptions, ConfirmReadResult } from '#console/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/pkgs-ts/console/src/prompts/select/read-confirm/session.ts b/pkgs-ts/console/src/prompts/select/read-confirm/session.ts new file mode 100644 index 00000000..75988015 --- /dev/null +++ b/pkgs-ts/console/src/prompts/select/read-confirm/session.ts @@ -0,0 +1,46 @@ +import { eraseRenderedFrame } from '#console/status/frame'; +import { renderActiveConfirm, renderCancelledConfirm } from '#console/prompts/select/render-confirm'; +import type { ConfirmReadOptions } from '#console/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/pkgs-ts/console/src/prompts/select/read-confirm/types.ts b/pkgs-ts/console/src/prompts/select/read-confirm/types.ts new file mode 100644 index 00000000..d54b9a7f --- /dev/null +++ b/pkgs-ts/console/src/prompts/select/read-confirm/types.ts @@ -0,0 +1,12 @@ +import type { ConfirmPromptOptions } from '#console/types'; + +export type ConfirmReadOptions = ConfirmPromptOptions & { + hasDefault?: boolean; +}; + +export type ConfirmReadResult = { + cancelled: boolean; + frame?: string; + submitted: boolean; + value: boolean; +}; diff --git a/pkgs-ts/console/src/prompts/select/read-multiple.ts b/pkgs-ts/console/src/prompts/select/read-multiple.ts new file mode 100644 index 00000000..7dd96e33 --- /dev/null +++ b/pkgs-ts/console/src/prompts/select/read-multiple.ts @@ -0,0 +1,24 @@ +import { promptEnvironment } from '#console/environment'; +import { readMultipleChoicesInteractive } from '#console/prompts/select/read-multiple/interactive'; +import { readLineMultipleChoices } from '#console/prompts/select/read-multiple/line-mode'; +import type { MultipleChoicesReadResult } from '#console/prompts/select/read-multiple/types'; +import type { Choice, MultiSelectPromptOptions } from '#console/types'; + +export type { MultipleChoicesReadResult } from '#console/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/pkgs-ts/console/src/prompts/select/read-multiple/cancel.ts b/pkgs-ts/console/src/prompts/select/read-multiple/cancel.ts new file mode 100644 index 00000000..dba5b3e9 --- /dev/null +++ b/pkgs-ts/console/src/prompts/select/read-multiple/cancel.ts @@ -0,0 +1,17 @@ +import { cancelPrompt } from '#console/prompt'; +import { eraseRenderedFrame } from '#console/status/frame'; +import { markedChoiceValues } from '#console/prompts/select/multiple'; +import { cancelledMultipleChoicesResult } from '#console/prompts/select/read-multiple/result'; +import type { MultipleChoicesReaderSession } from '#console/prompts/select/read-multiple/session'; +import type { MultipleChoicesReadResult } from '#console/prompts/select/read-multiple/types'; +import { renderCancelledChoices } from '#console/prompts/select/render'; +import type { Choice } from '#console/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/pkgs-ts/console/src/prompts/select/read-multiple/interactive.ts b/pkgs-ts/console/src/prompts/select/read-multiple/interactive.ts new file mode 100644 index 00000000..f99e3cbd --- /dev/null +++ b/pkgs-ts/console/src/prompts/select/read-multiple/interactive.ts @@ -0,0 +1,46 @@ +import { Key } from '#console/key'; +import { cancelMultipleChoices } from '#console/prompts/select/read-multiple/cancel'; +import { applyMultipleChoicesKey } from '#console/prompts/select/read-multiple/keys'; +import { multipleChoicesResult } from '#console/prompts/select/read-multiple/result'; +import { createMultipleChoicesReaderSession } from '#console/prompts/select/read-multiple/session'; +import type { MultipleChoicesReadResult } from '#console/prompts/select/read-multiple/types'; +import type { Choice, MultiSelectPromptOptions } from '#console/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/pkgs-ts/console/src/prompts/select/read-multiple/keys.ts b/pkgs-ts/console/src/prompts/select/read-multiple/keys.ts new file mode 100644 index 00000000..dbcf4f23 --- /dev/null +++ b/pkgs-ts/console/src/prompts/select/read-multiple/keys.ts @@ -0,0 +1,51 @@ +import { Key } from '#console/key'; +import { selectNavigationAction } from '#console/prompts/select/keys'; +import { choicesFromCommaSeparated } from '#console/prompts/select/multiple'; +import { parseChoiceIndex } from '#console/prompts/select/navigation'; +import { multipleChoicesValueResult } from '#console/prompts/select/read-multiple/result'; +import type { MultipleChoicesReaderSession } from '#console/prompts/select/read-multiple/session'; +import type { MultipleChoicesReadResult } from '#console/prompts/select/read-multiple/types'; +import type { Choice } from '#console/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/pkgs-ts/console/src/prompts/select/read-multiple/line-mode.ts b/pkgs-ts/console/src/prompts/select/read-multiple/line-mode.ts new file mode 100644 index 00000000..8d9b71ca --- /dev/null +++ b/pkgs-ts/console/src/prompts/select/read-multiple/line-mode.ts @@ -0,0 +1,16 @@ +import { ask } from '#console/prompt'; +import { renderChoices } from '#console/theme'; +import { choicesFromCommaSeparated, defaultChoiceValues } from '#console/prompts/select/multiple'; +import { multipleChoicesValueResult } from '#console/prompts/select/read-multiple/result'; +import type { MultipleChoicesReadResult } from '#console/prompts/select/read-multiple/types'; +import type { Choice } from '#console/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/pkgs-ts/console/src/prompts/select/read-multiple/result.ts b/pkgs-ts/console/src/prompts/select/read-multiple/result.ts new file mode 100644 index 00000000..e13e67d7 --- /dev/null +++ b/pkgs-ts/console/src/prompts/select/read-multiple/result.ts @@ -0,0 +1,32 @@ +import { markedChoiceValues } from '#console/prompts/select/multiple'; +import type { MultipleChoicesReadResult } from '#console/prompts/select/read-multiple/types'; +import type { Choice } from '#console/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/pkgs-ts/console/src/prompts/select/read-multiple/session.ts b/pkgs-ts/console/src/prompts/select/read-multiple/session.ts new file mode 100644 index 00000000..f51fac95 --- /dev/null +++ b/pkgs-ts/console/src/prompts/select/read-multiple/session.ts @@ -0,0 +1,62 @@ +import { createMultipleChoicesSessionFrame } from '#console/prompts/select/read-multiple/session/frame'; +import { createMultipleChoicesSessionState } from '#console/prompts/select/read-multiple/session/state'; +import type { SelectNavigationAction } from '#console/prompts/select/keys'; +import type { Choice, MultiSelectPromptOptions } from '#console/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/pkgs-ts/console/src/prompts/select/read-multiple/session/frame.ts b/pkgs-ts/console/src/prompts/select/read-multiple/session/frame.ts new file mode 100644 index 00000000..c441cded --- /dev/null +++ b/pkgs-ts/console/src/prompts/select/read-multiple/session/frame.ts @@ -0,0 +1,25 @@ +import { eraseRenderedFrame } from '#console/status/frame'; +import { renderMultipleChoices } from '#console/prompts/select/render'; +import type { Choice, MultiSelectPromptOptions } from '#console/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/pkgs-ts/console/src/prompts/select/read-multiple/session/state.ts b/pkgs-ts/console/src/prompts/select/read-multiple/session/state.ts new file mode 100644 index 00000000..9e02480e --- /dev/null +++ b/pkgs-ts/console/src/prompts/select/read-multiple/session/state.ts @@ -0,0 +1,46 @@ +import { firstEnabledIndex } from '#console/concerns/choices'; +import { moveSelectHighlight } from '#console/prompts/select/keys'; +import { markedChoiceIndexes, toggleAllEnabledChoices, toggleMarkedChoice } from '#console/prompts/select/multiple'; +import type { SelectNavigationAction } from '#console/prompts/select/keys'; +import type { Choice } from '#console/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/pkgs-ts/console/src/prompts/select/read-multiple/types.ts similarity index 100% rename from packages/tui/src/prompts/select/read-multiple/types.ts rename to pkgs-ts/console/src/prompts/select/read-multiple/types.ts diff --git a/pkgs-ts/console/src/prompts/select/read-selected.ts b/pkgs-ts/console/src/prompts/select/read-selected.ts new file mode 100644 index 00000000..441dfae4 --- /dev/null +++ b/pkgs-ts/console/src/prompts/select/read-selected.ts @@ -0,0 +1,25 @@ +import { promptEnvironment } from '#console/environment'; +import { readSelectedChoiceInteractive } from '#console/prompts/select/read-selected/interactive'; +import { readSelectedChoiceLineMode } from '#console/prompts/select/read-selected/line-mode'; +import type { SelectedChoiceReadResult } from '#console/prompts/select/read-selected/types'; +import type { Choice, SelectPromptOptions } from '#console/types'; + +export type { SelectedChoiceReadResult } from '#console/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/pkgs-ts/console/src/prompts/select/read-selected/interactive.ts b/pkgs-ts/console/src/prompts/select/read-selected/interactive.ts new file mode 100644 index 00000000..6b8b44fb --- /dev/null +++ b/pkgs-ts/console/src/prompts/select/read-selected/interactive.ts @@ -0,0 +1,51 @@ +import { Key } from '#console/key'; +import { selectNavigationAction } from '#console/prompts/select/keys'; +import { parseChoiceIndex } from '#console/prompts/select/navigation'; +import { cancelSelectedChoice } from '#console/prompts/select/read-selected/interactive/cancel'; +import { exhaustedSelectedChoice, indexedSelectedChoice, submittedSelectedChoice } from '#console/prompts/select/read-selected/interactive/result'; +import { createSelectedChoiceReaderSession } from '#console/prompts/select/read-selected/session'; +import type { SelectedChoiceReadResult } from '#console/prompts/select/read-selected/types'; +import type { Choice, PromptInput, SelectPromptOptions } from '#console/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/pkgs-ts/console/src/prompts/select/read-selected/interactive/cancel.ts b/pkgs-ts/console/src/prompts/select/read-selected/interactive/cancel.ts new file mode 100644 index 00000000..a1f1d922 --- /dev/null +++ b/pkgs-ts/console/src/prompts/select/read-selected/interactive/cancel.ts @@ -0,0 +1,26 @@ +import { cancelPrompt } from '#console/prompt'; +import { parseChoiceValue } from '#console/concerns/validators/choice'; +import { eraseRenderedFrame } from '#console/status/frame'; +import { cancelledSelectedChoiceResult } from '#console/prompts/select/read-selected/result'; +import { renderCancelledChoice } from '#console/prompts/select/render'; +import type { SelectedChoiceReaderSession } from '#console/prompts/select/read-selected/session'; +import type { SelectedChoiceReadResult } from '#console/prompts/select/read-selected/types'; +import type { Choice } from '#console/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/pkgs-ts/console/src/prompts/select/read-selected/interactive/result.ts b/pkgs-ts/console/src/prompts/select/read-selected/interactive/result.ts new file mode 100644 index 00000000..e2f2d522 --- /dev/null +++ b/pkgs-ts/console/src/prompts/select/read-selected/interactive/result.ts @@ -0,0 +1,22 @@ +import { selectedChoiceAt, selectedChoiceResult } from '#console/prompts/select/read-selected/result'; +import type { SelectedChoiceReaderSession } from '#console/prompts/select/read-selected/session'; +import type { SelectedChoiceReadResult } from '#console/prompts/select/read-selected/types'; +import type { Choice } from '#console/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/pkgs-ts/console/src/prompts/select/read-selected/line-mode.ts b/pkgs-ts/console/src/prompts/select/read-selected/line-mode.ts new file mode 100644 index 00000000..eec9a9ba --- /dev/null +++ b/pkgs-ts/console/src/prompts/select/read-selected/line-mode.ts @@ -0,0 +1,34 @@ +import { ask } from '#console/prompt'; +import { renderChoices } from '#console/theme'; +import { findChoice } from '#console/concerns/choices'; +import { invalidSelectedChoice, selectedChoiceByDefault, selectedChoiceResult } from '#console/prompts/select/read-selected/result'; +import type { SelectedChoiceReadResult } from '#console/prompts/select/read-selected/types'; +import type { Choice } from '#console/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/pkgs-ts/console/src/prompts/select/read-selected/result.ts b/pkgs-ts/console/src/prompts/select/read-selected/result.ts new file mode 100644 index 00000000..03b7f65c --- /dev/null +++ b/pkgs-ts/console/src/prompts/select/read-selected/result.ts @@ -0,0 +1,46 @@ +import { PromptValidationError } from '#console/prompt'; +import { choiceByValue, choiceValueEquals, firstEnabledIndex } from '#console/concerns/choices'; +import type { SelectedChoiceReadResult } from '#console/prompts/select/read-selected/types'; +import type { Choice } from '#console/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/pkgs-ts/console/src/prompts/select/read-selected/session.ts b/pkgs-ts/console/src/prompts/select/read-selected/session.ts new file mode 100644 index 00000000..11da6a61 --- /dev/null +++ b/pkgs-ts/console/src/prompts/select/read-selected/session.ts @@ -0,0 +1,47 @@ +import { eraseRenderedFrame } from '#console/status/frame'; +import { moveSelectHighlight } from '#console/prompts/select/keys'; +import { defaultChoiceIndex } from '#console/prompts/select/read-selected/result'; +import { renderSelectedChoice } from '#console/prompts/select/render'; +import type { SelectNavigationAction } from '#console/prompts/select/keys'; +import type { Choice, SelectPromptOptions } from '#console/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/pkgs-ts/console/src/prompts/select/read-selected/types.ts similarity index 100% rename from packages/tui/src/prompts/select/read-selected/types.ts rename to pkgs-ts/console/src/prompts/select/read-selected/types.ts diff --git a/packages/tui/src/prompts/select/render-confirm.ts b/pkgs-ts/console/src/prompts/select/render-confirm.ts similarity index 87% rename from packages/tui/src/prompts/select/render-confirm.ts rename to pkgs-ts/console/src/prompts/select/render-confirm.ts index f4e46a26..fb976a4d 100644 --- a/packages/tui/src/prompts/select/render-confirm.ts +++ b/pkgs-ts/console/src/prompts/select/render-confirm.ts @@ -1,7 +1,7 @@ -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'; +import { promptEnvironment } from '#console/environment'; +import { renderBox } from '#console/theme/box'; +import { cyan, dim, green, red } from '#console/theme/styles'; +import type { ConfirmPromptOptions } from '#console/types'; const confirmLabel = (options: ConfirmPromptOptions, confirmed: boolean): string => { return confirmed ? (options.yes ?? 'Yes') : (options.no ?? 'No'); diff --git a/packages/tui/src/prompts/select/render-rows.ts b/pkgs-ts/console/src/prompts/select/render-rows.ts similarity index 87% rename from packages/tui/src/prompts/select/render-rows.ts rename to pkgs-ts/console/src/prompts/select/render-rows.ts index fc619816..abdd5006 100644 --- a/packages/tui/src/prompts/select/render-rows.ts +++ b/pkgs-ts/console/src/prompts/select/render-rows.ts @@ -1,7 +1,7 @@ -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'; +import { choiceLabel } from '#console/concerns/choice-label'; +import { renderChoiceWindowRows } from '#console/prompts/select/render-rows/window'; +import { cyan, dim, strikethrough } from '#console/theme/styles'; +import type { Choice } from '#console/types'; export const renderActiveChoiceRows = (choices: Array>, selected: number, scroll?: number): string => { return renderChoiceWindowRows(choices, selected, scroll, (choice, index) => { diff --git a/pkgs-ts/console/src/prompts/select/render-rows/window.ts b/pkgs-ts/console/src/prompts/select/render-rows/window.ts new file mode 100644 index 00000000..269e2dd5 --- /dev/null +++ b/pkgs-ts/console/src/prompts/select/render-rows/window.ts @@ -0,0 +1,10 @@ +import { choiceWindow } from '#console/concerns/choices'; +import { renderScrollbarRows } from '#console/concerns/scrollbar'; +import type { Choice } from '#console/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/pkgs-ts/console/src/prompts/select/render.ts b/pkgs-ts/console/src/prompts/select/render.ts new file mode 100644 index 00000000..58d6673d --- /dev/null +++ b/pkgs-ts/console/src/prompts/select/render.ts @@ -0,0 +1,44 @@ +import { promptEnvironment } from '#console/environment'; +import { joinedInfoDetails, resolveInfo } from '#console/concerns/info'; +import { renderActiveChecklistRows, renderActiveChoiceRows, renderCancelledChecklistRows, renderCancelledChoiceRows } from '#console/prompts/select/render-rows'; +import { renderBox } from '#console/theme/box'; +import { cyan, dim, red } from '#console/theme/styles'; +import type { Choice, MultiSelectPromptOptions, SelectPromptOptions } from '#console/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/pkgs-ts/console/src/prompts/select/run.ts b/pkgs-ts/console/src/prompts/select/run.ts new file mode 100644 index 00000000..883240f9 --- /dev/null +++ b/pkgs-ts/console/src/prompts/select/run.ts @@ -0,0 +1,60 @@ +import { normalizeChoices } from '#console/concerns/choices'; +import { promptUntilValid, promptWithFallback } from '#console/prompt'; +import { activePromptFrame } from '#console/prompt/active-frame'; +import { createPromptSubmissionState } from '#console/prompt/submission'; +import { preserveSelectRetryDefault, transformSelectValue, transformedSelectDefault } from '#console/prompts/select/defaults'; +import { readSelectedChoice } from '#console/prompts/select/read-selected'; +import { renderSubmittedChoice } from '#console/prompts/select/render'; +import { assertSelectOptions } from '#console/prompts/select/validators/options'; +import type { NormalizedSelectPromptOptions } from '#console/prompts/select/defaults'; +import type { SelectPromptOptions } from '#console/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/pkgs-ts/console/src/prompts/select/select.ts b/pkgs-ts/console/src/prompts/select/select.ts new file mode 100644 index 00000000..8dc15da0 --- /dev/null +++ b/pkgs-ts/console/src/prompts/select/select.ts @@ -0,0 +1,33 @@ +import { normalizeSelectPromptOptions, selectHasDefault } from '#console/prompts/select/options'; +import { runSelectPrompt } from '#console/prompts/select/run'; +import type { ChoiceOptions, SelectPromptOptions } from '#console/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/pkgs-ts/console/src/prompts/select/validators/defaults.ts similarity index 100% rename from packages/tui/src/prompts/select/validators/defaults.ts rename to pkgs-ts/console/src/prompts/select/validators/defaults.ts diff --git a/pkgs-ts/console/src/prompts/select/validators/options.ts b/pkgs-ts/console/src/prompts/select/validators/options.ts new file mode 100644 index 00000000..a01316be --- /dev/null +++ b/pkgs-ts/console/src/prompts/select/validators/options.ts @@ -0,0 +1,10 @@ +import { z } from 'zod'; +import type { SelectPromptOptions } from '#console/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/pkgs-ts/console/src/prompts/select/validators/overload.ts similarity index 89% rename from packages/tui/src/prompts/select/validators/overload.ts rename to pkgs-ts/console/src/prompts/select/validators/overload.ts index 470f42d2..916bf94e 100644 --- a/packages/tui/src/prompts/select/validators/overload.ts +++ b/pkgs-ts/console/src/prompts/select/validators/overload.ts @@ -1,6 +1,6 @@ import { z } from 'zod'; -import { choiceOptionsSchema } from '#tui/concerns/validators/choice'; -import type { ChoiceOptions } from '#tui/types'; +import { choiceOptionsSchema } from '#console/concerns/validators/choice'; +import type { ChoiceOptions } from '#console/types'; const selectPromptLabelSchema = z.string(); const confirmDefaultSchema = z.boolean(); diff --git a/pkgs-ts/console/src/prompts/suggest/autocomplete-prompt.ts b/pkgs-ts/console/src/prompts/suggest/autocomplete-prompt.ts new file mode 100644 index 00000000..3fea335d --- /dev/null +++ b/pkgs-ts/console/src/prompts/suggest/autocomplete-prompt.ts @@ -0,0 +1,39 @@ +import { runTextSuggestionPrompt } from '#console/prompts/suggest/lifecycle'; +import { readAutocompleteValue } from '#console/prompts/suggest/read-autocomplete'; +import { renderSubmittedAutocomplete } from '#console/prompts/suggest/render-autocomplete'; +import { suggestOptions } from '#console/prompts/suggest/options'; +import { isSuggestPromptLabel } from '#console/prompts/suggest/validators/overload'; +import type { SuggestOptions } from '#console/prompts/suggest/options'; +import type { MaybePromise, TextPromptOptions } from '#console/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/pkgs-ts/console/src/prompts/suggest/autocomplete.ts b/pkgs-ts/console/src/prompts/suggest/autocomplete.ts new file mode 100644 index 00000000..0ebc9196 --- /dev/null +++ b/pkgs-ts/console/src/prompts/suggest/autocomplete.ts @@ -0,0 +1,32 @@ +import { Key } from '#console/key'; +import { characterLength } from '#console/typed-value/characters'; +import { nextAutocompleteHighlight } from '#console/prompts/suggest/navigation'; +import type { TypedValueState } from '#console/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/pkgs-ts/console/src/prompts/suggest/ghost-text.ts similarity index 79% rename from packages/tui/src/prompts/suggest/ghost-text.ts rename to pkgs-ts/console/src/prompts/suggest/ghost-text.ts index 5b77a6e1..5a405fb7 100644 --- a/packages/tui/src/prompts/suggest/ghost-text.ts +++ b/pkgs-ts/console/src/prompts/suggest/ghost-text.ts @@ -1,7 +1,7 @@ -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'; +import { dim, inverse } from '#console/theme/styles'; +import { characterLength, characters, fromCharacters } from '#console/typed-value/characters'; +import { valueWithCursor } from '#console/typed-value/cursor'; +import type { TypedValueState } from '#console/typed-value/types'; const startsWithInput = (match: string, value: string): boolean => { const normalizedValue = value.toLowerCase(); diff --git a/pkgs-ts/console/src/prompts/suggest/index.ts b/pkgs-ts/console/src/prompts/suggest/index.ts new file mode 100644 index 00000000..83770a6c --- /dev/null +++ b/pkgs-ts/console/src/prompts/suggest/index.ts @@ -0,0 +1,3 @@ +export { autocomplete } from '#console/prompts/suggest/autocomplete-prompt'; +export { suggest } from '#console/prompts/suggest/suggest'; +export type { SuggestOptions } from '#console/prompts/suggest/options'; diff --git a/pkgs-ts/console/src/prompts/suggest/keys.ts b/pkgs-ts/console/src/prompts/suggest/keys.ts new file mode 100644 index 00000000..11222e46 --- /dev/null +++ b/pkgs-ts/console/src/prompts/suggest/keys.ts @@ -0,0 +1,58 @@ +import { Key, oneOf } from '#console/key'; +import { firstSuggestionHighlight, lastSuggestionHighlight, nextSuggestionHighlight, pageSuggestionHighlight } from '#console/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/pkgs-ts/console/src/prompts/suggest/lifecycle.ts b/pkgs-ts/console/src/prompts/suggest/lifecycle.ts new file mode 100644 index 00000000..d2769082 --- /dev/null +++ b/pkgs-ts/console/src/prompts/suggest/lifecycle.ts @@ -0,0 +1,56 @@ +import { promptUntilValid, promptWithFallback } from '#console/prompt'; +import { activePromptFrame } from '#console/prompt/active-frame'; +import { createPromptSubmissionState } from '#console/prompt/submission'; +import { transformedTextDefault } from '#console/prompts/text-default'; +import type { TextSuggestionReadResult } from '#console/prompts/suggest/read-result'; +import type { SuggestOptions } from '#console/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/pkgs-ts/console/src/prompts/suggest/navigation.ts b/pkgs-ts/console/src/prompts/suggest/navigation.ts new file mode 100644 index 00000000..67c35c1d --- /dev/null +++ b/pkgs-ts/console/src/prompts/suggest/navigation.ts @@ -0,0 +1,35 @@ +import { pageIndex } from '#console/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/pkgs-ts/console/src/prompts/suggest/options.ts b/pkgs-ts/console/src/prompts/suggest/options.ts new file mode 100644 index 00000000..cc123899 --- /dev/null +++ b/pkgs-ts/console/src/prompts/suggest/options.ts @@ -0,0 +1,27 @@ +import { isSuggestPromptLabel } from '#console/prompts/suggest/validators/overload'; +import type { MaybePromise, TextPromptOptions } from '#console/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/pkgs-ts/console/src/prompts/suggest/read-autocomplete.ts b/pkgs-ts/console/src/prompts/suggest/read-autocomplete.ts new file mode 100644 index 00000000..c7012b3b --- /dev/null +++ b/pkgs-ts/console/src/prompts/suggest/read-autocomplete.ts @@ -0,0 +1,62 @@ +import { promptEnvironment } from '#console/environment'; +import { Key } from '#console/key'; +import { ask } from '#console/prompt'; +import { autocompleteNavigationDirection, canAcceptAutocomplete } from '#console/prompts/suggest/autocomplete'; +import { cancelAutocompleteValue } from '#console/prompts/suggest/read-autocomplete/cancel'; +import { suggestionReadResult } from '#console/prompts/suggest/read-result'; +import { createAutocompleteReaderSession } from '#console/prompts/suggest/read-autocomplete/session'; +import { currentAutocompleteValue } from '#console/prompts/suggest/read-autocomplete/submission'; +import type { TextSuggestionReadResult } from '#console/prompts/suggest/read-result'; +import type { SuggestOptions } from '#console/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/pkgs-ts/console/src/prompts/suggest/read-autocomplete/cancel.ts b/pkgs-ts/console/src/prompts/suggest/read-autocomplete/cancel.ts new file mode 100644 index 00000000..5412cf4a --- /dev/null +++ b/pkgs-ts/console/src/prompts/suggest/read-autocomplete/cancel.ts @@ -0,0 +1,16 @@ +import { cancelPrompt } from '#console/prompt'; +import { renderCancelledAutocomplete } from '#console/prompts/suggest/render-autocomplete'; +import { suggestionReadResult } from '#console/prompts/suggest/read-result'; +import { eraseRenderedFrame } from '#console/status/frame'; +import type { AutocompleteReaderSession } from '#console/prompts/suggest/read-autocomplete/session'; +import type { SuggestOptions } from '#console/prompts/suggest/options'; +import type { TextSuggestionReadResult } from '#console/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/pkgs-ts/console/src/prompts/suggest/read-autocomplete/frame.ts b/pkgs-ts/console/src/prompts/suggest/read-autocomplete/frame.ts new file mode 100644 index 00000000..1f764cce --- /dev/null +++ b/pkgs-ts/console/src/prompts/suggest/read-autocomplete/frame.ts @@ -0,0 +1,32 @@ +import { eraseRenderedFrame } from '#console/status/frame'; +import { renderAutocomplete } from '#console/prompts/suggest/render-autocomplete'; +import type { SuggestOptions } from '#console/prompts/suggest/options'; +import type { TypedValueState } from '#console/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/pkgs-ts/console/src/prompts/suggest/read-autocomplete/session.ts b/pkgs-ts/console/src/prompts/suggest/read-autocomplete/session.ts new file mode 100644 index 00000000..4e2302a0 --- /dev/null +++ b/pkgs-ts/console/src/prompts/suggest/read-autocomplete/session.ts @@ -0,0 +1,54 @@ +import { createAutocompleteFrameRenderer } from '#console/prompts/suggest/read-autocomplete/frame'; +import { createAutocompleteReaderState } from '#console/prompts/suggest/read-autocomplete/state'; +import type { TypedValueState } from '#console/typed-value/types'; +import type { SuggestOptions } from '#console/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/pkgs-ts/console/src/prompts/suggest/read-autocomplete/state.ts b/pkgs-ts/console/src/prompts/suggest/read-autocomplete/state.ts new file mode 100644 index 00000000..c08cdbf1 --- /dev/null +++ b/pkgs-ts/console/src/prompts/suggest/read-autocomplete/state.ts @@ -0,0 +1,61 @@ +import { acceptAutocompleteMatch } from '#console/prompts/suggest/autocomplete'; +import { createAutocompleteHighlightState } from '#console/prompts/suggest/read-autocomplete/state/highlight'; +import { createSuggestQueryState } from '#console/prompts/suggest/state/query'; +import type { SuggestOptions } from '#console/prompts/suggest/options'; +import type { TypedValueState } from '#console/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/pkgs-ts/console/src/prompts/suggest/read-autocomplete/state/highlight.ts b/pkgs-ts/console/src/prompts/suggest/read-autocomplete/state/highlight.ts new file mode 100644 index 00000000..01778ae1 --- /dev/null +++ b/pkgs-ts/console/src/prompts/suggest/read-autocomplete/state/highlight.ts @@ -0,0 +1,23 @@ +import { moveAutocompleteHighlight } from '#console/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/pkgs-ts/console/src/prompts/suggest/read-autocomplete/submission.ts b/pkgs-ts/console/src/prompts/suggest/read-autocomplete/submission.ts new file mode 100644 index 00000000..e5b8f216 --- /dev/null +++ b/pkgs-ts/console/src/prompts/suggest/read-autocomplete/submission.ts @@ -0,0 +1,7 @@ +import { suggestionReadResult } from '#console/prompts/suggest/read-result'; +import type { AutocompleteReaderSession } from '#console/prompts/suggest/read-autocomplete/session'; +import type { TextSuggestionReadResult } from '#console/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/pkgs-ts/console/src/prompts/suggest/read-result.ts similarity index 76% rename from packages/tui/src/prompts/suggest/read-result.ts rename to pkgs-ts/console/src/prompts/suggest/read-result.ts index 9472c8e3..18623aa2 100644 --- a/packages/tui/src/prompts/suggest/read-result.ts +++ b/pkgs-ts/console/src/prompts/suggest/read-result.ts @@ -1,5 +1,5 @@ -import { characterLength } from '#tui/typed-value/characters'; -import type { TypedValueState } from '#tui/typed-value'; +import { characterLength } from '#console/typed-value/characters'; +import type { TypedValueState } from '#console/typed-value'; export type TextSuggestionReadResult = { cancelled: boolean; diff --git a/pkgs-ts/console/src/prompts/suggest/read.ts b/pkgs-ts/console/src/prompts/suggest/read.ts new file mode 100644 index 00000000..19b665df --- /dev/null +++ b/pkgs-ts/console/src/prompts/suggest/read.ts @@ -0,0 +1,59 @@ +import { promptEnvironment } from '#console/environment'; +import { Key } from '#console/key'; +import { ask } from '#console/prompt'; +import { clearsSuggestionHighlight, suggestNavigationAction } from '#console/prompts/suggest/keys'; +import { cancelSuggestionValue } from '#console/prompts/suggest/read/cancel'; +import { currentSuggestionValue, highlightedSuggestionValue } from '#console/prompts/suggest/read/submission'; +import { suggestionReadResult } from '#console/prompts/suggest/read-result'; +import { createSuggestReaderSession } from '#console/prompts/suggest/read/session'; +import type { TextSuggestionReadResult } from '#console/prompts/suggest/read-result'; +import type { SuggestOptions } from '#console/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/pkgs-ts/console/src/prompts/suggest/read/cancel.ts b/pkgs-ts/console/src/prompts/suggest/read/cancel.ts new file mode 100644 index 00000000..b3639c4d --- /dev/null +++ b/pkgs-ts/console/src/prompts/suggest/read/cancel.ts @@ -0,0 +1,16 @@ +import { cancelPrompt } from '#console/prompt'; +import { renderCancelledSuggestion } from '#console/prompts/suggest/render'; +import { suggestionReadResult } from '#console/prompts/suggest/read-result'; +import { eraseRenderedFrame } from '#console/status/frame'; +import type { SuggestOptions } from '#console/prompts/suggest/options'; +import type { TextSuggestionReadResult } from '#console/prompts/suggest/read-result'; +import type { SuggestReaderSession } from '#console/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/pkgs-ts/console/src/prompts/suggest/read/frame.ts b/pkgs-ts/console/src/prompts/suggest/read/frame.ts new file mode 100644 index 00000000..7b052265 --- /dev/null +++ b/pkgs-ts/console/src/prompts/suggest/read/frame.ts @@ -0,0 +1,32 @@ +import { eraseRenderedFrame } from '#console/status/frame'; +import { renderSuggestions } from '#console/prompts/suggest/render'; +import type { SuggestOptions } from '#console/prompts/suggest/options'; +import type { TypedValueState } from '#console/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/pkgs-ts/console/src/prompts/suggest/read/session.ts b/pkgs-ts/console/src/prompts/suggest/read/session.ts new file mode 100644 index 00000000..c9b7621a --- /dev/null +++ b/pkgs-ts/console/src/prompts/suggest/read/session.ts @@ -0,0 +1,62 @@ +import { createSuggestFrameRenderer } from '#console/prompts/suggest/read/frame'; +import { createSuggestReaderState } from '#console/prompts/suggest/read/state'; +import type { SuggestNavigationAction } from '#console/prompts/suggest/keys'; +import type { SuggestOptions } from '#console/prompts/suggest/options'; +import type { TypedValueState } from '#console/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/pkgs-ts/console/src/prompts/suggest/read/state.ts b/pkgs-ts/console/src/prompts/suggest/read/state.ts new file mode 100644 index 00000000..789319e1 --- /dev/null +++ b/pkgs-ts/console/src/prompts/suggest/read/state.ts @@ -0,0 +1,53 @@ +import { createSuggestHighlightState } from '#console/prompts/suggest/read/state/highlight'; +import { createSuggestQueryState } from '#console/prompts/suggest/state/query'; +import type { SuggestNavigationAction } from '#console/prompts/suggest/keys'; +import type { SuggestOptions } from '#console/prompts/suggest/options'; +import type { TypedValueState } from '#console/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/pkgs-ts/console/src/prompts/suggest/read/state/highlight.ts b/pkgs-ts/console/src/prompts/suggest/read/state/highlight.ts new file mode 100644 index 00000000..61d8a349 --- /dev/null +++ b/pkgs-ts/console/src/prompts/suggest/read/state/highlight.ts @@ -0,0 +1,24 @@ +import { moveSuggestionHighlight } from '#console/prompts/suggest/keys'; +import type { SuggestNavigationAction } from '#console/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/pkgs-ts/console/src/prompts/suggest/read/submission.ts b/pkgs-ts/console/src/prompts/suggest/read/submission.ts new file mode 100644 index 00000000..3ec71fbc --- /dev/null +++ b/pkgs-ts/console/src/prompts/suggest/read/submission.ts @@ -0,0 +1,14 @@ +import { suggestionReadResult } from '#console/prompts/suggest/read-result'; +import type { TextSuggestionReadResult } from '#console/prompts/suggest/read-result'; +import type { SuggestReaderSession } from '#console/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/pkgs-ts/console/src/prompts/suggest/render-autocomplete.ts b/pkgs-ts/console/src/prompts/suggest/render-autocomplete.ts new file mode 100644 index 00000000..dd9c461e --- /dev/null +++ b/pkgs-ts/console/src/prompts/suggest/render-autocomplete.ts @@ -0,0 +1,29 @@ +import { promptEnvironment } from '#console/environment'; +import { joinedInfoDetails, resolveInfo } from '#console/concerns/info'; +import { autocompleteDisplayValue } from '#console/prompts/suggest/ghost-text'; +import { renderBox } from '#console/theme/box'; +import { cyan, dim, red, strikethrough } from '#console/theme/styles'; +import type { SuggestOptions } from '#console/prompts/suggest/options'; +import type { TypedValueState } from '#console/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/pkgs-ts/console/src/prompts/suggest/render.ts b/pkgs-ts/console/src/prompts/suggest/render.ts new file mode 100644 index 00000000..921c5cbb --- /dev/null +++ b/pkgs-ts/console/src/prompts/suggest/render.ts @@ -0,0 +1,35 @@ +import { promptEnvironment } from '#console/environment'; +import { resolveInfo } from '#console/concerns/info'; +import { renderSuggestBody } from '#console/prompts/suggest/render/body'; +import { renderBox } from '#console/theme/box'; +import { cyan, dim, red, strikethrough } from '#console/theme/styles'; +import type { SuggestOptions } from '#console/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/pkgs-ts/console/src/prompts/suggest/render/body.ts b/pkgs-ts/console/src/prompts/suggest/render/body.ts new file mode 100644 index 00000000..ba5cef8d --- /dev/null +++ b/pkgs-ts/console/src/prompts/suggest/render/body.ts @@ -0,0 +1,14 @@ +import { dim } from '#console/theme/styles'; +import { valueWithCursor } from '#console/typed-value/cursor'; +import { renderSuggestRows } from '#console/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/pkgs-ts/console/src/prompts/suggest/render/rows.ts b/pkgs-ts/console/src/prompts/suggest/render/rows.ts new file mode 100644 index 00000000..c6e7a1bb --- /dev/null +++ b/pkgs-ts/console/src/prompts/suggest/render/rows.ts @@ -0,0 +1,19 @@ +import { choiceWindow } from '#console/concerns/choices'; +import { renderScrollbarRows } from '#console/concerns/scrollbar'; +import { cyan, dim } from '#console/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/pkgs-ts/console/src/prompts/suggest/resolve.ts b/pkgs-ts/console/src/prompts/suggest/resolve.ts new file mode 100644 index 00000000..5dbc519a --- /dev/null +++ b/pkgs-ts/console/src/prompts/suggest/resolve.ts @@ -0,0 +1,14 @@ +import { resolveSuggestionSource } from '#console/prompts/suggest/validators/source'; +import type { SuggestOptions } from '#console/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/pkgs-ts/console/src/prompts/suggest/state/query.ts similarity index 78% rename from packages/tui/src/prompts/suggest/state/query.ts rename to pkgs-ts/console/src/prompts/suggest/state/query.ts index aad2a208..da6424d7 100644 --- a/packages/tui/src/prompts/suggest/state/query.ts +++ b/pkgs-ts/console/src/prompts/suggest/state/query.ts @@ -1,8 +1,8 @@ -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'; +import { applyTypedKey } from '#console/typed-value'; +import { initialSuggestionState } from '#console/prompts/suggest/read-result'; +import { resolveSuggestions } from '#console/prompts/suggest/resolve'; +import type { SuggestOptions } from '#console/prompts/suggest/options'; +import type { TypedValueState } from '#console/typed-value/types'; export type SuggestQueryInputResult = { cancelled: boolean; diff --git a/pkgs-ts/console/src/prompts/suggest/suggest.ts b/pkgs-ts/console/src/prompts/suggest/suggest.ts new file mode 100644 index 00000000..1331f9d8 --- /dev/null +++ b/pkgs-ts/console/src/prompts/suggest/suggest.ts @@ -0,0 +1,38 @@ +import { runTextSuggestionPrompt } from '#console/prompts/suggest/lifecycle'; +import { readSuggestionValue } from '#console/prompts/suggest/read'; +import { renderSubmittedSuggestion } from '#console/prompts/suggest/render'; +import { suggestOptions } from '#console/prompts/suggest/options'; +import type { SuggestOptions } from '#console/prompts/suggest/options'; +import type { MaybePromise, TextPromptOptions } from '#console/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/pkgs-ts/console/src/prompts/suggest/validators/overload.ts similarity index 100% rename from packages/tui/src/prompts/suggest/validators/overload.ts rename to pkgs-ts/console/src/prompts/suggest/validators/overload.ts diff --git a/pkgs-ts/console/src/prompts/suggest/validators/source.ts b/pkgs-ts/console/src/prompts/suggest/validators/source.ts new file mode 100644 index 00000000..56cdccba --- /dev/null +++ b/pkgs-ts/console/src/prompts/suggest/validators/source.ts @@ -0,0 +1,44 @@ +import { z } from 'zod'; +import { functionSchema } from '#console/validators/function'; +import type { MaybePromise } from '#console/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/pkgs-ts/console/src/prompts/text-default.ts similarity index 82% rename from packages/tui/src/prompts/text-default.ts rename to pkgs-ts/console/src/prompts/text-default.ts index 4d1a5710..66d56113 100644 --- a/packages/tui/src/prompts/text-default.ts +++ b/pkgs-ts/console/src/prompts/text-default.ts @@ -1,4 +1,4 @@ -import type { TextPromptOptions } from '#tui/types'; +import type { TextPromptOptions } from '#console/types'; export const transformedTextDefault = async (options: Pick): Promise => { const value = options.default ?? ''; diff --git a/pkgs-ts/console/src/prompts/text.ts b/pkgs-ts/console/src/prompts/text.ts new file mode 100644 index 00000000..65676bc0 --- /dev/null +++ b/pkgs-ts/console/src/prompts/text.ts @@ -0,0 +1,37 @@ +import { normalizeTextPromptOptions } from '#console/prompts/text/options'; +import { runTextPrompt } from '#console/prompts/text/run'; +import type { TextPromptOptions } from '#console/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/pkgs-ts/console/src/prompts/text/options.ts b/pkgs-ts/console/src/prompts/text/options.ts new file mode 100644 index 00000000..3e6a1987 --- /dev/null +++ b/pkgs-ts/console/src/prompts/text/options.ts @@ -0,0 +1,30 @@ +import { textOptions } from '#console/concerns/text-options'; +import { isTextPromptLabel } from '#console/concerns/validators/text-options'; +import type { TextPromptOptions } from '#console/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/pkgs-ts/console/src/prompts/text/run.ts b/pkgs-ts/console/src/prompts/text/run.ts new file mode 100644 index 00000000..63245ab7 --- /dev/null +++ b/pkgs-ts/console/src/prompts/text/run.ts @@ -0,0 +1,50 @@ +import { promptUntilValid, promptWithFallback } from '#console/prompt'; +import { activePromptFrame } from '#console/prompt/active-frame'; +import { createPromptSubmissionState } from '#console/prompt/submission'; +import { transformedTextDefault } from '#console/prompts/text-default'; +import { readTypedValue } from '#console/typed-value'; +import { renderSubmittedTypedValue } from '#console/typed-value/render'; +import type { TextPromptOptions } from '#console/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/pkgs-ts/console/src/prompts/textarea.ts b/pkgs-ts/console/src/prompts/textarea.ts new file mode 100644 index 00000000..61fafa95 --- /dev/null +++ b/pkgs-ts/console/src/prompts/textarea.ts @@ -0,0 +1,40 @@ +import { normalizeTextareaPromptOptions } from '#console/prompts/textarea/options'; +import { runTextareaPrompt } from '#console/prompts/textarea/run'; +import type { TextareaPromptOptions } from '#console/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/pkgs-ts/console/src/prompts/textarea/options.ts b/pkgs-ts/console/src/prompts/textarea/options.ts new file mode 100644 index 00000000..99042223 --- /dev/null +++ b/pkgs-ts/console/src/prompts/textarea/options.ts @@ -0,0 +1,35 @@ +import { isTextPromptLabel } from '#console/concerns/validators/text-options'; +import type { TextareaPromptOptions } from '#console/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/pkgs-ts/console/src/prompts/textarea/run.ts b/pkgs-ts/console/src/prompts/textarea/run.ts new file mode 100644 index 00000000..aa35484d --- /dev/null +++ b/pkgs-ts/console/src/prompts/textarea/run.ts @@ -0,0 +1,52 @@ +import { promptUntilValid, promptWithFallback } from '#console/prompt'; +import { activePromptFrame } from '#console/prompt/active-frame'; +import { createPromptSubmissionState } from '#console/prompt/submission'; +import { transformedTextDefault } from '#console/prompts/text-default'; +import { readTypedValue } from '#console/typed-value'; +import { renderSubmittedTextareaFrame } from '#console/typed-value/textarea-frame'; +import type { TextareaPromptOptions } from '#console/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/pkgs-ts/console/src/status.ts b/pkgs-ts/console/src/status.ts new file mode 100644 index 00000000..71ecad86 --- /dev/null +++ b/pkgs-ts/console/src/status.ts @@ -0,0 +1,6 @@ +export { progress, Progress } from '#console/status/progress'; +export type { ProgressSignalTarget } from '#console/status/progress'; +export { spin } from '#console/status/spinner'; +export { Logger, task } from '#console/status/task'; +export type { TaskDefinition } from '#console/status/task'; +export { Stream, stream } from '#console/status/stream'; diff --git a/pkgs-ts/console/src/status/frame.ts b/pkgs-ts/console/src/status/frame.ts new file mode 100644 index 00000000..90955533 --- /dev/null +++ b/pkgs-ts/console/src/status/frame.ts @@ -0,0 +1,15 @@ +import { erasePreviousLines } from '#console/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/pkgs-ts/console/src/status/progress.ts b/pkgs-ts/console/src/status/progress.ts new file mode 100644 index 00000000..c3e75376 --- /dev/null +++ b/pkgs-ts/console/src/status/progress.ts @@ -0,0 +1,35 @@ +import { Progress } from '#console/status/progress/progress'; +import { runProgressSteps } from '#console/status/progress/run'; +import { progressValues } from '#console/status/progress/steps'; +import type { MaybePromise } from '#console/types'; + +import { isProgressTotalArgument, progressMessageArgument, progressStepsArgument } from '#console/status/progress/validators/arguments'; + +export { Progress }; +export type { ProgressSignalTarget } from '#console/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/pkgs-ts/console/src/status/progress/controller.ts b/pkgs-ts/console/src/status/progress/controller.ts new file mode 100644 index 00000000..13fda532 --- /dev/null +++ b/pkgs-ts/console/src/status/progress/controller.ts @@ -0,0 +1,42 @@ +import { ProgressRenderer } from '#console/status/progress/renderer'; +import { ProgressState } from '#console/status/progress/state'; +import { ProgressTerminalLifecycle } from '#console/status/progress/terminal'; +import type { ProgressSignalTarget } from '#console/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/pkgs-ts/console/src/status/progress/labels.ts b/pkgs-ts/console/src/status/progress/labels.ts new file mode 100644 index 00000000..49dbf328 --- /dev/null +++ b/pkgs-ts/console/src/status/progress/labels.ts @@ -0,0 +1,9 @@ +import type { ProgressController } from '#console/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/pkgs-ts/console/src/status/progress/progress.ts b/pkgs-ts/console/src/status/progress/progress.ts new file mode 100644 index 00000000..7cd13fec --- /dev/null +++ b/pkgs-ts/console/src/status/progress/progress.ts @@ -0,0 +1,68 @@ +import { ProgressController } from '#console/status/progress/controller'; +import { setProgressHint, setProgressLabel } from '#console/status/progress/labels'; +import { progressPromptError } from '#console/status/progress/prompt'; +import { progressCurrent, progressPercentageValue, progressValue } from '#console/status/progress/readback'; +import type { ProgressSignalTarget } from '#console/status/progress/terminal'; + +export type { ProgressSignalTarget } from '#console/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/pkgs-ts/console/src/status/progress/prompt.ts similarity index 100% rename from packages/tui/src/status/progress/prompt.ts rename to pkgs-ts/console/src/status/progress/prompt.ts diff --git a/pkgs-ts/console/src/status/progress/readback.ts b/pkgs-ts/console/src/status/progress/readback.ts new file mode 100644 index 00000000..1dcf1249 --- /dev/null +++ b/pkgs-ts/console/src/status/progress/readback.ts @@ -0,0 +1,11 @@ +import type { ProgressController } from '#console/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/pkgs-ts/console/src/status/progress/render.ts b/pkgs-ts/console/src/status/progress/render.ts new file mode 100644 index 00000000..6b682890 --- /dev/null +++ b/pkgs-ts/console/src/status/progress/render.ts @@ -0,0 +1,37 @@ +import { renderBox } from '#console/theme/box'; +import { clampedProgressCurrent, progressRatio } from '#console/status/progress/state/value'; +import { progressFrameWidth } from '#console/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/pkgs-ts/console/src/status/progress/renderer.ts b/pkgs-ts/console/src/status/progress/renderer.ts new file mode 100644 index 00000000..b0b1a61f --- /dev/null +++ b/pkgs-ts/console/src/status/progress/renderer.ts @@ -0,0 +1,16 @@ +import { promptEnvironment } from '#console/environment'; +import { renderProgressFrame } from '#console/status/progress/render'; +import type { ProgressStateSnapshot } from '#console/status/progress/state'; +import type { ProgressTerminalLifecycle } from '#console/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/pkgs-ts/console/src/status/progress/run.ts b/pkgs-ts/console/src/status/progress/run.ts new file mode 100644 index 00000000..47c5f4e7 --- /dev/null +++ b/pkgs-ts/console/src/status/progress/run.ts @@ -0,0 +1,34 @@ +import type { MaybePromise } from '#console/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/pkgs-ts/console/src/status/progress/state.ts b/pkgs-ts/console/src/status/progress/state.ts new file mode 100644 index 00000000..bb1ce690 --- /dev/null +++ b/pkgs-ts/console/src/status/progress/state.ts @@ -0,0 +1,59 @@ +import { progressStateSnapshot } from '#console/status/progress/state/snapshot'; +import { clampedProgressCurrent, nextProgressCurrent, progressRatio } from '#console/status/progress/state/value'; +import { parseProgressTotal } from '#console/status/validators/progress'; +import type { ProgressFrameState } from '#console/status/progress/render'; +import type { ProgressStateSnapshot } from '#console/status/progress/state/snapshot'; + +export type { ProgressStateSnapshot } from '#console/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/pkgs-ts/console/src/status/progress/state/snapshot.ts similarity index 81% rename from packages/tui/src/status/progress/state/snapshot.ts rename to pkgs-ts/console/src/status/progress/state/snapshot.ts index 9f7fb04e..b284b1d6 100644 --- a/packages/tui/src/status/progress/state/snapshot.ts +++ b/pkgs-ts/console/src/status/progress/state/snapshot.ts @@ -1,4 +1,4 @@ -import type { ProgressFrameState } from '#tui/status/progress/render'; +import type { ProgressFrameState } from '#console/status/progress/render'; export type ProgressStateSnapshot = { current: number; diff --git a/pkgs-ts/console/src/status/progress/state/value.ts b/pkgs-ts/console/src/status/progress/state/value.ts new file mode 100644 index 00000000..e63894a2 --- /dev/null +++ b/pkgs-ts/console/src/status/progress/state/value.ts @@ -0,0 +1,17 @@ +import { parseProgressStep } from '#console/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/pkgs-ts/console/src/status/progress/steps.ts similarity index 76% rename from packages/tui/src/status/progress/steps.ts rename to pkgs-ts/console/src/status/progress/steps.ts index d606dc5c..8a2c9c51 100644 --- a/packages/tui/src/status/progress/steps.ts +++ b/pkgs-ts/console/src/status/progress/steps.ts @@ -1,4 +1,4 @@ -import { parseProgressValuesInput } from '#tui/status/validators/progress'; +import { parseProgressValuesInput } from '#console/status/validators/progress'; export const progressValues = (steps: Iterable | number): Array => { const parsed = parseProgressValuesInput(steps); diff --git a/pkgs-ts/console/src/status/progress/terminal.ts b/pkgs-ts/console/src/status/progress/terminal.ts new file mode 100644 index 00000000..22b7bdd2 --- /dev/null +++ b/pkgs-ts/console/src/status/progress/terminal.ts @@ -0,0 +1,55 @@ +import { eraseRenderedFrame } from '#console/status/frame'; +import { ProgressSignalHandlers } from '#console/status/progress/terminal/signals'; +import { hideCursor, showCursor } from '#console/terminal'; +import type { ProgressSignalTarget } from '#console/status/progress/terminal/signals'; + +export type { ProgressSignalTarget } from '#console/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/pkgs-ts/console/src/status/progress/terminal/signals.ts b/pkgs-ts/console/src/status/progress/terminal/signals.ts new file mode 100644 index 00000000..baa6d372 --- /dev/null +++ b/pkgs-ts/console/src/status/progress/terminal/signals.ts @@ -0,0 +1,41 @@ +import { parseStatusSignalTarget } from '#console/status/validators/signal-target'; +import type { StatusSignalTarget } from '#console/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/pkgs-ts/console/src/status/progress/validators/arguments.ts similarity index 94% rename from packages/tui/src/status/progress/validators/arguments.ts rename to pkgs-ts/console/src/status/progress/validators/arguments.ts index 2cf51bbf..50529a2a 100644 --- a/packages/tui/src/status/progress/validators/arguments.ts +++ b/pkgs-ts/console/src/status/progress/validators/arguments.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import { iterableSchema } from '#tui/validators/iterable'; +import { iterableSchema } from '#console/validators/iterable'; const progressTotalArgumentSchema = z.union([z.number(), z.literal(Number.POSITIVE_INFINITY), z.literal(Number.NEGATIVE_INFINITY)]); const progressMessageArgumentSchema = z.string(); diff --git a/packages/tui/src/status/progress/validators/frame.ts b/pkgs-ts/console/src/status/progress/validators/frame.ts similarity index 100% rename from packages/tui/src/status/progress/validators/frame.ts rename to pkgs-ts/console/src/status/progress/validators/frame.ts diff --git a/pkgs-ts/console/src/status/signals.ts b/pkgs-ts/console/src/status/signals.ts new file mode 100644 index 00000000..782140be --- /dev/null +++ b/pkgs-ts/console/src/status/signals.ts @@ -0,0 +1,57 @@ +import { parseStatusSignalTarget } from '#console/status/validators/signal-target'; +import type { StatusSignalTarget } from '#console/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/pkgs-ts/console/src/status/spinner.ts b/pkgs-ts/console/src/status/spinner.ts new file mode 100644 index 00000000..154eabca --- /dev/null +++ b/pkgs-ts/console/src/status/spinner.ts @@ -0,0 +1,13 @@ +import { resolveSpinnerOptions } from '#console/status/spinner/options'; +import { runSpinnerLifecycle } from '#console/status/spinner/lifecycle'; +import type { MaybePromise, StatusOptions } from '#console/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/pkgs-ts/console/src/status/spinner/lifecycle.ts b/pkgs-ts/console/src/status/spinner/lifecycle.ts new file mode 100644 index 00000000..625d4025 --- /dev/null +++ b/pkgs-ts/console/src/status/spinner/lifecycle.ts @@ -0,0 +1,40 @@ +import { promptEnvironment } from '#console/environment'; +import { eraseRenderedFrame } from '#console/status/frame'; +import { StatusSignalCleanup } from '#console/status/signals'; +import { renderSpinnerFrame } from '#console/status/spinner/render'; +import { hideCursor, showCursor } from '#console/terminal'; +import type { MaybePromise } from '#console/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/pkgs-ts/console/src/status/spinner/options.ts b/pkgs-ts/console/src/status/spinner/options.ts new file mode 100644 index 00000000..21e5938d --- /dev/null +++ b/pkgs-ts/console/src/status/spinner/options.ts @@ -0,0 +1,21 @@ +import { isSpinnerCallback, isSpinnerMessage, parseSpinnerCallback, parseSpinnerOptions } from '#console/status/spinner/validators/options'; +import type { MaybePromise, StatusOptions } from '#console/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/pkgs-ts/console/src/status/spinner/render.ts similarity index 100% rename from packages/tui/src/status/spinner/render.ts rename to pkgs-ts/console/src/status/spinner/render.ts diff --git a/pkgs-ts/console/src/status/spinner/validators/options.ts b/pkgs-ts/console/src/status/spinner/validators/options.ts new file mode 100644 index 00000000..e61c1cf6 --- /dev/null +++ b/pkgs-ts/console/src/status/spinner/validators/options.ts @@ -0,0 +1,43 @@ +import { z } from 'zod'; +import { functionSchema } from '#console/validators/function'; +import type { MaybePromise } from '#console/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/pkgs-ts/console/src/status/stream.ts b/pkgs-ts/console/src/status/stream.ts new file mode 100644 index 00000000..4832ac37 --- /dev/null +++ b/pkgs-ts/console/src/status/stream.ts @@ -0,0 +1,13 @@ +import { Stream } from '#console/status/stream/output'; + +export { Stream } from '#console/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/pkgs-ts/console/src/status/stream/buffer.ts b/pkgs-ts/console/src/status/stream/buffer.ts new file mode 100644 index 00000000..f9caa3ef --- /dev/null +++ b/pkgs-ts/console/src/status/stream/buffer.ts @@ -0,0 +1,38 @@ +import { parseStreamFadeSteps } from '#console/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/pkgs-ts/console/src/status/stream/fade.ts b/pkgs-ts/console/src/status/stream/fade.ts new file mode 100644 index 00000000..0613f5ef --- /dev/null +++ b/pkgs-ts/console/src/status/stream/fade.ts @@ -0,0 +1,39 @@ +import { backgroundColor, foregroundColor, supportsTrueColor } from '#console/terminal'; +import { dim, foregroundRgb } from '#console/theme/styles'; +import { parseStreamFadeColor, parseStreamFadeSteps, parseStreamFadeTrueColor } from '#console/status/stream/validators/fade'; +import type { TerminalColor } from '#console/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/pkgs-ts/console/src/status/stream/lifecycle.ts b/pkgs-ts/console/src/status/stream/lifecycle.ts new file mode 100644 index 00000000..9f84fa2e --- /dev/null +++ b/pkgs-ts/console/src/status/stream/lifecycle.ts @@ -0,0 +1,42 @@ +import { StatusSignalCleanup } from '#console/status/signals'; +import { hideCursor, showCursor } from '#console/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/pkgs-ts/console/src/status/stream/output.ts b/pkgs-ts/console/src/status/stream/output.ts new file mode 100644 index 00000000..2a11ccce --- /dev/null +++ b/pkgs-ts/console/src/status/stream/output.ts @@ -0,0 +1,78 @@ +import { createStreamOutputContext } from '#console/status/stream/output/context'; +import { streamClosedError, streamPromptError } from '#console/status/stream/output/errors'; +import { pipeStreamSource } from '#console/status/stream/output/pipe'; +import { streamBufferLines, streamBufferValue } from '#console/status/stream/output/readback'; +import { flushStreamBuffer, renderStreamBuffer, requestStreamBufferRender } from '#console/status/stream/output/rendering'; +import { parseStreamChunk } from '#console/status/stream/validators/chunk'; +import type { StreamOutputContext } from '#console/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/pkgs-ts/console/src/status/stream/output/context.ts b/pkgs-ts/console/src/status/stream/output/context.ts new file mode 100644 index 00000000..b2a84099 --- /dev/null +++ b/pkgs-ts/console/src/status/stream/output/context.ts @@ -0,0 +1,18 @@ +import { StreamBuffer } from '#console/status/stream/buffer'; +import { StreamLifecycle } from '#console/status/stream/lifecycle'; +import { StreamRenderer } from '#console/status/stream/renderer'; +import { StreamRenderScheduler } from '#console/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/pkgs-ts/console/src/status/stream/output/errors.ts similarity index 100% rename from packages/tui/src/status/stream/output/errors.ts rename to pkgs-ts/console/src/status/stream/output/errors.ts diff --git a/packages/tui/src/status/stream/output/pipe.ts b/pkgs-ts/console/src/status/stream/output/pipe.ts similarity index 100% rename from packages/tui/src/status/stream/output/pipe.ts rename to pkgs-ts/console/src/status/stream/output/pipe.ts diff --git a/pkgs-ts/console/src/status/stream/output/readback.ts b/pkgs-ts/console/src/status/stream/output/readback.ts new file mode 100644 index 00000000..c1dd330e --- /dev/null +++ b/pkgs-ts/console/src/status/stream/output/readback.ts @@ -0,0 +1,10 @@ +import { streamLines } from '#console/status/stream/render'; +import type { StreamBuffer } from '#console/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/pkgs-ts/console/src/status/stream/output/rendering.ts b/pkgs-ts/console/src/status/stream/output/rendering.ts new file mode 100644 index 00000000..db4b82f2 --- /dev/null +++ b/pkgs-ts/console/src/status/stream/output/rendering.ts @@ -0,0 +1,32 @@ +import type { StreamBuffer } from '#console/status/stream/buffer'; +import type { StreamRenderer } from '#console/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/pkgs-ts/console/src/status/stream/render.ts b/pkgs-ts/console/src/status/stream/render.ts new file mode 100644 index 00000000..5cbe03d0 --- /dev/null +++ b/pkgs-ts/console/src/status/stream/render.ts @@ -0,0 +1,29 @@ +import { wrap } from '#console/strings'; +import type { StreamFadeStyle } from '#console/status/stream/fade'; +import { parseStreamChunkLines } from '#console/status/stream/validators/chunk'; +import { parseStreamFrameWidth } from '#console/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/pkgs-ts/console/src/status/stream/renderer.ts b/pkgs-ts/console/src/status/stream/renderer.ts new file mode 100644 index 00000000..37792692 --- /dev/null +++ b/pkgs-ts/console/src/status/stream/renderer.ts @@ -0,0 +1,39 @@ +import { promptEnvironment } from '#console/environment'; +import { eraseRenderedFrame } from '#console/status/frame'; +import { streamFadeStyles } from '#console/status/stream/fade'; +import { renderStreamFrame } from '#console/status/stream/render'; +import type { StreamFadeStyle } from '#console/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/pkgs-ts/console/src/status/stream/scheduler.ts similarity index 100% rename from packages/tui/src/status/stream/scheduler.ts rename to pkgs-ts/console/src/status/stream/scheduler.ts diff --git a/packages/tui/src/status/stream/validators/chunk.ts b/pkgs-ts/console/src/status/stream/validators/chunk.ts similarity index 100% rename from packages/tui/src/status/stream/validators/chunk.ts rename to pkgs-ts/console/src/status/stream/validators/chunk.ts diff --git a/pkgs-ts/console/src/status/stream/validators/fade.ts b/pkgs-ts/console/src/status/stream/validators/fade.ts new file mode 100644 index 00000000..6ef6ad46 --- /dev/null +++ b/pkgs-ts/console/src/status/stream/validators/fade.ts @@ -0,0 +1,37 @@ +import { z } from 'zod'; +import { parseTerminalColor } from '#console/terminal/validators/color'; +import type { TerminalColor } from '#console/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/pkgs-ts/console/src/status/task.ts b/pkgs-ts/console/src/status/task.ts new file mode 100644 index 00000000..1c1abd43 --- /dev/null +++ b/pkgs-ts/console/src/status/task.ts @@ -0,0 +1,16 @@ +import { resolveTaskDefinition } from '#console/status/task/definition'; +import { runTaskLifecycle } from '#console/status/task/lifecycle'; +import { Logger } from '#console/status/task/logger'; +import type { TaskDefinition } from '#console/status/task/definition'; +import type { MaybePromise } from '#console/types'; + +export { Logger }; +export type { TaskDefinition } from '#console/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/pkgs-ts/console/src/status/task/definition.ts b/pkgs-ts/console/src/status/task/definition.ts new file mode 100644 index 00000000..dc3beb2e --- /dev/null +++ b/pkgs-ts/console/src/status/task/definition.ts @@ -0,0 +1,47 @@ +import { isTaskTitle, parseTaskCallback, parseTaskDefinition } from '#console/status/task/validators/definition'; +import type { Logger } from '#console/status/task/logger'; +import type { MaybePromise } from '#console/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/pkgs-ts/console/src/status/task/frame.ts b/pkgs-ts/console/src/status/task/frame.ts new file mode 100644 index 00000000..2fcaa259 --- /dev/null +++ b/pkgs-ts/console/src/status/task/frame.ts @@ -0,0 +1,22 @@ +import { renderTaskFrame } from '#console/status/task/render'; +import type { Logger } from '#console/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/pkgs-ts/console/src/status/task/lifecycle.ts b/pkgs-ts/console/src/status/task/lifecycle.ts new file mode 100644 index 00000000..a04d600f --- /dev/null +++ b/pkgs-ts/console/src/status/task/lifecycle.ts @@ -0,0 +1,55 @@ +import { eraseRenderedFrame } from '#console/status/frame'; +import { StatusSignalCleanup } from '#console/status/signals'; +import { Logger } from '#console/status/task/logger'; +import { captureTaskProcessOutput } from '#console/status/task/process-output'; +import { createTaskLifecycleRenderer } from '#console/status/task/lifecycle/rendering'; +import { hideCursor, showCursor } from '#console/terminal'; +import type { ResolvedTaskDefinition } from '#console/status/task/definition'; +import type { TaskLifecycleRenderer } from '#console/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/pkgs-ts/console/src/status/task/lifecycle/rendering.ts b/pkgs-ts/console/src/status/task/lifecycle/rendering.ts new file mode 100644 index 00000000..b2af34df --- /dev/null +++ b/pkgs-ts/console/src/status/task/lifecycle/rendering.ts @@ -0,0 +1,41 @@ +import { promptEnvironment } from '#console/environment'; +import { eraseRenderedFrame } from '#console/status/frame'; +import { renderLoggerTaskFrame } from '#console/status/task/frame'; +import type { Logger } from '#console/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/pkgs-ts/console/src/status/task/logger.ts b/pkgs-ts/console/src/status/task/logger.ts new file mode 100644 index 00000000..ba09e791 --- /dev/null +++ b/pkgs-ts/console/src/status/task/logger.ts @@ -0,0 +1 @@ +export { Logger } from '#console/status/task/logger/logger'; diff --git a/packages/tui/src/status/task/logger/events.ts b/pkgs-ts/console/src/status/task/logger/events.ts similarity index 100% rename from packages/tui/src/status/task/logger/events.ts rename to pkgs-ts/console/src/status/task/logger/events.ts diff --git a/packages/tui/src/status/task/logger/labels.ts b/pkgs-ts/console/src/status/task/logger/labels.ts similarity index 100% rename from packages/tui/src/status/task/logger/labels.ts rename to pkgs-ts/console/src/status/task/logger/labels.ts diff --git a/packages/tui/src/status/task/logger/limits.ts b/pkgs-ts/console/src/status/task/logger/limits.ts similarity index 78% rename from packages/tui/src/status/task/logger/limits.ts rename to pkgs-ts/console/src/status/task/logger/limits.ts index be18f469..f6d7910f 100644 --- a/packages/tui/src/status/task/logger/limits.ts +++ b/pkgs-ts/console/src/status/task/logger/limits.ts @@ -1,4 +1,4 @@ -import { parseLogLimit } from '#tui/status/validators/limit'; +import { parseLogLimit } from '#console/status/validators/limit'; export type TaskLoggerLimits = { line: number; diff --git a/pkgs-ts/console/src/status/task/logger/lines.ts b/pkgs-ts/console/src/status/task/logger/lines.ts new file mode 100644 index 00000000..b9af4535 --- /dev/null +++ b/pkgs-ts/console/src/status/task/logger/lines.ts @@ -0,0 +1,46 @@ +import { sanitizeTaskLine } from '#console/status/task/sanitize'; +import { parseTaskLogMessageLines } from '#console/status/task/logger/validators/message'; +import { parseLogLimit } from '#console/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/pkgs-ts/console/src/status/task/logger/logger.ts b/pkgs-ts/console/src/status/task/logger/logger.ts new file mode 100644 index 00000000..52197fa5 --- /dev/null +++ b/pkgs-ts/console/src/status/task/logger/logger.ts @@ -0,0 +1,97 @@ +import { createTaskLoggerLimits } from '#console/status/task/logger/limits'; +import { createTaskLoggerLabels } from '#console/status/task/logger/labels'; +import { notifyTaskLoggerChanged } from '#console/status/task/logger/events'; +import { syncTaskLoggerLabel, syncTaskLoggerSubLabel } from '#console/status/task/logger/sync'; +import type { TaskLoggerChangeHandler } from '#console/status/task/logger/events'; +import type { TaskLoggerLimits } from '#console/status/task/logger/limits'; +import type { PartialTaskLogState } from '#console/status/task/logger/lines'; +import type { TaskLoggerLabels } from '#console/status/task/logger/labels'; +import type { StableTaskMessage } from '#console/status/task/messages'; + +import { clearTaskLoggerPartial, writeStableTaskLoggerMessage, writeTaskLoggerLine, writeTaskLoggerPartial } from '#console/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/pkgs-ts/console/src/status/task/logger/methods.ts b/pkgs-ts/console/src/status/task/logger/methods.ts new file mode 100644 index 00000000..2fe87bf4 --- /dev/null +++ b/pkgs-ts/console/src/status/task/logger/methods.ts @@ -0,0 +1,24 @@ +import { appendPartialTaskLog, appendTaskLogLines } from '#console/status/task/logger/lines'; +import { appendStableTaskLoggerOutput } from '#console/status/task/logger/output'; +import type { TaskLoggerLimits } from '#console/status/task/logger/limits'; +import type { PartialTaskLogState } from '#console/status/task/logger/lines'; +import type { StableTaskMessage } from '#console/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/pkgs-ts/console/src/status/task/logger/output.ts b/pkgs-ts/console/src/status/task/logger/output.ts new file mode 100644 index 00000000..eca8a32c --- /dev/null +++ b/pkgs-ts/console/src/status/task/logger/output.ts @@ -0,0 +1,11 @@ +import { appendStableTaskMessage } from '#console/status/task/logger/stable'; +import type { StableTaskMessage } from '#console/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/pkgs-ts/console/src/status/task/logger/stable.ts b/pkgs-ts/console/src/status/task/logger/stable.ts new file mode 100644 index 00000000..3fe88326 --- /dev/null +++ b/pkgs-ts/console/src/status/task/logger/stable.ts @@ -0,0 +1,12 @@ +import { parseLogLimit } from '#console/status/validators/limit'; +import type { StableTaskMessage } from '#console/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/pkgs-ts/console/src/status/task/logger/sync.ts similarity index 78% rename from packages/tui/src/status/task/logger/sync.ts rename to pkgs-ts/console/src/status/task/logger/sync.ts index 82596665..8bc997f2 100644 --- a/packages/tui/src/status/task/logger/sync.ts +++ b/pkgs-ts/console/src/status/task/logger/sync.ts @@ -1,5 +1,5 @@ -import { setTaskLoggerLabel, setTaskLoggerSubLabel } from '#tui/status/task/logger/labels'; -import type { TaskLoggerLabels } from '#tui/status/task/logger/labels'; +import { setTaskLoggerLabel, setTaskLoggerSubLabel } from '#console/status/task/logger/labels'; +import type { TaskLoggerLabels } from '#console/status/task/logger/labels'; export type TaskLoggerLabelSync = { labelValue: string; diff --git a/packages/tui/src/status/task/logger/validators/message.ts b/pkgs-ts/console/src/status/task/logger/validators/message.ts similarity index 100% rename from packages/tui/src/status/task/logger/validators/message.ts rename to pkgs-ts/console/src/status/task/logger/validators/message.ts diff --git a/packages/tui/src/status/task/messages.ts b/pkgs-ts/console/src/status/task/messages.ts similarity index 100% rename from packages/tui/src/status/task/messages.ts rename to pkgs-ts/console/src/status/task/messages.ts diff --git a/pkgs-ts/console/src/status/task/process-output.ts b/pkgs-ts/console/src/status/task/process-output.ts new file mode 100644 index 00000000..c7e1dfd2 --- /dev/null +++ b/pkgs-ts/console/src/status/task/process-output.ts @@ -0,0 +1,34 @@ +import { ProcessOutputBuffer } from '#console/status/task/process-output/buffer'; +import { parseProcessOutputWrite, parseWritableProcessStream } from '#console/status/task/process-output/validators/writer'; +import { processOutputWriter } from '#console/status/task/process-output/writer'; +import type { Logger } from '#console/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/pkgs-ts/console/src/status/task/process-output/buffer.ts b/pkgs-ts/console/src/status/task/process-output/buffer.ts new file mode 100644 index 00000000..de32d1c1 --- /dev/null +++ b/pkgs-ts/console/src/status/task/process-output/buffer.ts @@ -0,0 +1,32 @@ +import type { Logger } from '#console/status/task/logger'; +import { parseProcessOutputChunkLines } from '#console/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/pkgs-ts/console/src/status/task/process-output/validators/chunk.ts similarity index 100% rename from packages/tui/src/status/task/process-output/validators/chunk.ts rename to pkgs-ts/console/src/status/task/process-output/validators/chunk.ts diff --git a/pkgs-ts/console/src/status/task/process-output/validators/writer.ts b/pkgs-ts/console/src/status/task/process-output/validators/writer.ts new file mode 100644 index 00000000..22e0f32d --- /dev/null +++ b/pkgs-ts/console/src/status/task/process-output/validators/writer.ts @@ -0,0 +1,6 @@ +export { parseProcessOutputCallback, parseProcessOutputEncoding } from '#console/status/task/process-output/validators/writer/callback'; +export type { ProcessOutputCallback } from '#console/status/task/process-output/validators/writer/callback'; +export { parseProcessOutputChunk, resolveProcessOutputWrite } from '#console/status/task/process-output/validators/writer/chunk'; +export type { ResolvedProcessOutputWrite } from '#console/status/task/process-output/validators/writer/chunk'; +export { parseProcessOutputWrite, parseWritableProcessStream } from '#console/status/task/process-output/validators/writer/stream'; +export type { WritableProcessStream } from '#console/status/task/process-output/validators/writer/stream'; diff --git a/packages/tui/src/status/task/process-output/validators/writer/callback.ts b/pkgs-ts/console/src/status/task/process-output/validators/writer/callback.ts similarity index 93% rename from packages/tui/src/status/task/process-output/validators/writer/callback.ts rename to pkgs-ts/console/src/status/task/process-output/validators/writer/callback.ts index 70868109..b0b1aa49 100644 --- a/packages/tui/src/status/task/process-output/validators/writer/callback.ts +++ b/pkgs-ts/console/src/status/task/process-output/validators/writer/callback.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import { functionSchema } from '#tui/validators/function'; +import { functionSchema } from '#console/validators/function'; export type ProcessOutputCallback = (error?: Error | null) => void; diff --git a/packages/tui/src/status/task/process-output/validators/writer/chunk.ts b/pkgs-ts/console/src/status/task/process-output/validators/writer/chunk.ts similarity index 83% rename from packages/tui/src/status/task/process-output/validators/writer/chunk.ts rename to pkgs-ts/console/src/status/task/process-output/validators/writer/chunk.ts index 5881ec3f..8aff4670 100644 --- a/packages/tui/src/status/task/process-output/validators/writer/chunk.ts +++ b/pkgs-ts/console/src/status/task/process-output/validators/writer/chunk.ts @@ -1,6 +1,6 @@ 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'; +import { parseProcessOutputCallback, parseProcessOutputEncoding } from '#console/status/task/process-output/validators/writer/callback'; +import type { ProcessOutputCallback } from '#console/status/task/process-output/validators/writer/callback'; export type ResolvedProcessOutputWrite = { callback?: ProcessOutputCallback; diff --git a/pkgs-ts/console/src/status/task/process-output/validators/writer/stream.ts b/pkgs-ts/console/src/status/task/process-output/validators/writer/stream.ts new file mode 100644 index 00000000..bcaed463 --- /dev/null +++ b/pkgs-ts/console/src/status/task/process-output/validators/writer/stream.ts @@ -0,0 +1,34 @@ +import { z } from 'zod'; +import { functionSchema } from '#console/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/pkgs-ts/console/src/status/task/process-output/writer.ts b/pkgs-ts/console/src/status/task/process-output/writer.ts new file mode 100644 index 00000000..a8af51ac --- /dev/null +++ b/pkgs-ts/console/src/status/task/process-output/writer.ts @@ -0,0 +1,18 @@ +import { resolveProcessOutputWrite } from '#console/status/task/process-output/validators/writer'; +import type { ProcessOutputBuffer } from '#console/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/pkgs-ts/console/src/status/task/render.ts b/pkgs-ts/console/src/status/task/render.ts new file mode 100644 index 00000000..8641d9dd --- /dev/null +++ b/pkgs-ts/console/src/status/task/render.ts @@ -0,0 +1,64 @@ +import { renderSpinnerFrame } from '#console/status/spinner/render'; +import { parseLogLimit } from '#console/status/validators/limit'; +import type { StableTaskMessage } from '#console/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/pkgs-ts/console/src/status/task/sanitize.ts similarity index 100% rename from packages/tui/src/status/task/sanitize.ts rename to pkgs-ts/console/src/status/task/sanitize.ts diff --git a/pkgs-ts/console/src/status/task/validators/definition.ts b/pkgs-ts/console/src/status/task/validators/definition.ts new file mode 100644 index 00000000..812e14bf --- /dev/null +++ b/pkgs-ts/console/src/status/task/validators/definition.ts @@ -0,0 +1,50 @@ +import { z } from 'zod'; +import { functionSchema } from '#console/validators/function'; +import type { TaskDefinition } from '#console/status/task/definition'; +import type { Logger } from '#console/status/task/logger'; +import type { MaybePromise } from '#console/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/pkgs-ts/console/src/status/validators/limit.ts similarity index 100% rename from packages/tui/src/status/validators/limit.ts rename to pkgs-ts/console/src/status/validators/limit.ts diff --git a/pkgs-ts/console/src/status/validators/progress.ts b/pkgs-ts/console/src/status/validators/progress.ts new file mode 100644 index 00000000..1532f427 --- /dev/null +++ b/pkgs-ts/console/src/status/validators/progress.ts @@ -0,0 +1,40 @@ +import { z } from 'zod'; +import { iterableSchema } from '#console/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/pkgs-ts/console/src/status/validators/signal-target.ts similarity index 91% rename from packages/tui/src/status/validators/signal-target.ts rename to pkgs-ts/console/src/status/validators/signal-target.ts index 781229e9..b0992402 100644 --- a/packages/tui/src/status/validators/signal-target.ts +++ b/pkgs-ts/console/src/status/validators/signal-target.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import { functionSchema } from '#tui/validators/function'; +import { functionSchema } from '#console/validators/function'; export type StatusSignalTarget = { off(signal: string, listener: () => void): unknown; diff --git a/pkgs-ts/console/src/string-utils/ansi.ts b/pkgs-ts/console/src/string-utils/ansi.ts new file mode 100644 index 00000000..68d6b612 --- /dev/null +++ b/pkgs-ts/console/src/string-utils/ansi.ts @@ -0,0 +1,7 @@ +import stripAnsi from 'strip-ansi'; + +export { ansiCloseSequence, ESC } from '#console/string-utils/ansi/codes'; +export { parseAnsiSegments } from '#console/string-utils/ansi/segments'; +export type { AnsiSegment } from '#console/string-utils/ansi/segments'; + +export const parseAnsiText = (value: string): string => stripAnsi(value); diff --git a/packages/tui/src/string-utils/ansi/codes.ts b/pkgs-ts/console/src/string-utils/ansi/codes.ts similarity index 100% rename from packages/tui/src/string-utils/ansi/codes.ts rename to pkgs-ts/console/src/string-utils/ansi/codes.ts diff --git a/packages/tui/src/string-utils/ansi/segments.ts b/pkgs-ts/console/src/string-utils/ansi/segments.ts similarity index 98% rename from packages/tui/src/string-utils/ansi/segments.ts rename to pkgs-ts/console/src/string-utils/ansi/segments.ts index ab7ef3e6..5ae7faf0 100644 --- a/packages/tui/src/string-utils/ansi/segments.ts +++ b/pkgs-ts/console/src/string-utils/ansi/segments.ts @@ -1,4 +1,4 @@ -import { ansiCode, ansiCodeParts, ESC, isAnsiResetCode, isAnsiStyleSequence } from '#tui/string-utils/ansi/codes'; +import { ansiCode, ansiCodeParts, ESC, isAnsiResetCode, isAnsiStyleSequence } from '#console/string-utils/ansi/codes'; export type AnsiSegment = { codes: string; diff --git a/pkgs-ts/console/src/string-utils/truncate.ts b/pkgs-ts/console/src/string-utils/truncate.ts new file mode 100644 index 00000000..54336027 --- /dev/null +++ b/pkgs-ts/console/src/string-utils/truncate.ts @@ -0,0 +1,66 @@ +import { ansiCloseSequence, parseAnsiSegments } from '#console/string-utils/ansi'; +import { parseStringWidth } from '#console/string-utils/validators/width'; +import { visibleWidth } from '#console/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/pkgs-ts/console/src/string-utils/validators/width.ts similarity index 100% rename from packages/tui/src/string-utils/validators/width.ts rename to pkgs-ts/console/src/string-utils/validators/width.ts diff --git a/packages/tui/src/string-utils/width.ts b/pkgs-ts/console/src/string-utils/width.ts similarity index 100% rename from packages/tui/src/string-utils/width.ts rename to pkgs-ts/console/src/string-utils/width.ts diff --git a/pkgs-ts/console/src/string-utils/wrap.ts b/pkgs-ts/console/src/string-utils/wrap.ts new file mode 100644 index 00000000..dcc3755d --- /dev/null +++ b/pkgs-ts/console/src/string-utils/wrap.ts @@ -0,0 +1,7 @@ +import stripAnsi from 'strip-ansi'; +import { restoreAnsiWrappedLines } from '#console/string-utils/wrap/ansi'; +import { plainWrap } from '#console/string-utils/wrap/plain'; + +export const wrap = (value: string, width: number): string[] => { + return restoreAnsiWrappedLines(value, plainWrap(stripAnsi(value), width)); +}; diff --git a/pkgs-ts/console/src/string-utils/wrap/ansi.ts b/pkgs-ts/console/src/string-utils/wrap/ansi.ts new file mode 100644 index 00000000..2437c368 --- /dev/null +++ b/pkgs-ts/console/src/string-utils/wrap/ansi.ts @@ -0,0 +1,57 @@ +import { ansiCloseSequence, parseAnsiSegments } from '#console/string-utils/ansi'; +import type { AnsiSegment } from '#console/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/pkgs-ts/console/src/string-utils/wrap/plain.ts similarity index 91% rename from packages/tui/src/string-utils/wrap/plain.ts rename to pkgs-ts/console/src/string-utils/wrap/plain.ts index 6fbea989..08577c12 100644 --- a/packages/tui/src/string-utils/wrap/plain.ts +++ b/pkgs-ts/console/src/string-utils/wrap/plain.ts @@ -1,5 +1,5 @@ -import { parseStringWidth } from '#tui/string-utils/validators/width'; -import { visibleWidth } from '#tui/string-utils/width'; +import { parseStringWidth } from '#console/string-utils/validators/width'; +import { visibleWidth } from '#console/string-utils/width'; const wrappedWideWord = (word: string, width: number): string[] => { const chunks: string[] = []; diff --git a/pkgs-ts/console/src/strings.ts b/pkgs-ts/console/src/strings.ts new file mode 100644 index 00000000..930bd9cf --- /dev/null +++ b/pkgs-ts/console/src/strings.ts @@ -0,0 +1,5 @@ +export { parseAnsiSegments, parseAnsiText } from '#console/string-utils/ansi'; +export type { AnsiSegment } from '#console/string-utils/ansi'; +export { truncate } from '#console/string-utils/truncate'; +export { visibleWidth } from '#console/string-utils/width'; +export { wrap } from '#console/string-utils/wrap'; diff --git a/pkgs-ts/console/src/terminal.ts b/pkgs-ts/console/src/terminal.ts new file mode 100644 index 00000000..d67a9181 --- /dev/null +++ b/pkgs-ts/console/src/terminal.ts @@ -0,0 +1,61 @@ +import { promptEnvironment } from '#console/environment'; +import { defaultBackgroundColor, defaultForegroundColor, parseTerminalColor, terminalSupportsTrueColor } from '#console/terminal/capabilities'; +import type { TerminalColor } from '#console/terminal/capabilities'; +import { parseTerminalDimension, parseTerminalLineCount } from '#console/terminal/validators/size'; +import { parseTerminalTitle } from '#console/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/pkgs-ts/console/src/terminal/capabilities.ts similarity index 86% rename from packages/tui/src/terminal/capabilities.ts rename to pkgs-ts/console/src/terminal/capabilities.ts index 959a2691..82746513 100644 --- a/packages/tui/src/terminal/capabilities.ts +++ b/pkgs-ts/console/src/terminal/capabilities.ts @@ -1,4 +1,4 @@ -import { parseTerminalColor } from '#tui/terminal/validators/color'; +import { parseTerminalColor } from '#console/terminal/validators/color'; export type TerminalColor = readonly [number, number, number]; diff --git a/packages/tui/src/terminal/validators/color.ts b/pkgs-ts/console/src/terminal/validators/color.ts similarity index 84% rename from packages/tui/src/terminal/validators/color.ts rename to pkgs-ts/console/src/terminal/validators/color.ts index 9434d344..df433c12 100644 --- a/packages/tui/src/terminal/validators/color.ts +++ b/pkgs-ts/console/src/terminal/validators/color.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import type { TerminalColor } from '#tui/terminal/capabilities'; +import type { TerminalColor } from '#console/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)]); diff --git a/packages/tui/src/terminal/validators/size.ts b/pkgs-ts/console/src/terminal/validators/size.ts similarity index 100% rename from packages/tui/src/terminal/validators/size.ts rename to pkgs-ts/console/src/terminal/validators/size.ts diff --git a/packages/tui/src/terminal/validators/title.ts b/pkgs-ts/console/src/terminal/validators/title.ts similarity index 100% rename from packages/tui/src/terminal/validators/title.ts rename to pkgs-ts/console/src/terminal/validators/title.ts diff --git a/pkgs-ts/console/src/theme.ts b/pkgs-ts/console/src/theme.ts new file mode 100644 index 00000000..64d5273a --- /dev/null +++ b/pkgs-ts/console/src/theme.ts @@ -0,0 +1,5 @@ +export { renderChoices } from '#console/theme/choices'; +export { renderError } from '#console/theme/error'; +export { renderQuestion } from '#console/theme/question'; +export { symbols } from '#console/theme/symbols'; +export { renderTable } from '#console/theme/table'; diff --git a/packages/tui/src/theme/box.ts b/pkgs-ts/console/src/theme/box.ts similarity index 89% rename from packages/tui/src/theme/box.ts rename to pkgs-ts/console/src/theme/box.ts index 1f236fde..1c1ed04a 100644 --- a/packages/tui/src/theme/box.ts +++ b/pkgs-ts/console/src/theme/box.ts @@ -1,6 +1,6 @@ -import { truncate, visibleWidth } from '#tui/strings'; -import { terminalSize } from '#tui/terminal'; -import { parseBoxWidth } from '#tui/theme/validators/box'; +import { truncate, visibleWidth } from '#console/strings'; +import { terminalSize } from '#console/terminal'; +import { parseBoxWidth } from '#console/theme/validators/box'; const DEFAULT_BOX_WIDTH = 60; const TERMINAL_PADDING = 6; diff --git a/pkgs-ts/console/src/theme/choice-disabled.ts b/pkgs-ts/console/src/theme/choice-disabled.ts new file mode 100644 index 00000000..ca335913 --- /dev/null +++ b/pkgs-ts/console/src/theme/choice-disabled.ts @@ -0,0 +1,9 @@ +import { parseDisabledReason } from '#console/theme/validators/disabled'; + +export const choiceDisabledSuffix = (disabled: boolean | string | undefined): string => { + if (!disabled) { + return ''; + } + + return ` (${parseDisabledReason(disabled) ?? 'disabled'})`; +}; diff --git a/pkgs-ts/console/src/theme/choices.ts b/pkgs-ts/console/src/theme/choices.ts new file mode 100644 index 00000000..60649439 --- /dev/null +++ b/pkgs-ts/console/src/theme/choices.ts @@ -0,0 +1,13 @@ +import { choiceDisabledSuffix } from '#console/theme/choice-disabled'; +import type { Choice } from '#console/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/pkgs-ts/console/src/theme/error.ts b/pkgs-ts/console/src/theme/error.ts new file mode 100644 index 00000000..13536400 --- /dev/null +++ b/pkgs-ts/console/src/theme/error.ts @@ -0,0 +1,4 @@ +import { red } from '#console/theme/styles'; +import { symbols } from '#console/theme/symbols'; + +export const renderError = (message: string): string => `${red(`${symbols.error} ${message}`)}\n`; diff --git a/packages/tui/src/theme/question.ts b/pkgs-ts/console/src/theme/question.ts similarity index 77% rename from packages/tui/src/theme/question.ts rename to pkgs-ts/console/src/theme/question.ts index e3b96482..4b277bce 100644 --- a/packages/tui/src/theme/question.ts +++ b/pkgs-ts/console/src/theme/question.ts @@ -1,4 +1,4 @@ -import { symbols } from '#tui/theme/symbols'; +import { symbols } from '#console/theme/symbols'; export const renderQuestion = (message: string, hint?: string): string => { const suffix = hint ? ` ${hint}` : ''; diff --git a/packages/tui/src/theme/styles.ts b/pkgs-ts/console/src/theme/styles.ts similarity index 100% rename from packages/tui/src/theme/styles.ts rename to pkgs-ts/console/src/theme/styles.ts diff --git a/packages/tui/src/theme/symbols.ts b/pkgs-ts/console/src/theme/symbols.ts similarity index 100% rename from packages/tui/src/theme/symbols.ts rename to pkgs-ts/console/src/theme/symbols.ts diff --git a/pkgs-ts/console/src/theme/table.ts b/pkgs-ts/console/src/theme/table.ts new file mode 100644 index 00000000..79d0f13f --- /dev/null +++ b/pkgs-ts/console/src/theme/table.ts @@ -0,0 +1,30 @@ +import { visibleWidth } from '#console/strings'; +import { isRenderableTableColumnCount } from '#console/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/pkgs-ts/console/src/theme/validators/box.ts similarity index 100% rename from packages/tui/src/theme/validators/box.ts rename to pkgs-ts/console/src/theme/validators/box.ts diff --git a/packages/tui/src/theme/validators/disabled.ts b/pkgs-ts/console/src/theme/validators/disabled.ts similarity index 100% rename from packages/tui/src/theme/validators/disabled.ts rename to pkgs-ts/console/src/theme/validators/disabled.ts diff --git a/packages/tui/src/theme/validators/table.ts b/pkgs-ts/console/src/theme/validators/table.ts similarity index 100% rename from packages/tui/src/theme/validators/table.ts rename to pkgs-ts/console/src/theme/validators/table.ts diff --git a/pkgs-ts/console/src/typed-value.ts b/pkgs-ts/console/src/typed-value.ts new file mode 100644 index 00000000..3ba35c7b --- /dev/null +++ b/pkgs-ts/console/src/typed-value.ts @@ -0,0 +1,4 @@ +export { applyTypedKey } from '#console/typed-value/edit'; +export { readTypedValue } from '#console/typed-value/read'; +export type { TypedValueReadResult } from '#console/typed-value/read'; +export type { TypedValueOptions, TypedValueState } from '#console/typed-value/types'; diff --git a/packages/tui/src/typed-value/characters.ts b/pkgs-ts/console/src/typed-value/characters.ts similarity index 100% rename from packages/tui/src/typed-value/characters.ts rename to pkgs-ts/console/src/typed-value/characters.ts diff --git a/pkgs-ts/console/src/typed-value/cursor.ts b/pkgs-ts/console/src/typed-value/cursor.ts new file mode 100644 index 00000000..0153e5cf --- /dev/null +++ b/pkgs-ts/console/src/typed-value/cursor.ts @@ -0,0 +1,22 @@ +import { characters, fromCharacters } from '#console/typed-value/characters'; +import { dim, inverse } from '#console/theme/styles'; +import { parseTypedValueCursor } from '#console/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/pkgs-ts/console/src/typed-value/delete.ts similarity index 85% rename from packages/tui/src/typed-value/delete.ts rename to pkgs-ts/console/src/typed-value/delete.ts index 580e5395..1de29d57 100644 --- a/packages/tui/src/typed-value/delete.ts +++ b/pkgs-ts/console/src/typed-value/delete.ts @@ -1,5 +1,5 @@ -import { fromCharacters, stringIndexToCharacterIndex } from '#tui/typed-value/characters'; -import { moveToLineBoundary } from '#tui/typed-value/lines'; +import { fromCharacters, stringIndexToCharacterIndex } from '#console/typed-value/characters'; +import { moveToLineBoundary } from '#console/typed-value/lines'; export const deleteNextCharacter = (value: string[], cursor: number): number => { value.splice(cursor, 1); diff --git a/pkgs-ts/console/src/typed-value/edit.ts b/pkgs-ts/console/src/typed-value/edit.ts new file mode 100644 index 00000000..7d06ef91 --- /dev/null +++ b/pkgs-ts/console/src/typed-value/edit.ts @@ -0,0 +1,60 @@ +import { Key } from '#console/key'; +import { characterLength, characters } from '#console/typed-value/characters'; +import { deleteTypedValueKey } from '#console/typed-value/edit/deletion'; +import { insertPrintableKey } from '#console/typed-value/edit/insertion'; +import { moveTypedValueCursor } from '#console/typed-value/edit/navigation'; +import { typedKeyResult } from '#console/typed-value/edit/result'; +import { parseTypedValueCursor } from '#console/typed-value/validators/cursor'; +import type { AppliedTypedKey, TypedValueState } from '#console/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/pkgs-ts/console/src/typed-value/edit/deletion.ts similarity index 84% rename from packages/tui/src/typed-value/edit/deletion.ts rename to pkgs-ts/console/src/typed-value/edit/deletion.ts index a61492f1..60a55724 100644 --- a/packages/tui/src/typed-value/edit/deletion.ts +++ b/pkgs-ts/console/src/typed-value/edit/deletion.ts @@ -1,5 +1,5 @@ -import { Key } from '#tui/key'; -import { deleteNextCharacter, deletePreviousCharacter, deletePreviousWord, deleteToLineStart } from '#tui/typed-value/delete'; +import { Key } from '#console/key'; +import { deleteNextCharacter, deletePreviousCharacter, deletePreviousWord, deleteToLineStart } from '#console/typed-value/delete'; export const deleteTypedValueKey = (value: string[], cursor: number, key: string, allowNewLine: boolean): number | undefined => { if (key === Key.delete) { diff --git a/packages/tui/src/typed-value/edit/insertion.ts b/pkgs-ts/console/src/typed-value/edit/insertion.ts similarity index 78% rename from packages/tui/src/typed-value/edit/insertion.ts rename to pkgs-ts/console/src/typed-value/edit/insertion.ts index 77df2631..25a3b513 100644 --- a/packages/tui/src/typed-value/edit/insertion.ts +++ b/pkgs-ts/console/src/typed-value/edit/insertion.ts @@ -1,4 +1,4 @@ -import { characters, isPrintable } from '#tui/typed-value/characters'; +import { characters, isPrintable } from '#console/typed-value/characters'; export const insertPrintableKey = (value: string[], cursor: number, key: string): number | undefined => { if (!isPrintable(key)) { diff --git a/pkgs-ts/console/src/typed-value/edit/navigation.ts b/pkgs-ts/console/src/typed-value/edit/navigation.ts new file mode 100644 index 00000000..ab42df42 --- /dev/null +++ b/pkgs-ts/console/src/typed-value/edit/navigation.ts @@ -0,0 +1,30 @@ +import { Key, oneOf } from '#console/key'; +import { moveLine, moveToLineBoundary } from '#console/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/pkgs-ts/console/src/typed-value/edit/result.ts b/pkgs-ts/console/src/typed-value/edit/result.ts new file mode 100644 index 00000000..1b96130e --- /dev/null +++ b/pkgs-ts/console/src/typed-value/edit/result.ts @@ -0,0 +1,14 @@ +import { fromCharacters } from '#console/typed-value/characters'; +import type { AppliedTypedKey } from '#console/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/pkgs-ts/console/src/typed-value/line-ranges.ts similarity index 92% rename from packages/tui/src/typed-value/line-ranges.ts rename to pkgs-ts/console/src/typed-value/line-ranges.ts index ad90ab47..af5786a5 100644 --- a/packages/tui/src/typed-value/line-ranges.ts +++ b/pkgs-ts/console/src/typed-value/line-ranges.ts @@ -1,5 +1,5 @@ -import { visibleWidth } from '#tui/strings'; -import { parseTypedValueWrapWidth } from '#tui/typed-value/validators/width'; +import { visibleWidth } from '#console/strings'; +import { parseTypedValueWrapWidth } from '#console/typed-value/validators/width'; export type LineRange = { end: number; diff --git a/pkgs-ts/console/src/typed-value/lines.ts b/pkgs-ts/console/src/typed-value/lines.ts new file mode 100644 index 00000000..23e8682d --- /dev/null +++ b/pkgs-ts/console/src/typed-value/lines.ts @@ -0,0 +1,42 @@ +import { currentLine, lineRanges } from '#console/typed-value/line-ranges'; +import { visibleLineWindow } from '#console/typed-value/lines/window'; + +export { visibleLineWindow } from '#console/typed-value/lines/window'; +export { visibleTextWindow } from '#console/typed-value/lines/text-window'; +export type { VisibleLineWindow, VisibleTextWindow } from '#console/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/pkgs-ts/console/src/typed-value/lines/text-window.ts similarity index 77% rename from packages/tui/src/typed-value/lines/text-window.ts rename to pkgs-ts/console/src/typed-value/lines/text-window.ts index 94a7be53..80f05552 100644 --- a/packages/tui/src/typed-value/lines/text-window.ts +++ b/pkgs-ts/console/src/typed-value/lines/text-window.ts @@ -1,7 +1,7 @@ -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'; +import { lineRanges } from '#console/typed-value/line-ranges'; +import { visibleLineWindow } from '#console/typed-value/lines/window'; +import { parseTypedValueCursor } from '#console/typed-value/validators/cursor'; +import type { VisibleTextWindow } from '#console/typed-value/lines/types'; export const visibleTextWindow = (value: string, cursor: number, rows: number | undefined, width?: number): VisibleTextWindow => { const valueCharacters = [...value]; diff --git a/packages/tui/src/typed-value/lines/types.ts b/pkgs-ts/console/src/typed-value/lines/types.ts similarity index 100% rename from packages/tui/src/typed-value/lines/types.ts rename to pkgs-ts/console/src/typed-value/lines/types.ts diff --git a/pkgs-ts/console/src/typed-value/lines/window.ts b/pkgs-ts/console/src/typed-value/lines/window.ts new file mode 100644 index 00000000..d87737bf --- /dev/null +++ b/pkgs-ts/console/src/typed-value/lines/window.ts @@ -0,0 +1,30 @@ +import { fromCharacters } from '#console/typed-value/characters'; +import { currentLine, lineRanges } from '#console/typed-value/line-ranges'; +import { parseTypedValueCursor } from '#console/typed-value/validators/cursor'; +import { parseTypedValueRows } from '#console/typed-value/validators/rows'; +import type { VisibleLineWindow } from '#console/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/pkgs-ts/console/src/typed-value/read.ts b/pkgs-ts/console/src/typed-value/read.ts new file mode 100644 index 00000000..4d852939 --- /dev/null +++ b/pkgs-ts/console/src/typed-value/read.ts @@ -0,0 +1,59 @@ +import { promptEnvironment } from '#console/environment'; +import { cancelTypedValueRead } from '#console/typed-value/read/cancel'; +import { readTypedValueFallback } from '#console/typed-value/read/fallback'; +import { eraseRenderedFrame } from '#console/status/frame'; +import { applyTypedKey, initialTypedValueState } from '#console/typed-value/edit'; +import { renderTypedValue } from '#console/typed-value/render'; +import { TEXTAREA_CONTENT_WIDTH } from '#console/typed-value/textarea'; +import type { TypedValueOptions, TypedValueState } from '#console/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/pkgs-ts/console/src/typed-value/read/cancel.ts b/pkgs-ts/console/src/typed-value/read/cancel.ts new file mode 100644 index 00000000..03b45b2c --- /dev/null +++ b/pkgs-ts/console/src/typed-value/read/cancel.ts @@ -0,0 +1,28 @@ +import { cancelPrompt } from '#console/prompt'; +import { eraseRenderedFrame } from '#console/status/frame'; +import { renderCancelledTypedValue } from '#console/typed-value/render'; +import { renderCancelledTextareaFrame } from '#console/typed-value/textarea-frame'; +import type { TypedValueReadResult } from '#console/typed-value/read'; +import type { TypedValueOptions } from '#console/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/pkgs-ts/console/src/typed-value/read/fallback.ts b/pkgs-ts/console/src/typed-value/read/fallback.ts new file mode 100644 index 00000000..6971ef31 --- /dev/null +++ b/pkgs-ts/console/src/typed-value/read/fallback.ts @@ -0,0 +1,18 @@ +import { PromptValidationError } from '#console/prompt'; +import { renderQuestion } from '#console/theme'; +import type { PromptEnvironment } from '#console/contracts/environment'; +import type { TypedValueReadResult } from '#console/typed-value/read'; +import type { TypedValueOptions } from '#console/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/pkgs-ts/console/src/typed-value/render.ts b/pkgs-ts/console/src/typed-value/render.ts new file mode 100644 index 00000000..28502bab --- /dev/null +++ b/pkgs-ts/console/src/typed-value/render.ts @@ -0,0 +1,42 @@ +import { promptEnvironment } from '#console/environment'; +import { renderBox } from '#console/theme/box'; +import { cyan, dim, red, strikethrough } from '#console/theme/styles'; +import { valueWithCursor } from '#console/typed-value/cursor'; +import { visibleTextWindow } from '#console/typed-value/lines'; +import { renderTextareaFrame } from '#console/typed-value/textarea-frame'; +import type { TypedValueOptions, TypedValueState } from '#console/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/pkgs-ts/console/src/typed-value/textarea-frame.ts similarity index 77% rename from packages/tui/src/typed-value/textarea-frame.ts rename to pkgs-ts/console/src/typed-value/textarea-frame.ts index 9b4f063e..24cf3688 100644 --- a/packages/tui/src/typed-value/textarea-frame.ts +++ b/pkgs-ts/console/src/typed-value/textarea-frame.ts @@ -1,12 +1,12 @@ -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'; +import { renderScrollbarRows } from '#console/concerns/scrollbar'; +import { promptEnvironment } from '#console/environment'; +import { renderBox } from '#console/theme/box'; +import { cyan, dim, red, strikethrough } from '#console/theme/styles'; +import { valueWithCursor } from '#console/typed-value/cursor'; +import { visibleTextWindow } from '#console/typed-value/lines'; +import { TEXTAREA_CONTENT_WIDTH } from '#console/typed-value/textarea'; +import { parseTypedValueRows } from '#console/typed-value/validators/rows'; +import type { TypedValueOptions, TypedValueState } from '#console/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) }); diff --git a/packages/tui/src/typed-value/textarea.ts b/pkgs-ts/console/src/typed-value/textarea.ts similarity index 100% rename from packages/tui/src/typed-value/textarea.ts rename to pkgs-ts/console/src/typed-value/textarea.ts diff --git a/packages/tui/src/typed-value/types.ts b/pkgs-ts/console/src/typed-value/types.ts similarity index 100% rename from packages/tui/src/typed-value/types.ts rename to pkgs-ts/console/src/typed-value/types.ts diff --git a/packages/tui/src/typed-value/validators/cursor.ts b/pkgs-ts/console/src/typed-value/validators/cursor.ts similarity index 100% rename from packages/tui/src/typed-value/validators/cursor.ts rename to pkgs-ts/console/src/typed-value/validators/cursor.ts diff --git a/packages/tui/src/typed-value/validators/rows.ts b/pkgs-ts/console/src/typed-value/validators/rows.ts similarity index 100% rename from packages/tui/src/typed-value/validators/rows.ts rename to pkgs-ts/console/src/typed-value/validators/rows.ts diff --git a/packages/tui/src/typed-value/validators/width.ts b/pkgs-ts/console/src/typed-value/validators/width.ts similarity index 100% rename from packages/tui/src/typed-value/validators/width.ts rename to pkgs-ts/console/src/typed-value/validators/width.ts diff --git a/pkgs-ts/console/src/types.ts b/pkgs-ts/console/src/types.ts new file mode 100644 index 00000000..bbdc3c00 --- /dev/null +++ b/pkgs-ts/console/src/types.ts @@ -0,0 +1,16 @@ +export type { MaybePromise, PromptValue, ValidationResult, Validator } from '#console/contracts/base'; +export type { PromptEnvironment, PromptInput, PromptOutput } from '#console/contracts/environment'; +export type { BasePromptOptions } from '#console/contracts/options'; +export type { Choice, ChoiceInput, ChoiceOptions, PromptInfo } from '#console/contracts/choices'; +export type { + ConfirmPromptOptions, + MultiSearchPromptOptions, + MultiSelectPromptOptions, + NumberPromptOptions, + SearchPromptOptions, + SelectPromptOptions, + TextareaPromptOptions, + TextPromptOptions, +} from '#console/contracts/prompts'; +export type { DataTableObjectRow, DataTablePromptOptions, DataTableRow, TableCell, TableOptions } from '#console/contracts/output'; +export type { StatusOptions } from '#console/contracts/status'; diff --git a/packages/tui/src/validators/default.ts b/pkgs-ts/console/src/validators/default.ts similarity index 100% rename from packages/tui/src/validators/default.ts rename to pkgs-ts/console/src/validators/default.ts diff --git a/packages/tui/src/validators/function.ts b/pkgs-ts/console/src/validators/function.ts similarity index 100% rename from packages/tui/src/validators/function.ts rename to pkgs-ts/console/src/validators/function.ts diff --git a/packages/tui/src/validators/iterable.ts b/pkgs-ts/console/src/validators/iterable.ts similarity index 93% rename from packages/tui/src/validators/iterable.ts rename to pkgs-ts/console/src/validators/iterable.ts index 99270705..b72f7969 100644 --- a/packages/tui/src/validators/iterable.ts +++ b/pkgs-ts/console/src/validators/iterable.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import { functionSchema } from '#tui/validators/function'; +import { functionSchema } from '#console/validators/function'; const iteratorMethodSchema = functionSchema<() => Iterator>(); const asyncIteratorMethodSchema = functionSchema<() => AsyncIterator>(); diff --git a/packages/tui/src/validators/required.ts b/pkgs-ts/console/src/validators/required.ts similarity index 100% rename from packages/tui/src/validators/required.ts rename to pkgs-ts/console/src/validators/required.ts diff --git a/pkgs-ts/console/src/validators/result.ts b/pkgs-ts/console/src/validators/result.ts new file mode 100644 index 00000000..e7cb7421 --- /dev/null +++ b/pkgs-ts/console/src/validators/result.ts @@ -0,0 +1,14 @@ +import { z } from 'zod'; +import type { ValidationResult } from '#console/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/pkgs-ts/console/tests/choices.test.ts similarity index 99% rename from packages/tui/tests/choices.test.ts rename to pkgs-ts/console/tests/choices.test.ts index f20c5b4c..d2d28abe 100644 --- a/packages/tui/tests/choices.test.ts +++ b/pkgs-ts/console/tests/choices.test.ts @@ -1,6 +1,6 @@ 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'; +import { confirm, createMemoryOutput, createScriptedInput, Key, multiselect, parseAnsiText, PromptValidationError, select, withPromptEnvironment } from '#console/index'; +import type { SelectPromptOptions } from '#console/index'; describe('choice prompts', () => { it('confirms with direct y and n keys', async () => { diff --git a/packages/tui/tests/environment.test.ts b/pkgs-ts/console/tests/environment.test.ts similarity index 96% rename from packages/tui/tests/environment.test.ts rename to pkgs-ts/console/tests/environment.test.ts index 060aac5b..2678c0bd 100644 --- a/packages/tui/tests/environment.test.ts +++ b/pkgs-ts/console/tests/environment.test.ts @@ -1,8 +1,8 @@ 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'; +import { configurePrompts, createMemoryOutput, createScriptedInput, promptEnvironment, withPromptEnvironment } from '#console/environment'; +import { readRawKey } from '#console/environment/raw-key'; +import { Key } from '#console/key'; class FakeRawInput extends EventEmitter { isRaw = false; diff --git a/packages/tui/tests/form.test.ts b/pkgs-ts/console/tests/form.test.ts similarity index 99% rename from packages/tui/tests/form.test.ts rename to pkgs-ts/console/tests/form.test.ts index a81db692..3528c93c 100644 --- a/packages/tui/tests/form.test.ts +++ b/pkgs-ts/console/tests/form.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { confirm, createMemoryOutput, createScriptedInput, form, Key, outro, Stream, text, withPromptEnvironment } from '#tui/index'; +import { confirm, createMemoryOutput, createScriptedInput, form, Key, outro, Stream, text, withPromptEnvironment } from '#console/index'; describe('form builder', () => { it('runs chained steps and returns positional responses', async () => { diff --git a/packages/tui/tests/key.test.ts b/pkgs-ts/console/tests/key.test.ts similarity index 96% rename from packages/tui/tests/key.test.ts rename to pkgs-ts/console/tests/key.test.ts index c8fbebd4..1e877d0d 100644 --- a/packages/tui/tests/key.test.ts +++ b/pkgs-ts/console/tests/key.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { Key, keyFromEvent, oneOf } from '#tui/index'; +import { Key, keyFromEvent, oneOf } from '#console/index'; describe('keyboard mapping', () => { it('maps named keys', () => { diff --git a/packages/tui/tests/notify.test.ts b/pkgs-ts/console/tests/notify.test.ts similarity index 97% rename from packages/tui/tests/notify.test.ts rename to pkgs-ts/console/tests/notify.test.ts index 77580184..f0b3fde0 100644 --- a/packages/tui/tests/notify.test.ts +++ b/pkgs-ts/console/tests/notify.test.ts @@ -1,6 +1,6 @@ import { describe, expect, expectTypeOf, it } from 'vitest'; -import { createMemoryOutput, notificationCommand, notificationCommands, notify, notifyForPlatform, withPromptEnvironment } from '#tui/index'; -import type { NotificationCommand } from '#tui/index'; +import { createMemoryOutput, notificationCommand, notificationCommands, notify, notifyForPlatform, withPromptEnvironment } from '#console/index'; +import type { NotificationCommand } from '#console/index'; describe('notify helper', () => { it('builds macOS notification commands', () => { diff --git a/packages/tui/tests/number.test.ts b/pkgs-ts/console/tests/number.test.ts similarity index 98% rename from packages/tui/tests/number.test.ts rename to pkgs-ts/console/tests/number.test.ts index 07006d32..5ff28ea7 100644 --- a/packages/tui/tests/number.test.ts +++ b/pkgs-ts/console/tests/number.test.ts @@ -1,6 +1,6 @@ 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'; +import { createMemoryOutput, createScriptedInput, Key, number, parseAnsiText, PromptValidationError, withPromptEnvironment } from '#console/index'; +import { parseNumberInput } from '#console/prompts/number/validators/value'; describe('number prompt', () => { it('rejects partial decimal input and accepts a retry', async () => { diff --git a/packages/tui/tests/output.test.ts b/pkgs-ts/console/tests/output.test.ts similarity index 98% rename from packages/tui/tests/output.test.ts rename to pkgs-ts/console/tests/output.test.ts index 32d4c91e..5ddd6495 100644 --- a/packages/tui/tests/output.test.ts +++ b/pkgs-ts/console/tests/output.test.ts @@ -1,6 +1,6 @@ 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'; +import { alert, createMemoryOutput, error, grid, info, intro, note, outro, parseAnsiText, table, warning, withPromptEnvironment } from '#console/index'; +import { renderGrid } from '#console/output/grid'; describe('output helpers', () => { it('renders note-style helpers without returning a value', async () => { diff --git a/packages/tui/tests/password.test.ts b/pkgs-ts/console/tests/password.test.ts similarity index 98% rename from packages/tui/tests/password.test.ts rename to pkgs-ts/console/tests/password.test.ts index e668349e..710762f6 100644 --- a/packages/tui/tests/password.test.ts +++ b/pkgs-ts/console/tests/password.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { createMemoryOutput, createScriptedInput, Key, password, PromptValidationError, withPromptEnvironment } from '#tui/index'; +import { createMemoryOutput, createScriptedInput, Key, password, PromptValidationError, withPromptEnvironment } from '#console/index'; describe('password prompt', () => { it('returns typed values without rendering the raw password', async () => { diff --git a/packages/tui/tests/pause.test.ts b/pkgs-ts/console/tests/pause.test.ts similarity index 97% rename from packages/tui/tests/pause.test.ts rename to pkgs-ts/console/tests/pause.test.ts index ed25ca1f..adfa33d3 100644 --- a/packages/tui/tests/pause.test.ts +++ b/pkgs-ts/console/tests/pause.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { createMemoryOutput, createScriptedInput, Key, pause, withPromptEnvironment } from '#tui/index'; +import { createMemoryOutput, createScriptedInput, Key, pause, withPromptEnvironment } from '#console/index'; describe('pause prompt', () => { it('waits for enter from key-driven input', async () => { diff --git a/packages/tui/tests/progress.test.ts b/pkgs-ts/console/tests/progress.test.ts similarity index 99% rename from packages/tui/tests/progress.test.ts rename to pkgs-ts/console/tests/progress.test.ts index 1d115824..95169174 100644 --- a/packages/tui/tests/progress.test.ts +++ b/pkgs-ts/console/tests/progress.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { EventEmitter } from 'node:events'; -import { createMemoryOutput, Progress, progress, withPromptEnvironment } from '#tui/index'; +import { createMemoryOutput, Progress, progress, withPromptEnvironment } from '#console/index'; describe('progress helper', () => { it('maps iterable steps and returns callback results', async () => { diff --git a/packages/tui/tests/prompt.test.ts b/pkgs-ts/console/tests/prompt.test.ts similarity index 92% rename from packages/tui/tests/prompt.test.ts rename to pkgs-ts/console/tests/prompt.test.ts index f949ba1f..a88a9375 100644 --- a/packages/tui/tests/prompt.test.ts +++ b/pkgs-ts/console/tests/prompt.test.ts @@ -1,9 +1,9 @@ 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'; +import { createMemoryOutput, withPromptEnvironment } from '#console/environment'; +import { createScriptedInput } from '#console/environment/scripted-input'; +import { Key } from '#console/key'; +import { ask, cancelUsing, ensureRequired, promptUntilValid, PromptValidationError, validationMessage } from '#console/prompt'; +import { text } from '#console/prompts/basic'; describe('prompt validation', () => { afterEach(() => { diff --git a/packages/tui/tests/search.test.ts b/pkgs-ts/console/tests/search.test.ts similarity index 99% rename from packages/tui/tests/search.test.ts rename to pkgs-ts/console/tests/search.test.ts index f30e7772..3564cbbe 100644 --- a/packages/tui/tests/search.test.ts +++ b/pkgs-ts/console/tests/search.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { createMemoryOutput, createScriptedInput, Key, multisearch, parseAnsiText, PromptValidationError, search, withPromptEnvironment } from '#tui/index'; -import type { SearchPromptOptions } from '#tui/index'; +import { createMemoryOutput, createScriptedInput, Key, multisearch, parseAnsiText, PromptValidationError, search, withPromptEnvironment } from '#console/index'; +import type { SearchPromptOptions } from '#console/index'; const colors = (value: string): Record => { const options = { diff --git a/packages/tui/tests/spinner.test.ts b/pkgs-ts/console/tests/spinner.test.ts similarity index 96% rename from packages/tui/tests/spinner.test.ts rename to pkgs-ts/console/tests/spinner.test.ts index 42547afe..5cf0c0dd 100644 --- a/packages/tui/tests/spinner.test.ts +++ b/pkgs-ts/console/tests/spinner.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { createMemoryOutput, spin, withPromptEnvironment } from '#tui/index'; -import { renderSpinnerFrame, spinnerFrame } from '#tui/status/spinner/render'; +import { createMemoryOutput, spin, withPromptEnvironment } from '#console/index'; +import { renderSpinnerFrame, spinnerFrame } from '#console/status/spinner/render'; describe('spinner helper', () => { it('runs callback-first spinner callbacks', async () => { diff --git a/packages/tui/tests/stream.test.ts b/pkgs-ts/console/tests/stream.test.ts similarity index 96% rename from packages/tui/tests/stream.test.ts rename to pkgs-ts/console/tests/stream.test.ts index 3c99f3c9..0823ada8 100644 --- a/packages/tui/tests/stream.test.ts +++ b/pkgs-ts/console/tests/stream.test.ts @@ -1,7 +1,7 @@ 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'; +import { createMemoryOutput, parseAnsiText, stream, withPromptEnvironment } from '#console/index'; +import { streamFadeStyles } from '#console/status/stream/fade'; +import { renderStreamFrame } from '#console/status/stream/render'; describe('stream helper', () => { it('appends streamed content and returns the accumulated value', async () => { diff --git a/packages/tui/tests/strings.test.ts b/pkgs-ts/console/tests/strings.test.ts similarity index 98% rename from packages/tui/tests/strings.test.ts rename to pkgs-ts/console/tests/strings.test.ts index e7e6f3c2..04f54dac 100644 --- a/packages/tui/tests/strings.test.ts +++ b/pkgs-ts/console/tests/strings.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { parseAnsiSegments, parseAnsiText, truncate, visibleWidth, wrap } from '#tui/index'; +import { parseAnsiSegments, parseAnsiText, truncate, visibleWidth, wrap } from '#console/index'; describe('string utilities', () => { it('measures visible width without ANSI control sequences', () => { diff --git a/packages/tui/tests/suggest.test.ts b/pkgs-ts/console/tests/suggest.test.ts similarity index 99% rename from packages/tui/tests/suggest.test.ts rename to pkgs-ts/console/tests/suggest.test.ts index aeead0c1..c26888ea 100644 --- a/packages/tui/tests/suggest.test.ts +++ b/pkgs-ts/console/tests/suggest.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { autocomplete, createMemoryOutput, createScriptedInput, Key, parseAnsiText, suggest, withPromptEnvironment } from '#tui/index'; +import { autocomplete, createMemoryOutput, createScriptedInput, Key, parseAnsiText, suggest, withPromptEnvironment } from '#console/index'; const runSuggest = async (keys: string[], options: string[] | ((query: string) => string[])): Promise<{ output: string; result: string }> => { const output = createMemoryOutput(); diff --git a/packages/tui/tests/table.test.ts b/pkgs-ts/console/tests/table.test.ts similarity index 99% rename from packages/tui/tests/table.test.ts rename to pkgs-ts/console/tests/table.test.ts index ccc29b5e..9b4565df 100644 --- a/packages/tui/tests/table.test.ts +++ b/pkgs-ts/console/tests/table.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { createMemoryOutput, createScriptedInput, datatable, form, Key, parseAnsiText, table, visibleWidth, withPromptEnvironment } from '#tui/index'; +import { createMemoryOutput, createScriptedInput, datatable, form, Key, parseAnsiText, table, visibleWidth, withPromptEnvironment } from '#console/index'; describe('table output', () => { it('renders static table rows', async () => { diff --git a/packages/tui/tests/task.test.ts b/pkgs-ts/console/tests/task.test.ts similarity index 98% rename from packages/tui/tests/task.test.ts rename to pkgs-ts/console/tests/task.test.ts index 0587a674..62c27a1b 100644 --- a/packages/tui/tests/task.test.ts +++ b/pkgs-ts/console/tests/task.test.ts @@ -1,7 +1,7 @@ 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'; +import { createMemoryOutput, task, withPromptEnvironment } from '#console/index'; +import { eraseRenderedFrame, renderedFrameLineCount } from '#console/status/frame'; +import { renderTaskFrame } from '#console/status/task/render'; describe('task helper', () => { it('passes a bounded logger to the callback and returns the callback value', async () => { diff --git a/packages/tui/tests/terminal.test.ts b/pkgs-ts/console/tests/terminal.test.ts similarity index 98% rename from packages/tui/tests/terminal.test.ts rename to pkgs-ts/console/tests/terminal.test.ts index 072fedfe..0cfbea7b 100644 --- a/packages/tui/tests/terminal.test.ts +++ b/pkgs-ts/console/tests/terminal.test.ts @@ -15,7 +15,7 @@ import { supportsTrueColor, title, withPromptEnvironment, -} from '#tui/index'; +} from '#console/index'; describe('terminal helpers', () => { it('writes clear terminal control sequences', async () => { diff --git a/packages/tui/tests/text.test.ts b/pkgs-ts/console/tests/text.test.ts similarity index 99% rename from packages/tui/tests/text.test.ts rename to pkgs-ts/console/tests/text.test.ts index b64b2867..2a3c5e38 100644 --- a/packages/tui/tests/text.test.ts +++ b/pkgs-ts/console/tests/text.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { createMemoryOutput, createScriptedInput, Key, parseAnsiText, PromptValidationError, text, withPromptEnvironment } from '#tui/index'; +import { createMemoryOutput, createScriptedInput, Key, parseAnsiText, PromptValidationError, text, withPromptEnvironment } from '#console/index'; describe('text prompt', () => { it('renders typed values while reading raw key input', async () => { diff --git a/packages/tui/tests/textarea.test.ts b/pkgs-ts/console/tests/textarea.test.ts similarity index 99% rename from packages/tui/tests/textarea.test.ts rename to pkgs-ts/console/tests/textarea.test.ts index 694ce9f2..36585451 100644 --- a/packages/tui/tests/textarea.test.ts +++ b/pkgs-ts/console/tests/textarea.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { createMemoryOutput, createScriptedInput, Key, parseAnsiText, PromptValidationError, textarea, withPromptEnvironment } from '#tui/index'; +import { createMemoryOutput, createScriptedInput, Key, parseAnsiText, PromptValidationError, textarea, withPromptEnvironment } from '#console/index'; describe('textarea prompt', () => { it('accepts multiline input and submits with ctrl-d', async () => { diff --git a/packages/tui/tests/typed-value.test.ts b/pkgs-ts/console/tests/typed-value.test.ts similarity index 97% rename from packages/tui/tests/typed-value.test.ts rename to pkgs-ts/console/tests/typed-value.test.ts index 98604562..b3b83804 100644 --- a/packages/tui/tests/typed-value.test.ts +++ b/pkgs-ts/console/tests/typed-value.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { applyTypedKey, Key } from '#tui/index'; -import { visibleLineWindow } from '#tui/typed-value/lines'; +import { applyTypedKey, Key } from '#console/index'; +import { visibleLineWindow } from '#console/typed-value/lines'; const apply = (keys: string[]): string => { let state = { cursor: 0, value: '' }; diff --git a/packages/tui/tsconfig.build.json b/pkgs-ts/console/tsconfig.build.json similarity index 83% rename from packages/tui/tsconfig.build.json rename to pkgs-ts/console/tsconfig.build.json index 4acf1e5e..ba6939bf 100644 --- a/packages/tui/tsconfig.build.json +++ b/pkgs-ts/console/tsconfig.build.json @@ -8,7 +8,7 @@ "outDir": "dist", "rootDir": "src", "sourceMap": true, - "tsBuildInfoFile": "../../provision/.cache/tsbuild/tui-build.tsbuildinfo" + "tsBuildInfoFile": "../../infra/.cache/tsbuild/console-build.tsbuildinfo" }, "exclude": ["dist", "tests", "vitest.config.ts"], "include": ["src/**/*.ts"] diff --git a/pkgs-ts/console/tsconfig.json b/pkgs-ts/console/tsconfig.json new file mode 100644 index 00000000..adb2e61a --- /dev/null +++ b/pkgs-ts/console/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": ["@ollin/console-infra/tsconfig/base.json", "@ollin/console-infra/tsconfig/console.json"], + "compilerOptions": { + "baseUrl": ".", + "paths": { + "#console/*": ["./src/*"], + "#console/prompts/*": ["./src/prompts/*"] + } + }, + "include": ["src/**/*.ts", "vitest.config.ts"] +} diff --git a/pkgs-ts/console/vitest.config.ts b/pkgs-ts/console/vitest.config.ts new file mode 100644 index 00000000..934154cd --- /dev/null +++ b/pkgs-ts/console/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, 'infra', '.cache', 'vitest', 'console'); +const sourcePath = join(packagePath, 'src', '$1'); + +export default defineConfig({ + cacheDir: cachePath, + resolve: { + alias: [ + { + find: /^#console\/(.+)$/u, + replacement: sourcePath, + }, + ], + }, + test: { + environment: 'node', + globals: false, + }, +}); diff --git a/packages/acceptance/tests/package.test.ts b/pkgs-ts/tests/console/distribution/package.test.ts similarity index 82% rename from packages/acceptance/tests/package.test.ts rename to pkgs-ts/tests/console/distribution/package.test.ts index 53d40884..5ebb2ff9 100644 --- a/packages/acceptance/tests/package.test.ts +++ b/pkgs-ts/tests/console/distribution/package.test.ts @@ -4,24 +4,26 @@ 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'); +const testDirectoryPath = dirname(fileURLToPath(import.meta.url)); // .../tests/console/distribution +const suitePath = dirname(testDirectoryPath); // .../tests/console +const testsPackagePath = dirname(suitePath); // .../tests (consumer package) +const pkgsTsPath = dirname(testsPackagePath); // .../pkgs-ts +const consolePackagePath = join(pkgsTsPath, 'console'); // .../pkgs-ts/console (@ollin/console) +const workspacePath = dirname(pkgsTsPath); // .../tui (pnpm workspace root) +const consumerCachePath = join(workspacePath, 'infra', '.cache', 'vitest', 'tests'); describe('package consumption', () => { it('imports the built public entrypoint through ESM package resolution', () => { - execFileSync('pnpm', ['--filter', '@ollin/tui', 'build'], { + execFileSync('pnpm', ['--filter', '@ollin/console', '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(','));"], + ['--input-type=module', '-e', "const module = await import('@ollin/console'); console.log([typeof module.text, typeof module.table, typeof module.stream].join(','));"], { - cwd: acceptancePath, + cwd: testsPackagePath, encoding: 'utf8', }, ); @@ -30,7 +32,7 @@ describe('package consumption', () => { }, 30_000); it('exports the complete runtime helper surface from the root entrypoint', () => { - execFileSync('pnpm', ['--filter', '@ollin/tui', 'build'], { + execFileSync('pnpm', ['--filter', '@ollin/console', 'build'], { cwd: workspacePath, stdio: 'pipe', }); @@ -116,14 +118,14 @@ describe('package consumption', () => { '--input-type=module', '-e', ` - const module = await import('@ollin/tui'); + const module = await import('@ollin/console'); 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, + cwd: testsPackagePath, encoding: 'utf8', }, ); @@ -132,12 +134,12 @@ describe('package consumption', () => { }, 30_000); it('exports public helper types from the built root entrypoint', () => { - execFileSync('pnpm', ['--filter', '@ollin/tui', 'build'], { + execFileSync('pnpm', ['--filter', '@ollin/console', 'build'], { cwd: workspacePath, stdio: 'pipe', }); - const typecheckCachePath = join(acceptanceCachePath, 'types'); + const typecheckCachePath = join(consumerCachePath, 'types'); mkdirSync(typecheckCachePath, { recursive: true }); @@ -147,7 +149,7 @@ describe('package consumption', () => { try { mkdirSync(packageScopeDirectory, { recursive: true }); - symlinkSync(join(packagesPath, 'tui'), join(packageScopeDirectory, 'tui'), 'dir'); + symlinkSync(consolePackagePath, join(packageScopeDirectory, 'console'), 'dir'); writeFileSync( consumerPath, ` @@ -170,7 +172,7 @@ describe('package consumption', () => { TypedValueOptions, TypedValueReadResult, TypedValueState - } from '@ollin/tui'; + } from '@ollin/console'; const choices: ChoiceOptions = ['one', { label: 'Two', value: 'two' }]; const confirmOptions: ConfirmPromptOptions = { message: 'Continue?', default: true }; @@ -214,7 +216,7 @@ describe('package consumption', () => { try { execFileSync('pnpm', ['exec', 'tsc', '--ignoreConfig', '--module', 'NodeNext', '--moduleResolution', 'NodeNext', '--target', 'ES2022', '--strict', '--noEmit', consumerPath], { - cwd: acceptancePath, + cwd: testsPackagePath, stdio: 'pipe', }); } catch (error) { diff --git a/pkgs-ts/tests/console/forms/form-builder.test.ts b/pkgs-ts/tests/console/forms/form-builder.test.ts new file mode 100644 index 00000000..6f17383c --- /dev/null +++ b/pkgs-ts/tests/console/forms/form-builder.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest'; + +import { form } from '@ollin/console'; + +import { scripted } from '../support/scripted'; + +describe('form builders', () => { + 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); + }); +}); diff --git a/pkgs-ts/tests/console/output/messages.test.ts b/pkgs-ts/tests/console/output/messages.test.ts new file mode 100644 index 00000000..7dc3f98c --- /dev/null +++ b/pkgs-ts/tests/console/output/messages.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; + +import { createMemoryOutput, error, info, intro, note, outro, table, title, withPromptEnvironment } from '@ollin/console'; + +describe('output helpers', () => { + 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'); + }); +}); diff --git a/pkgs-ts/tests/console/output/status.test.ts b/pkgs-ts/tests/console/output/status.test.ts new file mode 100644 index 00000000..d48f5aa7 --- /dev/null +++ b/pkgs-ts/tests/console/output/status.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; + +import { createMemoryOutput, progress, spin, stream, withPromptEnvironment } from '@ollin/console'; + +describe('status helpers', () => { + 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/pkgs-ts/tests/console/prompts/prompts.test.ts b/pkgs-ts/tests/console/prompts/prompts.test.ts new file mode 100644 index 00000000..59ed2627 --- /dev/null +++ b/pkgs-ts/tests/console/prompts/prompts.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; + +import { confirm, multiselect, number, select, text } from '@ollin/console'; + +import { scripted } from '../support/scripted'; + +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'] }); + }); +}); diff --git a/packages/acceptance/tests/opentui.test.ts b/pkgs-ts/tests/console/rendering/opentui.test.ts similarity index 95% rename from packages/acceptance/tests/opentui.test.ts rename to pkgs-ts/tests/console/rendering/opentui.test.ts index 8dd293f1..46538459 100644 --- a/packages/acceptance/tests/opentui.test.ts +++ b/pkgs-ts/tests/console/rendering/opentui.test.ts @@ -1,7 +1,7 @@ import { Text } from '@opentui/core'; import { createTestRenderer } from '@opentui/core/testing'; import { describe, expect, it } from 'vitest'; -import { renderOpenTuiTextFrame } from '@ollin/tui'; +import { renderOpenTuiTextFrame } from '@ollin/console'; describe('OpenTUI memory renderer', () => { it('captures rendered text without writing to the real terminal', async () => { diff --git a/pkgs-ts/tests/console/support/scripted.ts b/pkgs-ts/tests/console/support/scripted.ts new file mode 100644 index 00000000..8afd5c92 --- /dev/null +++ b/pkgs-ts/tests/console/support/scripted.ts @@ -0,0 +1,17 @@ +import { createMemoryOutput, createScriptedInput, withPromptEnvironment } from '@ollin/console'; + +export 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 }; +}; diff --git a/pkgs-ts/tests/package.json b/pkgs-ts/tests/package.json new file mode 100644 index 00000000..f2c16589 --- /dev/null +++ b/pkgs-ts/tests/package.json @@ -0,0 +1,20 @@ +{ + "name": "@ollin/pkgs-ts-tests", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "test": "XDG_CACHE_HOME=../../infra/.cache/xdg PLAYWRIGHT_BROWSERS_PATH=../../infra/.cache/playwright vitest run --config vitest.config.ts", + "typecheck": "XDG_CACHE_HOME=../../infra/.cache/xdg PLAYWRIGHT_BROWSERS_PATH=../../infra/.cache/playwright tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@ollin/console": "workspace:*", + "@opentui/core": "^0.4.0" + }, + "devDependencies": { + "@ollin/console-infra": "workspace:*", + "@types/node": "^25.6.2", + "typescript": "^6.0.3", + "vitest": "^4.1.5" + } +} diff --git a/pkgs-ts/tests/tsconfig.json b/pkgs-ts/tests/tsconfig.json new file mode 100644 index 00000000..5e439ab0 --- /dev/null +++ b/pkgs-ts/tests/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": ["@ollin/console-infra/tsconfig/base.json", "@ollin/console-infra/tsconfig/tests.json"], + "compilerOptions": { + "baseUrl": "..", + "paths": { + "@ollin/console": ["console/src/index.ts"], + "#console/*": ["console/src/*"], + "#console/prompts/*": ["console/src/prompts/*"] + } + }, + "include": ["**/*.ts", "../console/src/**/*.ts"] +} diff --git a/pkgs-ts/tests/vitest.config.ts b/pkgs-ts/tests/vitest.config.ts new file mode 100644 index 00000000..a98d767a --- /dev/null +++ b/pkgs-ts/tests/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, 'infra', '.cache', 'vitest', 'tests'); +const sourcePath = join(packagesPath, 'console', 'src', 'index.ts'); +const internalSourcePath = join(packagesPath, 'console', 'src', '$1'); + +export default defineConfig({ + cacheDir: cachePath, + resolve: { + alias: [ + { + find: '@ollin/console', + replacement: sourcePath, + }, + { + find: /^#console\/(.+)$/u, + replacement: internalSourcePath, + }, + ], + }, + test: { + environment: 'node', + globals: false, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 93bbd017..bc304c89 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,12 +8,9 @@ importers: .: devDependencies: - '@ollin/artefacts': + '@ollin/console-infra': specifier: workspace:* - version: link:packages/artefacts - '@ollin/tsconfig': - specifier: workspace:* - version: link:packages/tsconfig + version: link:infra '@types/node': specifier: ^25.6.2 version: 25.9.2 @@ -27,44 +24,41 @@ importers: 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: + apps/docs: dependencies: - '@ollin/tui': + '@ollin/console': 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': + version: link:../../pkgs-ts/console + '@ollin/vitepress': specifier: workspace:* - version: link:../artefacts - '@ollin/tsconfig': + version: link:../vitepress + devDependencies: + '@ollin/console-infra': specifier: workspace:* - version: link:../tsconfig + version: link:../../infra '@types/node': specifier: ^25.6.2 version: 25.9.2 + 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)) - 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: + apps/vitepress: dependencies: '@fontsource/geist-mono': specifier: ^5.2.8 @@ -72,9 +66,6 @@ importers: '@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)) @@ -85,9 +76,9 @@ importers: specifier: ^2.9.10 version: 2.9.10(vue@3.5.38(typescript@6.0.3)) devDependencies: - '@ollin/tsconfig': + '@ollin/console-infra': specifier: workspace:* - version: link:../tsconfig + version: link:../../infra '@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)) @@ -106,31 +97,28 @@ importers: 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: {} + infra: + devDependencies: + '@types/node': + specifier: ^25.6.2 + version: 25.9.2 + typescript: + specifier: ^6.0.3 + version: 6.0.3 - packages/tui: + pkgs-go: {} + + pkgs-ts/console: dependencies: '@opentui/core': specifier: ^0.4.0 @@ -145,12 +133,9 @@ importers: specifier: ^4.4.3 version: 4.4.3 devDependencies: - '@ollin/artefacts': - specifier: workspace:* - version: link:../artefacts - '@ollin/tsconfig': + '@ollin/console-infra': specifier: workspace:* - version: link:../tsconfig + version: link:../../infra '@types/node': specifier: ^25.6.2 version: 25.9.2 @@ -161,13 +146,27 @@ importers: 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: + pkgs-ts/tests: + dependencies: + '@ollin/console': + specifier: workspace:* + version: link:../console + '@opentui/core': + specifier: ^0.4.0 + version: 0.4.0(typescript@6.0.3)(web-tree-sitter@0.25.10) devDependencies: - '@ollin/tsconfig': + '@ollin/console-infra': specifier: workspace:* - version: link:../../packages/tsconfig + version: link:../../infra + '@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: @@ -1024,27 +1023,15 @@ packages: '@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==} @@ -1057,37 +1044,20 @@ packages: '@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==} @@ -1784,14 +1754,6 @@ packages: '@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: @@ -2506,10 +2468,10 @@ snapshots: '@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))': + '@vitejs/plugin-vue@5.2.4(vite@5.4.21(@types/node@25.9.2)(lightningcss@1.32.0))(vue@3.5.38(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) + vue: 3.5.38(typescript@6.0.3) '@vitest/expect@4.1.8': dependencies: @@ -2552,14 +2514,6 @@ snapshots: 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 @@ -2568,28 +2522,11 @@ snapshots: 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 @@ -2602,11 +2539,6 @@ snapshots: 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 @@ -2630,31 +2562,15 @@ snapshots: 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 @@ -2662,20 +2578,12 @@ snapshots: '@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)': @@ -2683,7 +2591,7 @@ snapshots: '@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) + vue: 3.5.38(typescript@6.0.3) transitivePeerDependencies: - typescript @@ -2698,7 +2606,7 @@ snapshots: 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) + vue: 3.5.38(typescript@6.0.3) optionalDependencies: focus-trap: 7.8.0 transitivePeerDependencies: @@ -2710,7 +2618,7 @@ snapshots: '@vueuse/shared@12.8.2(typescript@6.0.3)': dependencies: - vue: 3.5.37(typescript@6.0.3) + vue: 3.5.38(typescript@6.0.3) transitivePeerDependencies: - typescript @@ -3272,9 +3180,9 @@ snapshots: '@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)) + '@vitejs/plugin-vue': 5.2.4(vite@5.4.21(@types/node@25.9.2)(lightningcss@1.32.0))(vue@3.5.38(typescript@6.0.3)) '@vue/devtools-api': 7.7.9 - '@vue/shared': 3.5.37 + '@vue/shared': 3.5.38 '@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 @@ -3282,7 +3190,7 @@ snapshots: 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) + vue: 3.5.38(typescript@6.0.3) optionalDependencies: postcss: 8.5.15 transitivePeerDependencies: @@ -3343,16 +3251,6 @@ snapshots: 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 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index a7606e20..f4e426ac 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,7 +1,8 @@ packages: - - "packages/*" - - "provision/go" - - "provision/ts" + - "pkgs-ts/*" + - "apps/*" + - "pkgs-go" + - "infra" allowBuilds: esbuild: true diff --git a/provision/.gitignore b/provision/.gitignore deleted file mode 100644 index ceddaa37..00000000 --- a/provision/.gitignore +++ /dev/null @@ -1 +0,0 @@ -.cache/ diff --git a/provision/README.md b/provision/README.md deleted file mode 100644 index 49210c24..00000000 --- a/provision/README.md +++ /dev/null @@ -1,24 +0,0 @@ -# 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 deleted file mode 100644 index 3515c4b9..00000000 --- a/provision/go/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -*.test -coverage.out diff --git a/provision/go/exports.go b/provision/go/exports.go deleted file mode 100644 index 545c15bf..00000000 --- a/provision/go/exports.go +++ /dev/null @@ -1,178 +0,0 @@ -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 deleted file mode 100644 index 6ade9760..00000000 --- a/provision/go/exports_test.go +++ /dev/null @@ -1,50 +0,0 @@ -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 deleted file mode 100644 index 40c58976..00000000 --- a/provision/go/go.mod +++ /dev/null @@ -1,23 +0,0 @@ -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 deleted file mode 100644 index 47f62dab..00000000 --- a/provision/go/go.sum +++ /dev/null @@ -1,33 +0,0 @@ -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 deleted file mode 100644 index 195fadb7..00000000 --- a/provision/go/internal/environment/environment.go +++ /dev/null @@ -1,96 +0,0 @@ -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 deleted file mode 100644 index ea30ef9f..00000000 --- a/provision/go/internal/environment/environment_test.go +++ /dev/null @@ -1,121 +0,0 @@ -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 deleted file mode 100644 index c0312899..00000000 --- a/provision/go/internal/environment/errors.go +++ /dev/null @@ -1,29 +0,0 @@ -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 deleted file mode 100644 index f12a2c89..00000000 --- a/provision/go/internal/environment/memory_output.go +++ /dev/null @@ -1,33 +0,0 @@ -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 deleted file mode 100644 index 0451faed..00000000 --- a/provision/go/internal/environment/scope.go +++ /dev/null @@ -1,80 +0,0 @@ -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 deleted file mode 100644 index 76a2b37a..00000000 --- a/provision/go/internal/environment/scripted_input.go +++ /dev/null @@ -1,34 +0,0 @@ -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 deleted file mode 100644 index bc23b4c6..00000000 --- a/provision/go/internal/environment/stdio.go +++ /dev/null @@ -1,89 +0,0 @@ -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 deleted file mode 100644 index 15a007b4..00000000 --- a/provision/go/internal/environment/stdio_test.go +++ /dev/null @@ -1,43 +0,0 @@ -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 deleted file mode 100644 index a2e58169..00000000 --- a/provision/go/internal/form/builder.go +++ /dev/null @@ -1,148 +0,0 @@ -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 deleted file mode 100644 index 2fa96e86..00000000 --- a/provision/go/internal/form/form_test.go +++ /dev/null @@ -1,214 +0,0 @@ -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 deleted file mode 100644 index d3cf86b5..00000000 --- a/provision/go/internal/form/output.go +++ /dev/null @@ -1,87 +0,0 @@ -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 deleted file mode 100644 index a448fea0..00000000 --- a/provision/go/internal/form/previous.go +++ /dev/null @@ -1,55 +0,0 @@ -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 deleted file mode 100644 index 3dfc719e..00000000 --- a/provision/go/internal/form/prompts.go +++ /dev/null @@ -1,111 +0,0 @@ -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 deleted file mode 100644 index dbb4c73f..00000000 --- a/provision/go/internal/form/responses.go +++ /dev/null @@ -1,70 +0,0 @@ -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 deleted file mode 100644 index 7a21ab4c..00000000 --- a/provision/go/internal/form/revert.go +++ /dev/null @@ -1,58 +0,0 @@ -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 deleted file mode 100644 index 38c6d7bb..00000000 --- a/provision/go/internal/form/status.go +++ /dev/null @@ -1,30 +0,0 @@ -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 deleted file mode 100644 index 775aca55..00000000 --- a/provision/go/internal/key/key.go +++ /dev/null @@ -1,175 +0,0 @@ -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 deleted file mode 100644 index 713ec8a3..00000000 --- a/provision/go/internal/key/key_test.go +++ /dev/null @@ -1,61 +0,0 @@ -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 deleted file mode 100644 index 0369d3a4..00000000 --- a/provision/go/internal/output/datatable.go +++ /dev/null @@ -1,300 +0,0 @@ -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 deleted file mode 100644 index df75486e..00000000 --- a/provision/go/internal/output/notes.go +++ /dev/null @@ -1,96 +0,0 @@ -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 deleted file mode 100644 index 600c4819..00000000 --- a/provision/go/internal/output/notify.go +++ /dev/null @@ -1,90 +0,0 @@ -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 deleted file mode 100644 index 53f3aca4..00000000 --- a/provision/go/internal/output/output_test.go +++ /dev/null @@ -1,178 +0,0 @@ -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 deleted file mode 100644 index 2089ccf3..00000000 --- a/provision/go/internal/output/table.go +++ /dev/null @@ -1,152 +0,0 @@ -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 deleted file mode 100644 index ad42c6bd..00000000 --- a/provision/go/internal/output/terminal.go +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 73c6c215..00000000 --- a/provision/go/internal/prompt/lifecycle.go +++ /dev/null @@ -1,71 +0,0 @@ -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 deleted file mode 100644 index ecdb1cc7..00000000 --- a/provision/go/internal/prompt/validation.go +++ /dev/null @@ -1,90 +0,0 @@ -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 deleted file mode 100644 index 536f5a5a..00000000 --- a/provision/go/internal/prompts/basic_test.go +++ /dev/null @@ -1,260 +0,0 @@ -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 deleted file mode 100644 index cb0a7faf..00000000 --- a/provision/go/internal/prompts/choices.go +++ /dev/null @@ -1,689 +0,0 @@ -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 deleted file mode 100644 index 362569a5..00000000 --- a/provision/go/internal/prompts/choices_test.go +++ /dev/null @@ -1,249 +0,0 @@ -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 deleted file mode 100644 index 3ec2c5a3..00000000 --- a/provision/go/internal/prompts/number.go +++ /dev/null @@ -1,230 +0,0 @@ -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 deleted file mode 100644 index 3ace43de..00000000 --- a/provision/go/internal/prompts/render.go +++ /dev/null @@ -1,218 +0,0 @@ -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 deleted file mode 100644 index ea591b65..00000000 --- a/provision/go/internal/prompts/search_suggest.go +++ /dev/null @@ -1,728 +0,0 @@ -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 deleted file mode 100644 index 95cc43ee..00000000 --- a/provision/go/internal/prompts/text.go +++ /dev/null @@ -1,252 +0,0 @@ -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 deleted file mode 100644 index 91c96708..00000000 --- a/provision/go/internal/status/frame.go +++ /dev/null @@ -1,21 +0,0 @@ -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 deleted file mode 100644 index 8f1a6991..00000000 --- a/provision/go/internal/status/progress.go +++ /dev/null @@ -1,154 +0,0 @@ -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 deleted file mode 100644 index b59de372..00000000 --- a/provision/go/internal/status/spinner.go +++ /dev/null @@ -1,52 +0,0 @@ -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 deleted file mode 100644 index 40fc718b..00000000 --- a/provision/go/internal/status/status_test.go +++ /dev/null @@ -1,194 +0,0 @@ -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 deleted file mode 100644 index 90960933..00000000 --- a/provision/go/internal/status/stream.go +++ /dev/null @@ -1,133 +0,0 @@ -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 deleted file mode 100644 index 0bd753db..00000000 --- a/provision/go/internal/status/task.go +++ /dev/null @@ -1,205 +0,0 @@ -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 deleted file mode 100644 index 4d1bcbb2..00000000 --- a/provision/go/internal/strings/strings.go +++ /dev/null @@ -1,300 +0,0 @@ -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 deleted file mode 100644 index 920c086f..00000000 --- a/provision/go/internal/strings/strings_test.go +++ /dev/null @@ -1,97 +0,0 @@ -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 deleted file mode 100644 index b446c297..00000000 --- a/provision/go/internal/terminal/terminal.go +++ /dev/null @@ -1,93 +0,0 @@ -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 deleted file mode 100644 index c6ccf2a5..00000000 --- a/provision/go/internal/terminal/terminal_test.go +++ /dev/null @@ -1,39 +0,0 @@ -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 deleted file mode 100644 index f7245da2..00000000 --- a/provision/go/internal/theme/box.go +++ /dev/null @@ -1,79 +0,0 @@ -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 deleted file mode 100644 index f573253f..00000000 --- a/provision/go/internal/theme/styles.go +++ /dev/null @@ -1,63 +0,0 @@ -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 deleted file mode 100644 index d424d367..00000000 --- a/provision/go/internal/theme/theme.go +++ /dev/null @@ -1,121 +0,0 @@ -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 deleted file mode 100644 index 6b7c89a8..00000000 --- a/provision/go/internal/theme/theme_test.go +++ /dev/null @@ -1,47 +0,0 @@ -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 deleted file mode 100644 index 37a58aa9..00000000 --- a/provision/go/internal/typedvalue/lines.go +++ /dev/null @@ -1,168 +0,0 @@ -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 deleted file mode 100644 index 9b602a6b..00000000 --- a/provision/go/internal/typedvalue/typedvalue.go +++ /dev/null @@ -1,247 +0,0 @@ -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 deleted file mode 100644 index 0b71ea42..00000000 --- a/provision/go/internal/typedvalue/typedvalue_test.go +++ /dev/null @@ -1,139 +0,0 @@ -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 deleted file mode 100644 index a12205f2..00000000 --- a/provision/go/package.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "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 deleted file mode 100644 index 9fc40811..00000000 --- a/provision/go/portal/config.go +++ /dev/null @@ -1,157 +0,0 @@ -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 deleted file mode 100644 index 78c6f1a3..00000000 --- a/provision/go/portal/main.go +++ /dev/null @@ -1,89 +0,0 @@ -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 deleted file mode 100644 index 9baadaa4..00000000 --- a/provision/go/portal/main_test.go +++ /dev/null @@ -1,104 +0,0 @@ -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 deleted file mode 100644 index c1231910..00000000 --- a/provision/ts/package.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "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 deleted file mode 100644 index 1b9c151f..00000000 --- a/provision/ts/src/cache.ts +++ /dev/null @@ -1,26 +0,0 @@ -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 deleted file mode 100644 index c32d8a30..00000000 --- a/provision/ts/src/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 3d1f5420..00000000 --- a/provision/ts/tsconfig.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "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 index 7744ae79..ac4e37ae 100644 --- a/turbo.json +++ b/turbo.json @@ -3,7 +3,7 @@ "tasks": { "build": { "dependsOn": ["^build"], - "outputs": ["dist/**", "src/.vitepress/dist/**", "src/api/**", "$TURBO_ROOT$/provision/.cache/bin/**"] + "outputs": ["dist/**", "src/.vitepress/dist/**", "src/api/**"] }, "test": { "dependsOn": ["^test"],