From 0078621df6834bc4dfe8ad3649a0eac859031e6c Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 13:28:00 +0800 Subject: [PATCH 001/675] Scaffold ollin tui prompts port --- .github/workflows/ci.yml | 34 + .gitignore | 7 + LICENSE.md | 23 + README.md | 20 + UPSTREAM.md | 54 + package.json | 25 + packages/acceptance/package.json | 21 + packages/acceptance/tests/helpers.test.ts | 103 ++ packages/acceptance/tests/opentui.test.ts | 29 + packages/acceptance/tsconfig.json | 7 + packages/acceptance/vitest.config.ts | 8 + packages/artefacts/package.json | 21 + packages/artefacts/src/index.ts | 12 + packages/artefacts/tsconfig.json | 11 + packages/artefacts/tsconfig/acceptance.json | 5 + packages/artefacts/tsconfig/tui.json | 5 + packages/tsconfig/base.json | 18 + packages/tsconfig/package.json | 9 + packages/tui/package.json | 33 + packages/tui/src/environment.ts | 77 ++ packages/tui/src/form.ts | 43 + packages/tui/src/index.ts | 31 + packages/tui/src/key.ts | 40 + packages/tui/src/opentui.ts | 5 + packages/tui/src/output.ts | 43 + packages/tui/src/prompt.ts | 67 ++ packages/tui/src/prompts/basic.ts | 56 + packages/tui/src/prompts/choices.ts | 135 +++ packages/tui/src/status.ts | 60 + packages/tui/src/strings.ts | 78 ++ packages/tui/src/terminal.ts | 27 + packages/tui/src/theme.ts | 47 + packages/tui/src/types.ts | 75 ++ packages/tui/tests/key.test.ts | 13 + packages/tui/tests/strings.test.ts | 20 + packages/tui/tsconfig.json | 11 + packages/tui/vitest.config.ts | 8 + pnpm-lock.yaml | 1091 +++++++++++++++++++ pnpm-workspace.yaml | 6 + turbo.json | 14 + 40 files changed, 2392 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 LICENSE.md create mode 100644 README.md create mode 100644 UPSTREAM.md create mode 100644 package.json create mode 100644 packages/acceptance/package.json create mode 100644 packages/acceptance/tests/helpers.test.ts create mode 100644 packages/acceptance/tests/opentui.test.ts create mode 100644 packages/acceptance/tsconfig.json create mode 100644 packages/acceptance/vitest.config.ts create mode 100644 packages/artefacts/package.json create mode 100644 packages/artefacts/src/index.ts create mode 100644 packages/artefacts/tsconfig.json create mode 100644 packages/artefacts/tsconfig/acceptance.json create mode 100644 packages/artefacts/tsconfig/tui.json create mode 100644 packages/tsconfig/base.json create mode 100644 packages/tsconfig/package.json create mode 100644 packages/tui/package.json create mode 100644 packages/tui/src/environment.ts create mode 100644 packages/tui/src/form.ts create mode 100644 packages/tui/src/index.ts create mode 100644 packages/tui/src/key.ts create mode 100644 packages/tui/src/opentui.ts create mode 100644 packages/tui/src/output.ts create mode 100644 packages/tui/src/prompt.ts create mode 100644 packages/tui/src/prompts/basic.ts create mode 100644 packages/tui/src/prompts/choices.ts create mode 100644 packages/tui/src/status.ts create mode 100644 packages/tui/src/strings.ts create mode 100644 packages/tui/src/terminal.ts create mode 100644 packages/tui/src/theme.ts create mode 100644 packages/tui/src/types.ts create mode 100644 packages/tui/tests/key.test.ts create mode 100644 packages/tui/tests/strings.test.ts create mode 100644 packages/tui/tsconfig.json create mode 100644 packages/tui/vitest.config.ts create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 turbo.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..18897ac2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,34 @@ +name: Typecheck and test + +on: + pull_request: + types: + - ready_for_review + +jobs: + validate: + name: Validate + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 11.1.1 + + - name: Setup Node + uses: actions/setup-node@v5 + with: + node-version: 25 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Typecheck + run: pnpm typecheck + + - name: Test + run: pnpm test diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..081e9e6d --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +.turbo/ +.DS_Store +coverage/ +packages/artefacts/.cache/ +packages/artefacts/.logs/ +packages/artefacts/dist/ diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 00000000..58d40187 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,23 @@ +MIT License + +Copyright (c) 2026 Oullin + +Portions of this package are derived from Laravel Prompts, Copyright (c) Taylor Otwell. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 00000000..31cf6907 --- /dev/null +++ b/README.md @@ -0,0 +1,20 @@ +# `@ollin/tui` + +An idiomatic TypeScript/OpenTUI port of `laravel/prompts`. + +This repository targets behavioral parity with `laravel/prompts@v0.3.18` while +using TypeScript-first modules and package boundaries. + +## Status + +Phase 1 contains the monorepo scaffold, public helper surface, shared prompt +environment, terminal utilities, baseline theme rendering, and local parity +tests. Full upstream feature-test parity is tracked in `UPSTREAM.md`. + +## Development + +```sh +pnpm install +pnpm typecheck +pnpm test +``` diff --git a/UPSTREAM.md b/UPSTREAM.md new file mode 100644 index 00000000..a809e8b7 --- /dev/null +++ b/UPSTREAM.md @@ -0,0 +1,54 @@ +# Upstream Parity Map + +Target upstream: `laravel/prompts@v0.3.18` + +Tree: `a19af51bb144bf87f08397921fa619f85c7d4e72` + +This port keeps Laravel Prompts behavior as the source of truth while using +idiomatic TypeScript and OpenTUI internals. + +## Implemented In Phase 1 + +| Upstream area | TypeScript area | +| --- | --- | +| `src/helpers.php` | `packages/tui/src/index.ts` | +| Prompt lifecycle and fallback behavior | `packages/tui/src/prompt.ts`, `packages/tui/src/environment.ts` | +| Key constants | `packages/tui/src/key.ts` | +| Terminal helpers | `packages/tui/src/terminal.ts` | +| Text, textarea, number, password | `packages/tui/src/prompts/basic.ts` | +| Confirm, select, multiselect, suggest, search, multisearch, autocomplete | `packages/tui/src/prompts/choices.ts` | +| Form builder | `packages/tui/src/form.ts` | +| Note, notify, table, grid, title, clear | `packages/tui/src/output.ts` | +| Spinner, progress, task, stream | `packages/tui/src/status.ts` | +| ANSI, wrapping, truncation, Unicode width | `packages/tui/src/strings.ts` | +| OpenTUI testing adapter | `packages/tui/src/opentui.ts` | + +## Remaining Exact-Parity Work + +The following upstream files still need full line-by-line behavioral mapping in +later phases: + +- `src/AutoCompletePrompt.php` +- `src/DataTablePrompt.php` +- `src/FormBuilder.php` +- `src/MultiSearchPrompt.php` +- `src/MultiSelectPrompt.php` +- `src/NotifyPrompt.php` +- `src/Progress.php` +- `src/Prompt.php` +- `src/SearchPrompt.php` +- `src/Spinner.php` +- `src/Stream.php` +- `src/Task.php` +- `src/Terminal.php` +- `src/TextareaPrompt.php` +- `src/Themes/Default/*` +- `tests/Feature/*` + +## Documented Deviations + +- Symfony Console output classes are represented by a TypeScript `PromptEnvironment`. +- Composer runtime checks are not applicable to an npm package. +- PHP validator integration is represented by typed synchronous or asynchronous + validator callbacks. +- `pcntl` spinner behavior is represented by TypeScript timers. diff --git a/package.json b/package.json new file mode 100644 index 00000000..58b980d7 --- /dev/null +++ b/package.json @@ -0,0 +1,25 @@ +{ + "name": "ollin-tui", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "test": "turbo test", + "typecheck": "turbo typecheck" + }, + "devDependencies": { + "@ollin/artefacts": "workspace:*", + "@ollin/tsconfig": "workspace:*", + "@types/node": "^25.6.2", + "turbo": "^2.9.12", + "typescript": "^6.0.3", + "vitest": "^4.1.5" + }, + "packageManager": "pnpm@11.1.1", + "pnpm": { + "onlyBuiltDependencies": [ + "@opentui/core", + "esbuild" + ] + } +} diff --git a/packages/acceptance/package.json b/packages/acceptance/package.json new file mode 100644 index 00000000..fe784147 --- /dev/null +++ b/packages/acceptance/package.json @@ -0,0 +1,21 @@ +{ + "name": "@ollin/acceptance", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "test": "vitest run --config vitest.config.ts", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@ollin/tui": "workspace:*", + "@opentui/core": "^0.4.0" + }, + "devDependencies": { + "@ollin/artefacts": "workspace:*", + "@ollin/tsconfig": "workspace:*", + "@types/node": "^25.6.2", + "typescript": "^6.0.3", + "vitest": "^4.1.5" + } +} diff --git a/packages/acceptance/tests/helpers.test.ts b/packages/acceptance/tests/helpers.test.ts new file mode 100644 index 00000000..dc41245f --- /dev/null +++ b/packages/acceptance/tests/helpers.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from 'vitest'; +import { + confirm, + createMemoryOutput, + createScriptedInput, + form, + multiselect, + note, + 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('Laravel-style helpers', () => { + it('reads text and number prompts', async () => { + await expect(scripted(['Taylor'], () => text('Name'))).resolves.toMatchObject({ result: 'Taylor' }); + await expect(scripted(['42'], () => 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: ['Laravel', '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(['Ada', 'y'], () => + form(async (builder) => ({ + name: await builder.text('Name'), + active: await builder.confirm('Active') + })) + ); + + expect(response.result).toEqual({ name: 'Ada', active: true }); + }); + + it('writes output helpers', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + note('Hello', 'Greeting'); + table({ headers: ['Name'], rows: [['Laravel']] }); + title('Demo'); + }); + + expect(output.text()).toContain('Greeting'); + 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.finish(); + await stream(['a', 'b']); + }); + + expect(output.text()).toContain('Working'); + expect(output.text()).toContain('Files: 2/2'); + expect(output.text()).toContain('ab'); + }); +}); diff --git a/packages/acceptance/tests/opentui.test.ts b/packages/acceptance/tests/opentui.test.ts new file mode 100644 index 00000000..63cce8a3 --- /dev/null +++ b/packages/acceptance/tests/opentui.test.ts @@ -0,0 +1,29 @@ +import { Text } from '@opentui/core'; +import { createTestRenderer } from '@opentui/core/testing'; +import { describe, expect, it } from 'vitest'; + +describe('OpenTUI memory renderer', () => { + it('captures rendered text without writing to the real terminal', async () => { + let testRenderer: Awaited>; + + try { + testRenderer = await createTestRenderer({ width: 20, height: 4 }); + } catch (error) { + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain('native FFI is not available'); + + return; + } + + const { renderer, renderOnce, captureCharFrame } = testRenderer; + + try { + renderer.root.add(Text({ content: 'Prompt' })); + await renderOnce(); + + expect(captureCharFrame()).toContain('Prompt'); + } finally { + renderer.destroy(); + } + }); +}); diff --git a/packages/acceptance/tsconfig.json b/packages/acceptance/tsconfig.json new file mode 100644 index 00000000..a24c7799 --- /dev/null +++ b/packages/acceptance/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": ["@ollin/tsconfig/base.json", "@ollin/artefacts/tsconfig/acceptance.json"], + "compilerOptions": { + "baseUrl": "." + }, + "include": ["tests/**/*.ts", "vitest.config.ts"] +} diff --git a/packages/acceptance/vitest.config.ts b/packages/acceptance/vitest.config.ts new file mode 100644 index 00000000..262b8b24 --- /dev/null +++ b/packages/acceptance/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + globals: false + } +}); diff --git a/packages/artefacts/package.json b/packages/artefacts/package.json new file mode 100644 index 00000000..d44bb416 --- /dev/null +++ b/packages/artefacts/package.json @@ -0,0 +1,21 @@ +{ + "name": "@ollin/artefacts", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./tsconfig/*.json": "./tsconfig/*.json" + }, + "scripts": { + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "devDependencies": { + "@ollin/tsconfig": "workspace:*", + "@types/node": "^25.6.2", + "typescript": "^6.0.3" + } +} diff --git a/packages/artefacts/src/index.ts b/packages/artefacts/src/index.ts new file mode 100644 index 00000000..479bc05a --- /dev/null +++ b/packages/artefacts/src/index.ts @@ -0,0 +1,12 @@ +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const artefactsRoot = resolve(fileURLToPath(new URL('..', import.meta.url))); + +export const artefactsPath = (...segments: string[]): string => resolve(artefactsRoot, ...segments); + +export const cacheDir = (...segments: string[]): string => artefactsPath('.cache', ...segments); + +export const distDir = (...segments: string[]): string => artefactsPath('dist', ...segments); + +export const logDir = (...segments: string[]): string => artefactsPath('.logs', ...segments); diff --git a/packages/artefacts/tsconfig.json b/packages/artefacts/tsconfig.json new file mode 100644 index 00000000..1d7ebf18 --- /dev/null +++ b/packages/artefacts/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "@ollin/tsconfig/base.json", + "compilerOptions": { + "baseUrl": ".", + "tsBuildInfoFile": ".cache/tsbuild/artefacts.tsbuildinfo", + "paths": { + "#artefacts/*": ["./src/*"] + } + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/artefacts/tsconfig/acceptance.json b/packages/artefacts/tsconfig/acceptance.json new file mode 100644 index 00000000..fe189ce5 --- /dev/null +++ b/packages/artefacts/tsconfig/acceptance.json @@ -0,0 +1,5 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "../.cache/tsbuild/acceptance.tsbuildinfo" + } +} diff --git a/packages/artefacts/tsconfig/tui.json b/packages/artefacts/tsconfig/tui.json new file mode 100644 index 00000000..70bc18e1 --- /dev/null +++ b/packages/artefacts/tsconfig/tui.json @@ -0,0 +1,5 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "../.cache/tsbuild/tui.tsbuildinfo" + } +} diff --git a/packages/tsconfig/base.json b/packages/tsconfig/base.json new file mode 100644 index 00000000..1590d292 --- /dev/null +++ b/packages/tsconfig/base.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "composite": true, + "target": "ES2022", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "moduleResolution": "Bundler", + "moduleDetection": "force", + "isolatedModules": true, + "skipLibCheck": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "types": ["node"], + "ignoreDeprecations": "6.0" + } +} diff --git a/packages/tsconfig/package.json b/packages/tsconfig/package.json new file mode 100644 index 00000000..d224a6e4 --- /dev/null +++ b/packages/tsconfig/package.json @@ -0,0 +1,9 @@ +{ + "name": "@ollin/tsconfig", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": { + "./base.json": "./base.json" + } +} diff --git a/packages/tui/package.json b/packages/tui/package.json new file mode 100644 index 00000000..b9e440cb --- /dev/null +++ b/packages/tui/package.json @@ -0,0 +1,33 @@ +{ + "name": "@ollin/tui", + "version": "0.1.0", + "private": true, + "type": "module", + "license": "MIT", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + } + }, + "imports": { + "#tui/*": "./src/*.ts", + "#tui/prompts/*": "./src/prompts/*.ts" + }, + "scripts": { + "test": "vitest run --config vitest.config.ts", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@opentui/core": "^0.4.0", + "string-width": "^7.2.0", + "strip-ansi": "^7.1.2" + }, + "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/environment.ts b/packages/tui/src/environment.ts new file mode 100644 index 00000000..63ccbada --- /dev/null +++ b/packages/tui/src/environment.ts @@ -0,0 +1,77 @@ +import { createInterface } from 'node:readline/promises'; +import { stdin as defaultStdin, stdout as defaultStdout, stderr as defaultStderr } from 'node:process'; +import type { PromptEnvironment, PromptInput, PromptOutput } from '#tui/types'; + +const outputFromStream = (stream: NodeJS.WritableStream): PromptOutput => ({ + write(content: string): void { + stream.write(content); + } +}); + +const defaultInput: PromptInput = { + async readLine(message: string): Promise { + const readline = createInterface({ + input: defaultStdin, + output: defaultStdout + }); + + try { + return await readline.question(message); + } finally { + readline.close(); + } + } +}; + +let currentEnvironment: PromptEnvironment = { + input: defaultInput, + output: outputFromStream(defaultStdout), + error: outputFromStream(defaultStderr), + interactive: Boolean(defaultStdin.isTTY) +}; + +export const promptEnvironment = (): PromptEnvironment => currentEnvironment; + +export const configurePrompts = (environment: Partial): void => { + currentEnvironment = { + ...currentEnvironment, + ...environment + }; +}; + +export const withPromptEnvironment = async ( + environment: Partial, + callback: () => Promise +): Promise => { + const previous = currentEnvironment; + configurePrompts(environment); + + try { + return await callback(); + } finally { + currentEnvironment = previous; + } +}; + +export const createMemoryOutput = (): PromptOutput & { text(): string } => { + let buffer = ''; + + return { + write(content: string): void { + buffer += content; + }, + text(): string { + return buffer; + } + }; +}; + +export const createScriptedInput = (lines: string[]): PromptInput => { + const queued = [...lines]; + + return { + async readLine(): Promise { + return queued.shift() ?? ''; + } + }; +}; diff --git a/packages/tui/src/form.ts b/packages/tui/src/form.ts new file mode 100644 index 00000000..56085871 --- /dev/null +++ b/packages/tui/src/form.ts @@ -0,0 +1,43 @@ +import { confirm, multiselect, select } from '#tui/prompts/choices'; +import { number, password, text, textarea } from '#tui/prompts/basic'; +import type { + ConfirmPromptOptions, + MultiSelectPromptOptions, + NumberPromptOptions, + SelectPromptOptions, + TextPromptOptions +} from '#tui/types'; + +export class FormBuilder { + async text(message: string | TextPromptOptions): Promise { + return text(message); + } + + async textarea(message: string | TextPromptOptions): Promise { + return textarea(message); + } + + async password(message: string | TextPromptOptions): Promise { + return password(message); + } + + async number(message: string | NumberPromptOptions): Promise { + return number(message); + } + + async confirm(message: string | ConfirmPromptOptions): Promise { + return confirm(message); + } + + async select(options: SelectPromptOptions): Promise { + return select(options); + } + + async multiselect(options: MultiSelectPromptOptions): Promise { + return multiselect(options); + } +} + +export const form = async (builder: (form: FormBuilder) => Promise | T): Promise => { + return builder(new FormBuilder()); +}; diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts new file mode 100644 index 00000000..f8a727cd --- /dev/null +++ b/packages/tui/src/index.ts @@ -0,0 +1,31 @@ +export { configurePrompts, createMemoryOutput, createScriptedInput, promptEnvironment, withPromptEnvironment } from '#tui/environment'; +export { FormBuilder, form } from '#tui/form'; +export { Key, keyFromEvent } from '#tui/key'; +export { createOpenTuiTextFrame } from '#tui/opentui'; +export { confirm, select, multiselect, suggest, search, multisearch, autocomplete, pause } from '#tui/prompts/choices'; +export { number, password, text, textarea } from '#tui/prompts/basic'; +export { clear, dataTable, grid, note, notify, table, title } from '#tui/output'; +export { PromptValidationError } from '#tui/prompt'; +export { Progress, progress, spin, stream, task } from '#tui/status'; +export { clearTerminal, cursorToStart, eraseLine, setTerminalTitle, terminalSize } from '#tui/terminal'; +export { parseAnsiText, truncate, visibleWidth, wrap } from '#tui/strings'; +export type { + BasePromptOptions, + Choice, + ChoiceInput, + ConfirmPromptOptions, + MaybePromise, + MultiSelectPromptOptions, + NumberPromptOptions, + PromptEnvironment, + PromptInput, + PromptOutput, + PromptValue, + SearchPromptOptions, + SelectPromptOptions, + StatusOptions, + TableOptions, + TextPromptOptions, + ValidationResult, + Validator +} from '#tui/types'; diff --git a/packages/tui/src/key.ts b/packages/tui/src/key.ts new file mode 100644 index 00000000..e25a37dd --- /dev/null +++ b/packages/tui/src/key.ts @@ -0,0 +1,40 @@ +export const Key = { + backspace: 'backspace', + delete: 'delete', + down: 'down', + end: 'end', + enter: 'enter', + escape: 'escape', + home: 'home', + left: 'left', + right: 'right', + space: 'space', + tab: 'tab', + up: 'up' +} as const; + +export type KeyName = (typeof Key)[keyof typeof Key]; + +export type KeyboardEventLike = { + name?: string; + sequence?: string; + ctrl?: boolean; + meta?: boolean; + shift?: boolean; +}; + +export const keyFromEvent = (event: KeyboardEventLike): KeyName | string => { + if (event.name && event.name in Key) { + return Key[event.name as keyof typeof Key]; + } + + if (event.sequence === ' ') { + return Key.space; + } + + if (event.sequence === '\r' || event.sequence === '\n') { + return Key.enter; + } + + return event.name ?? event.sequence ?? ''; +}; diff --git a/packages/tui/src/opentui.ts b/packages/tui/src/opentui.ts new file mode 100644 index 00000000..6ec6d0d0 --- /dev/null +++ b/packages/tui/src/opentui.ts @@ -0,0 +1,5 @@ +export type OpenTuiFrame = { + text: string; +}; + +export const createOpenTuiTextFrame = (text: string): OpenTuiFrame => ({ text }); diff --git a/packages/tui/src/output.ts b/packages/tui/src/output.ts new file mode 100644 index 00000000..124e4be4 --- /dev/null +++ b/packages/tui/src/output.ts @@ -0,0 +1,43 @@ +import { promptEnvironment } from '#tui/environment'; +import { clearTerminal, setTerminalTitle } from '#tui/terminal'; +import { renderTable } from '#tui/theme'; +import type { TableOptions } from '#tui/types'; + +const stringify = (value: string | number | boolean | null | undefined): string => { + return value === null || value === undefined ? '' : String(value); +}; + +export const note = (message: string, title?: string): void => { + const heading = title ? `${title}\n` : ''; + + promptEnvironment().output.write(`${heading}${message}\n`); +}; + +export const notify = (message: string, title = 'Notification'): void => { + note(message, title); +}; + +export const table = (options: TableOptions): void => { + const headers = options.headers ?? Object.keys(options.rows[0] ?? {}); + const rows = options.rows.map((row) => { + if (Array.isArray(row)) { + return row.map(stringify); + } + + return headers.map((header) => stringify(row[header])); + }); + + promptEnvironment().output.write(`${renderTable(headers, rows)}\n`); +}; + +export const grid = table; + +export const dataTable = table; + +export const title = (value: string): void => { + setTerminalTitle(value); +}; + +export const clear = (): void => { + clearTerminal(); +}; diff --git a/packages/tui/src/prompt.ts b/packages/tui/src/prompt.ts new file mode 100644 index 00000000..71bbde3d --- /dev/null +++ b/packages/tui/src/prompt.ts @@ -0,0 +1,67 @@ +import { promptEnvironment } from '#tui/environment'; +import { renderError, renderQuestion } from '#tui/theme'; +import type { BasePromptOptions, Validator } from '#tui/types'; + +export class PromptValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'PromptValidationError'; + } +} + +export const validationMessage = async (value: T, validator?: Validator): Promise => { + const result = await validator?.(value); + + if (typeof result === 'string') { + return result; + } + + if (result === false) { + return 'The given value is invalid.'; + } + + return undefined; +}; + +export const ensureRequired = (value: T, required?: boolean | string): string | undefined => { + const empty = value === undefined || value === null || value === '' || (Array.isArray(value) && value.length === 0); + + if (!empty || !required) { + return undefined; + } + + return typeof required === 'string' ? required : 'A value is required.'; +}; + +export const promptUntilValid = async ( + options: BasePromptOptions, + read: () => Promise +): Promise => { + const environment = promptEnvironment(); + + if (!environment.interactive && options.default !== undefined) { + return options.default; + } + + while (true) { + const value = await read(); + const required = ensureRequired(value, options.required); + const validation = required ?? (await validationMessage(value, options.validate)); + + if (!validation) { + return value; + } + + if (!environment.interactive) { + throw new PromptValidationError(validation); + } + + environment.error.write(renderError(validation)); + } +}; + +export const ask = async (message: string, hint?: string): Promise => { + const environment = promptEnvironment(); + + return environment.input.readLine(renderQuestion(message, hint)); +}; diff --git a/packages/tui/src/prompts/basic.ts b/packages/tui/src/prompts/basic.ts new file mode 100644 index 00000000..25193b09 --- /dev/null +++ b/packages/tui/src/prompts/basic.ts @@ -0,0 +1,56 @@ +import { ask, promptUntilValid, PromptValidationError } from '#tui/prompt'; +import type { NumberPromptOptions, TextPromptOptions } from '#tui/types'; + +const textOptions = (message: string | TextPromptOptions, fallback = ''): TextPromptOptions => { + if (typeof message === 'string') { + return { message, default: fallback }; + } + + return message; +}; + +export const text = async (message: string | TextPromptOptions): Promise => { + const options = textOptions(message); + + return promptUntilValid(options, async () => { + const answer = await ask(options.message, options.hint); + + return answer === '' && options.default !== undefined ? options.default : answer; + }); +}; + +export const textarea = async (message: string | TextPromptOptions): Promise => { + return text(textOptions(message)); +}; + +export const password = async (message: string | TextPromptOptions): Promise => { + return text(textOptions(message)); +}; + +export const number = async (message: string | NumberPromptOptions): Promise => { + const options: NumberPromptOptions = typeof message === 'string' ? { message } : message; + + return promptUntilValid(options, async () => { + const answer = await ask(options.message, options.hint); + + if (answer === '' && options.default !== undefined) { + return options.default; + } + + const parsed = options.integer ? Number.parseInt(answer, 10) : Number.parseFloat(answer); + + if (Number.isNaN(parsed)) { + throw new PromptValidationError('Please enter a valid number.'); + } + + if (options.min !== undefined && parsed < options.min) { + throw new PromptValidationError(`Please enter a value greater than or equal to ${options.min}.`); + } + + if (options.max !== undefined && parsed > options.max) { + throw new PromptValidationError(`Please enter a value less than or equal to ${options.max}.`); + } + + return parsed; + }); +}; diff --git a/packages/tui/src/prompts/choices.ts b/packages/tui/src/prompts/choices.ts new file mode 100644 index 00000000..458b08db --- /dev/null +++ b/packages/tui/src/prompts/choices.ts @@ -0,0 +1,135 @@ +import { ask, promptUntilValid, PromptValidationError } from '#tui/prompt'; +import { renderChoices } from '#tui/theme'; +import type { + Choice, + ChoiceInput, + ConfirmPromptOptions, + MultiSelectPromptOptions, + SearchPromptOptions, + SelectPromptOptions, + TextPromptOptions +} from '#tui/types'; + +export const normalizeChoices = (options: Array>): Array> => { + return options.map((choice) => { + if (typeof choice === 'object' && choice !== null && 'value' in choice && 'label' in choice) { + return choice; + } + + return { + label: String(choice), + value: choice as T + }; + }); +}; + +const findChoice = (choices: Array>, answer: string): Choice | undefined => { + const index = Number.parseInt(answer, 10); + + if (!Number.isNaN(index)) { + return choices[index - 1]; + } + + return choices.find((choice) => choice.label === answer || String(choice.value) === answer); +}; + +export const confirm = async (message: string | ConfirmPromptOptions): Promise => { + const options: ConfirmPromptOptions = typeof message === 'string' ? { message, default: true } : message; + + return promptUntilValid(options, async () => { + const suffix = options.default === false ? ' [y/N]' : ' [Y/n]'; + const answer = (await ask(`${options.message}${suffix}`, options.hint)).trim().toLowerCase(); + + if (answer === '' && options.default !== undefined) { + return options.default; + } + + return ['y', 'yes', options.yes?.toLowerCase()].includes(answer); + }); +}; + +export const select = async (options: SelectPromptOptions): Promise => { + const choices = normalizeChoices(options.options); + + return promptUntilValid(options, async () => { + const rendered = renderChoices(choices); + const answer = await ask(`${options.message}\n${rendered}\n`, options.hint); + const choice = findChoice(choices, answer); + const selected = choice?.value ?? options.default ?? choices.find((candidate) => !candidate.disabled)?.value; + + if (selected === undefined) { + throw new PromptValidationError('Please select a valid option.'); + } + + return selected; + }); +}; + +export const multiselect = async (options: MultiSelectPromptOptions): Promise => { + const choices = normalizeChoices(options.options); + + return promptUntilValid(options, async () => { + const rendered = renderChoices(choices); + const answer = await ask(`${options.message}\n${rendered}\n`, options.hint); + + if (answer.trim() === '' && options.default !== undefined) { + return options.default; + } + + return answer + .split(',') + .map((part) => findChoice(choices, part.trim())) + .filter((choice): choice is Choice => choice !== undefined && !choice.disabled) + .map((choice) => choice.value); + }); +}; + +export const suggest = async (message: string | TextPromptOptions): Promise => { + const options: TextPromptOptions = typeof message === 'string' ? { message } : message; + + return promptUntilValid(options, async () => { + const answer = await ask(options.message, options.hint); + + return answer === '' && options.default !== undefined ? options.default : answer; + }); +}; + +export const search = async (options: SearchPromptOptions): Promise => { + return promptUntilValid(options, async () => { + const query = await ask(options.message, options.hint); + const source = typeof options.options === 'function' ? await options.options(query) : options.options; + const choices = normalizeChoices(source); + const choice = findChoice(choices, query) ?? choices.find((candidate) => !candidate.disabled); + const selected = choice?.value ?? options.default ?? choices[0]?.value; + + if (selected === undefined) { + throw new PromptValidationError('Please select a valid option.'); + } + + return selected; + }); +}; + +export const multisearch = async (options: SearchPromptOptions): Promise => { + return promptUntilValid(options, async () => { + const query = await ask(options.message, options.hint); + const source = typeof options.options === 'function' ? await options.options(query) : options.options; + const choices = normalizeChoices(source); + + if (query.trim() === '' && options.default !== undefined) { + return options.default; + } + + return query + .split(',') + .map((part) => findChoice(choices, part.trim())) + .filter((choice): choice is Choice => choice !== undefined && !choice.disabled) + .flatMap((choice) => choice.value); + }); +}; + +export const autocomplete = async (options: SearchPromptOptions): Promise => search(options); + +export const pause = async (message = 'Press enter to continue'): Promise => { + await ask(message); +}; diff --git a/packages/tui/src/status.ts b/packages/tui/src/status.ts new file mode 100644 index 00000000..7e04af8c --- /dev/null +++ b/packages/tui/src/status.ts @@ -0,0 +1,60 @@ +import { promptEnvironment } from '#tui/environment'; +import type { MaybePromise, StatusOptions } from '#tui/types'; + +export const spin = async (callback: () => MaybePromise, options: StatusOptions = { message: 'Loading' }): Promise => { + promptEnvironment().output.write(`${options.message}...\n`); + + try { + const result = await callback(); + promptEnvironment().output.write(`Done: ${options.message}\n`); + + return result; + } catch (error) { + promptEnvironment().error.write(`Failed: ${options.message}\n`); + throw error; + } +}; + +export class Progress { + #current = 0; + + constructor( + readonly total: number, + readonly message = 'Progress' + ) {} + + advance(step = 1): void { + this.#current = Math.min(this.total, this.#current + step); + promptEnvironment().output.write(`${this.message}: ${this.#current}/${this.total}\n`); + } + + finish(): void { + this.#current = this.total; + promptEnvironment().output.write(`${this.message}: ${this.total}/${this.total}\n`); + } + + value(): number { + return this.#current; + } +} + +export const progress = (total: number, message?: string): Progress => new Progress(total, message); + +export type TaskDefinition = { + title: string; + task: () => MaybePromise; +}; + +export const task = async (definition: TaskDefinition): Promise => { + promptEnvironment().output.write(`${definition.title}\n`); + const result = await definition.task(); + promptEnvironment().output.write(`Done: ${definition.title}\n`); + + return result; +}; + +export const stream = async (source: AsyncIterable | Iterable): Promise => { + for await (const chunk of source) { + promptEnvironment().output.write(chunk); + } +}; diff --git a/packages/tui/src/strings.ts b/packages/tui/src/strings.ts new file mode 100644 index 00000000..0034cb2b --- /dev/null +++ b/packages/tui/src/strings.ts @@ -0,0 +1,78 @@ +import stringWidth from 'string-width'; +import stripAnsi from 'strip-ansi'; + +export const visibleWidth = (value: string): number => stringWidth(stripAnsi(value)); + +export const truncate = (value: string, width: number, marker = '...'): string => { + if (width <= 0) { + return ''; + } + + if (visibleWidth(value) <= width) { + return value; + } + + if (width <= marker.length) { + return marker.slice(0, width); + } + + let result = ''; + + for (const char of value) { + if (visibleWidth(`${result}${char}${marker}`) > width) { + return `${result}${marker}`; + } + + result += char; + } + + return result; +}; + +export const wrap = (value: string, width: number): string[] => { + if (width <= 0) { + return [value]; + } + + const lines: string[] = []; + + for (const originalLine of value.split('\n')) { + let line = ''; + + for (const word of originalLine.split(/(\s+)/u)) { + if (visibleWidth(`${line}${word}`) <= width) { + line += word; + continue; + } + + if (line.length > 0) { + lines.push(line.trimEnd()); + line = ''; + } + + if (visibleWidth(word) > width) { + let chunk = ''; + + for (const char of word) { + if (visibleWidth(`${chunk}${char}`) > width) { + lines.push(chunk); + chunk = ''; + } + + chunk += char; + } + + line = chunk; + continue; + } + + line = word.trimStart(); + } + + lines.push(line.trimEnd()); + } + + return lines; +}; + +export const parseAnsiText = (value: string): string => stripAnsi(value); diff --git a/packages/tui/src/terminal.ts b/packages/tui/src/terminal.ts new file mode 100644 index 00000000..5b2d9c5c --- /dev/null +++ b/packages/tui/src/terminal.ts @@ -0,0 +1,27 @@ +import { promptEnvironment } from '#tui/environment'; + +export type TerminalSize = { + columns: number; + rows: number; +}; + +export const terminalSize = (): TerminalSize => ({ + columns: process.stdout.columns ?? 80, + rows: process.stdout.rows ?? 24 +}); + +export const clearTerminal = (): void => { + promptEnvironment().output.write('\u001Bc'); +}; + +export const setTerminalTitle = (title: string): void => { + promptEnvironment().output.write(`\u001B]0;${title}\u0007`); +}; + +export const eraseLine = (): void => { + promptEnvironment().output.write('\u001B[2K'); +}; + +export const cursorToStart = (): void => { + promptEnvironment().output.write('\r'); +}; diff --git a/packages/tui/src/theme.ts b/packages/tui/src/theme.ts new file mode 100644 index 00000000..f41a7ef1 --- /dev/null +++ b/packages/tui/src/theme.ts @@ -0,0 +1,47 @@ +import { visibleWidth } from '#tui/strings'; +import type { Choice } from '#tui/types'; + +export const symbols = { + question: '?', + answer: '>', + error: '!', + info: 'i', + selected: '●', + unselected: '○', + pointer: '›', + success: '✓', + warning: '!' +} as const; + +export const renderQuestion = (message: string, hint?: string): string => { + const suffix = hint ? ` ${hint}` : ''; + + return `${symbols.question} ${message}${suffix} `; +}; + +export const renderError = (message: string): string => `${symbols.error} ${message}\n`; + +export const renderChoices = (choices: Array>): string => { + return choices + .map((choice, index) => { + const disabled = choice.disabled ? ` (${typeof choice.disabled === 'string' ? choice.disabled : 'disabled'})` : ''; + const hint = choice.hint ? ` ${choice.hint}` : ''; + + return ` ${index + 1}. ${choice.label}${hint}${disabled}`; + }) + .join('\n'); +}; + +export const renderTable = (headers: string[], rows: string[][]): string => { + const widths = headers.map((header, index) => { + return Math.max(visibleWidth(header), ...rows.map((row) => visibleWidth(row[index] ?? ''))); + }); + + const renderRow = (columns: string[]): string => { + return `| ${columns.map((column, index) => column.padEnd(widths[index] ?? 0)).join(' | ')} |`; + }; + + const divider = `| ${widths.map((width) => '-'.repeat(width)).join(' | ')} |`; + + return [renderRow(headers), divider, ...rows.map(renderRow)].join('\n'); +}; diff --git a/packages/tui/src/types.ts b/packages/tui/src/types.ts new file mode 100644 index 00000000..3a7ac1bd --- /dev/null +++ b/packages/tui/src/types.ts @@ -0,0 +1,75 @@ +export type MaybePromise = T | Promise; + +export type ValidationResult = string | false | null | undefined; + +export type Validator = (value: T) => MaybePromise; + +export type PromptValue = string | number | boolean | string[] | number[]; + +export type PromptOutput = { + write(content: string): void; +}; + +export type PromptInput = { + readLine(message: string): Promise; +}; + +export type PromptEnvironment = { + input: PromptInput; + output: PromptOutput; + error: PromptOutput; + interactive: boolean; +}; + +export type BasePromptOptions = { + message: string; + default?: T; + required?: boolean | string; + hint?: string; + validate?: Validator; +}; + +export type Choice = { + label: string; + value: T; + hint?: string; + disabled?: boolean | string; +}; + +export type ChoiceInput = Choice | T; + +export type TextPromptOptions = BasePromptOptions & { + placeholder?: string; +}; + +export type NumberPromptOptions = BasePromptOptions & { + min?: number; + max?: number; + integer?: boolean; +}; + +export type ConfirmPromptOptions = BasePromptOptions & { + yes?: string; + no?: string; +}; + +export type SelectPromptOptions = BasePromptOptions & { + options: Array>; +}; + +export type MultiSelectPromptOptions = BasePromptOptions & { + options: Array>; +}; + +export type SearchPromptOptions = BasePromptOptions & { + options: Array> | ((query: string) => MaybePromise>>); +}; + +export type TableOptions = { + headers?: string[]; + rows: Array | Record>; +}; + +export type StatusOptions = { + message: string; +}; diff --git a/packages/tui/tests/key.test.ts b/packages/tui/tests/key.test.ts new file mode 100644 index 00000000..7e45440f --- /dev/null +++ b/packages/tui/tests/key.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest'; +import { Key, keyFromEvent } from '../src/index'; + +describe('keyboard mapping', () => { + it('maps named keys', () => { + expect(keyFromEvent({ name: 'enter' })).toBe(Key.enter); + }); + + it('maps space and newline sequences', () => { + expect(keyFromEvent({ sequence: ' ' })).toBe(Key.space); + expect(keyFromEvent({ sequence: '\n' })).toBe(Key.enter); + }); +}); diff --git a/packages/tui/tests/strings.test.ts b/packages/tui/tests/strings.test.ts new file mode 100644 index 00000000..52b1fc19 --- /dev/null +++ b/packages/tui/tests/strings.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; +import { parseAnsiText, truncate, visibleWidth, wrap } from '../src/index'; + +describe('string utilities', () => { + it('measures visible width without ANSI control sequences', () => { + expect(visibleWidth('\u001B[31mLaravel\u001B[39m')).toBe(7); + }); + + it('truncates by visible width', () => { + expect(truncate('Laravel Prompts', 10)).toBe('Laravel...'); + }); + + it('wraps words to the requested width', () => { + expect(wrap('Laravel Prompts', 7)).toEqual(['Laravel', 'Prompts']); + }); + + it('strips ANSI text', () => { + expect(parseAnsiText('\u001B[32mDone\u001B[39m')).toBe('Done'); + }); +}); diff --git a/packages/tui/tsconfig.json b/packages/tui/tsconfig.json new file mode 100644 index 00000000..e222b8e8 --- /dev/null +++ b/packages/tui/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": ["@ollin/tsconfig/base.json", "@ollin/artefacts/tsconfig/tui.json"], + "compilerOptions": { + "baseUrl": ".", + "paths": { + "#tui/*": ["./src/*"], + "#tui/prompts/*": ["./src/prompts/*"] + } + }, + "include": ["src/**/*.ts", "vitest.config.ts"] +} diff --git a/packages/tui/vitest.config.ts b/packages/tui/vitest.config.ts new file mode 100644 index 00000000..262b8b24 --- /dev/null +++ b/packages/tui/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + globals: false + } +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 00000000..aa948a84 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,1091 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@ollin/artefacts': + specifier: workspace:* + version: link:packages/artefacts + '@ollin/tsconfig': + specifier: workspace:* + version: link:packages/tsconfig + '@types/node': + specifier: ^25.6.2 + version: 25.9.2 + turbo: + specifier: ^2.9.12 + version: 2.9.17 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + vitest: + specifier: ^4.1.5 + version: 4.1.8(@types/node@25.9.2)(vite@8.0.16(@types/node@25.9.2)) + + packages/acceptance: + dependencies: + '@ollin/tui': + specifier: workspace:* + version: link:../tui + '@opentui/core': + specifier: ^0.4.0 + version: 0.4.0(typescript@6.0.3)(web-tree-sitter@0.25.10) + devDependencies: + '@ollin/artefacts': + specifier: workspace:* + version: link:../artefacts + '@ollin/tsconfig': + specifier: workspace:* + version: link:../tsconfig + '@types/node': + specifier: ^25.6.2 + version: 25.9.2 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + vitest: + specifier: ^4.1.5 + version: 4.1.8(@types/node@25.9.2)(vite@8.0.16(@types/node@25.9.2)) + + 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/tsconfig: {} + + packages/tui: + dependencies: + '@opentui/core': + specifier: ^0.4.0 + version: 0.4.0(typescript@6.0.3)(web-tree-sitter@0.25.10) + string-width: + specifier: ^7.2.0 + version: 7.2.0 + strip-ansi: + specifier: ^7.1.2 + version: 7.2.0 + devDependencies: + '@ollin/artefacts': + specifier: workspace:* + version: link:../artefacts + '@ollin/tsconfig': + specifier: workspace:* + version: link:../tsconfig + '@types/node': + specifier: ^25.6.2 + version: 25.9.2 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + vitest: + specifier: ^4.1.5 + version: 4.1.8(@types/node@25.9.2)(vite@8.0.16(@types/node@25.9.2)) + +packages: + + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@napi-rs/wasm-runtime@1.1.5': + resolution: {integrity: sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@opentui/core-darwin-arm64@0.4.0': + resolution: {integrity: sha512-t7VGLn2LyCGWSrhYdQ2xnq0L0sjg6VOdM6OyFxrZC3HU69m+PXqIhWMpGw/m8V6W7//uM1RX2GQrB/mgJ1Ce0Q==} + cpu: [arm64] + os: [darwin] + + '@opentui/core-darwin-x64@0.4.0': + resolution: {integrity: sha512-/3goEuLFxn9df4fsLw9MSzbp8T3Rcs0NltCS3k73LcCb27pBmHH2FFNDKIj96m5ktK2JIWglEXcX85/i1qy4ug==} + cpu: [x64] + os: [darwin] + + '@opentui/core-linux-arm64-musl@0.4.0': + resolution: {integrity: sha512-8SL+HCpXc1Ott4MYfPzBdNM6XUhknBlqAKOmdDnTxkFSnnadJxhBJo+JYX1jxQP/lkd3jKUpEKDc51k9wud4tg==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@opentui/core-linux-arm64@0.4.0': + resolution: {integrity: sha512-KBv05c0e+FRUpPKRqrE5bDmmIhiJsho43EDXJuYOUYJAuGsTii8J8ws0Q4GYBIxZa/atG1Wv3rEKR7+RSd29lA==} + cpu: [arm64] + os: [linux] + + '@opentui/core-linux-x64-musl@0.4.0': + resolution: {integrity: sha512-EpBHP5S8O83VgI4YIM/CPZTUVIGJjYyk+dcYJl76phtRYF4CwZTvomQqyHb0lzXGZ7LwSDoQxsWtLs/hEOVyPA==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@opentui/core-linux-x64@0.4.0': + resolution: {integrity: sha512-n2aZdF2vkzXyV0C0JL9Ok3EDImtWqewZVcFutMkCRK+EQk3a4oa+bl2Y07XpxGN8FZyJxS+R/HWSoDbKBNKDAg==} + cpu: [x64] + os: [linux] + + '@opentui/core-win32-arm64@0.4.0': + resolution: {integrity: sha512-nUoKrHTHDMyqDGSqN7XfKvjaevgS460Pd99tzLCY4rTZcyDTdEUDfnLgEQrkURRCfUsBbwB1lNnq1aP4u+GeZQ==} + cpu: [arm64] + os: [win32] + + '@opentui/core-win32-x64@0.4.0': + resolution: {integrity: sha512-3uHjGfgjhw+tyxDoF6bs11Gbrt+8VP7k6vAMm9Qyvd8cExDFaNcR1E5Tf/6yR7oxGfh9E951ALue8M7h6l/lLQ==} + cpu: [x64] + os: [win32] + + '@opentui/core@0.4.0': + resolution: {integrity: sha512-G3TmJmaPoxD6SadwevZNE30H/pMZsr/qneVaKc7bmFBxA+uHgxFSAsMoFYaEqcFJM1dGt22kMJb+YY2ZahzqVw==} + peerDependencies: + web-tree-sitter: 0.25.10 + + '@oxc-project/types@0.133.0': + resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==} + + '@rolldown/binding-android-arm64@1.0.3': + resolution: {integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.3': + resolution: {integrity: sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.3': + resolution: {integrity: sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.3': + resolution: {integrity: sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.3': + resolution: {integrity: sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.3': + resolution: {integrity: sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.0.3': + resolution: {integrity: sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.0.3': + resolution: {integrity: sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.0.3': + resolution: {integrity: sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.0.3': + resolution: {integrity: sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.0.3': + resolution: {integrity: sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.0.3': + resolution: {integrity: sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.3': + resolution: {integrity: sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.3': + resolution: {integrity: sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.3': + resolution: {integrity: sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@turbo/darwin-64@2.9.17': + resolution: {integrity: sha512-io5jn5RDeU+9YV78rWhwG++HD/OZ/Lxg1sg93+jDGKQNP3UDxY6RX2dmarbCILhNxNuAM8FH3WgGMY9E96Mf8w==} + cpu: [x64] + os: [darwin] + + '@turbo/darwin-arm64@2.9.17': + resolution: {integrity: sha512-83YZTYmN2sxFWf2LTMOwqbOvR3qZMa/TSFwnB6BHVBbIWyoPPe+TAdSTd8KevEx8ml8KkycJ/9A70DFVReyUww==} + cpu: [arm64] + os: [darwin] + + '@turbo/linux-64@2.9.17': + resolution: {integrity: sha512-teKfwJg0zSC+C2ZSOsX3VnAJGVgcN+pgKNmnGWzcpXQ9eIkAQtYP+getrQ2f1Tw/ePudnreQhq8tVP8S73Vy6Q==} + cpu: [x64] + os: [linux] + + '@turbo/linux-arm64@2.9.17': + resolution: {integrity: sha512-mqO36x2CNtJ9CCbEf5xOqH662tVSc1wB0mxh7dopBpgXg0fEifdzwX0IEAUW1WUAaNH986L7iEUUgw3HNqUWgg==} + cpu: [arm64] + os: [linux] + + '@turbo/windows-64@2.9.17': + resolution: {integrity: sha512-jbyoNePufyMoSSrvVr+/mglcjmya/MOgoIrSHPr67iZ1VFgrlMQXHXtptR2lR48gi+86b1XBvsviJZ9A7zrydg==} + cpu: [x64] + os: [win32] + + '@turbo/windows-arm64@2.9.17': + resolution: {integrity: sha512-+ql0wYc99Y2AMvyHCcC/P+xtyV4nz522L+C9HDnyi7ryHXBGM+ZjBP28M7SLBGMDImgpN8sk2szpgbvreMeXVA==} + cpu: [arm64] + os: [win32] + + '@tybys/wasm-util@0.10.2': + resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/node@25.9.2': + resolution: {integrity: sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw==} + + '@vitest/expect@4.1.8': + resolution: {integrity: sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==} + + '@vitest/mocker@4.1.8': + resolution: {integrity: sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.8': + resolution: {integrity: sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==} + + '@vitest/runner@4.1.8': + resolution: {integrity: sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==} + + '@vitest/snapshot@4.1.8': + resolution: {integrity: sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==} + + '@vitest/spy@4.1.8': + resolution: {integrity: sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==} + + '@vitest/utils@4.1.8': + resolution: {integrity: sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + bun-ffi-structs@0.2.3: + resolution: {integrity: sha512-pgJiXP+hEgFo9qG51J6ItfY4ocs3vniwNzJ9WhoakB3QB2GdzQxX2EXssentPYlB2hOfJrTjO6iIQkWYzUodpg==} + peerDependencies: + typescript: ^5 + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + diff@9.0.0: + resolution: {integrity: sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==} + engines: {node: '>=0.3.1'} + + emoji-regex@10.6.0: + resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + get-east-asian-width@1.6.0: + resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} + engines: {node: '>=18'} + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + marked@17.0.1: + resolution: {integrity: sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==} + engines: {node: '>= 20'} + hasBin: true + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + obug@2.1.2: + resolution: {integrity: sha512-AWGB9WFcRXOQs48Z/udjI5ZcZMHXwX8XPByNpOydgcGsDLIzjGizhoMWJyKAWze7AVW/2W1i+/gPX4YtKe5cyg==} + engines: {node: '>=12.20.0'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + engines: {node: ^10 || ^12 || >=14} + + rolldown@1.0.3: + resolution: {integrity: sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + + strip-ansi@7.1.2: + resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} + engines: {node: '>=12'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + turbo@2.9.17: + resolution: {integrity: sha512-91Q3KxfHJn7esFu2Ic6j9pkvQqWjncQCOp7r1gCKChRSb/+T/yIjsavAmbGLmFRKAzSjmWW/FMrcknmJ4hEOPA==} + hasBin: true + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + + vite@8.0.16: + resolution: {integrity: sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.1.18 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.8: + resolution: {integrity: sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.8 + '@vitest/browser-preview': 4.1.8 + '@vitest/browser-webdriverio': 4.1.8 + '@vitest/coverage-istanbul': 4.1.8 + '@vitest/coverage-v8': 4.1.8 + '@vitest/ui': 4.1.8 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + web-tree-sitter@0.25.10: + resolution: {integrity: sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA==} + peerDependencies: + '@types/emscripten': ^1.40.0 + peerDependenciesMeta: + '@types/emscripten': + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + yoga-layout@3.2.1: + resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==} + +snapshots: + + '@emnapi/core@1.10.0': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.2 + optional: true + + '@opentui/core-darwin-arm64@0.4.0': + optional: true + + '@opentui/core-darwin-x64@0.4.0': + optional: true + + '@opentui/core-linux-arm64-musl@0.4.0': + optional: true + + '@opentui/core-linux-arm64@0.4.0': + optional: true + + '@opentui/core-linux-x64-musl@0.4.0': + optional: true + + '@opentui/core-linux-x64@0.4.0': + optional: true + + '@opentui/core-win32-arm64@0.4.0': + optional: true + + '@opentui/core-win32-x64@0.4.0': + optional: true + + '@opentui/core@0.4.0(typescript@6.0.3)(web-tree-sitter@0.25.10)': + dependencies: + bun-ffi-structs: 0.2.3(typescript@6.0.3) + diff: 9.0.0 + marked: 17.0.1 + string-width: 7.2.0 + strip-ansi: 7.1.2 + web-tree-sitter: 0.25.10 + yoga-layout: 3.2.1 + optionalDependencies: + '@opentui/core-darwin-arm64': 0.4.0 + '@opentui/core-darwin-x64': 0.4.0 + '@opentui/core-linux-arm64': 0.4.0 + '@opentui/core-linux-arm64-musl': 0.4.0 + '@opentui/core-linux-x64': 0.4.0 + '@opentui/core-linux-x64-musl': 0.4.0 + '@opentui/core-win32-arm64': 0.4.0 + '@opentui/core-win32-x64': 0.4.0 + transitivePeerDependencies: + - typescript + + '@oxc-project/types@0.133.0': {} + + '@rolldown/binding-android-arm64@1.0.3': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.3': + optional: true + + '@rolldown/binding-darwin-x64@1.0.3': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.3': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.3': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.3': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.3': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.0.3': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.0.3': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.3': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.3': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.3': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.3': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.3': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.3': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@standard-schema/spec@1.1.0': {} + + '@turbo/darwin-64@2.9.17': + optional: true + + '@turbo/darwin-arm64@2.9.17': + optional: true + + '@turbo/linux-64@2.9.17': + optional: true + + '@turbo/linux-arm64@2.9.17': + optional: true + + '@turbo/windows-64@2.9.17': + optional: true + + '@turbo/windows-arm64@2.9.17': + optional: true + + '@tybys/wasm-util@0.10.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/node@25.9.2': + dependencies: + undici-types: 7.24.6 + + '@vitest/expect@4.1.8': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.8 + '@vitest/utils': 4.1.8 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.8(vite@8.0.16(@types/node@25.9.2))': + dependencies: + '@vitest/spy': 4.1.8 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.0.16(@types/node@25.9.2) + + '@vitest/pretty-format@4.1.8': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.8': + dependencies: + '@vitest/utils': 4.1.8 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.8': + dependencies: + '@vitest/pretty-format': 4.1.8 + '@vitest/utils': 4.1.8 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.8': {} + + '@vitest/utils@4.1.8': + dependencies: + '@vitest/pretty-format': 4.1.8 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + + ansi-regex@6.2.2: {} + + assertion-error@2.0.1: {} + + bun-ffi-structs@0.2.3(typescript@6.0.3): + dependencies: + typescript: 6.0.3 + + chai@6.2.2: {} + + convert-source-map@2.0.0: {} + + detect-libc@2.1.2: {} + + diff@9.0.0: {} + + emoji-regex@10.6.0: {} + + es-module-lexer@2.1.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.3.0: {} + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + fsevents@2.3.3: + optional: true + + get-east-asian-width@1.6.0: {} + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + marked@17.0.1: {} + + nanoid@3.3.12: {} + + obug@2.1.2: {} + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + postcss@8.5.15: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + rolldown@1.0.3: + dependencies: + '@oxc-project/types': 0.133.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.3 + '@rolldown/binding-darwin-arm64': 1.0.3 + '@rolldown/binding-darwin-x64': 1.0.3 + '@rolldown/binding-freebsd-x64': 1.0.3 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.3 + '@rolldown/binding-linux-arm64-gnu': 1.0.3 + '@rolldown/binding-linux-arm64-musl': 1.0.3 + '@rolldown/binding-linux-ppc64-gnu': 1.0.3 + '@rolldown/binding-linux-s390x-gnu': 1.0.3 + '@rolldown/binding-linux-x64-gnu': 1.0.3 + '@rolldown/binding-linux-x64-musl': 1.0.3 + '@rolldown/binding-openharmony-arm64': 1.0.3 + '@rolldown/binding-wasm32-wasi': 1.0.3 + '@rolldown/binding-win32-arm64-msvc': 1.0.3 + '@rolldown/binding-win32-x64-msvc': 1.0.3 + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@4.1.0: {} + + string-width@7.2.0: + dependencies: + emoji-regex: 10.6.0 + get-east-asian-width: 1.6.0 + strip-ansi: 7.2.0 + + strip-ansi@7.1.2: + dependencies: + ansi-regex: 6.2.2 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + + tinybench@2.9.0: {} + + tinyexec@1.2.4: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinyrainbow@3.1.0: {} + + tslib@2.8.1: + optional: true + + turbo@2.9.17: + optionalDependencies: + '@turbo/darwin-64': 2.9.17 + '@turbo/darwin-arm64': 2.9.17 + '@turbo/linux-64': 2.9.17 + '@turbo/linux-arm64': 2.9.17 + '@turbo/windows-64': 2.9.17 + '@turbo/windows-arm64': 2.9.17 + + typescript@6.0.3: {} + + undici-types@7.24.6: {} + + vite@8.0.16(@types/node@25.9.2): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.15 + rolldown: 1.0.3 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 25.9.2 + fsevents: 2.3.3 + + vitest@4.1.8(@types/node@25.9.2)(vite@8.0.16(@types/node@25.9.2)): + dependencies: + '@vitest/expect': 4.1.8 + '@vitest/mocker': 4.1.8(vite@8.0.16(@types/node@25.9.2)) + '@vitest/pretty-format': 4.1.8 + '@vitest/runner': 4.1.8 + '@vitest/snapshot': 4.1.8 + '@vitest/spy': 4.1.8 + '@vitest/utils': 4.1.8 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.2 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 8.0.16(@types/node@25.9.2) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 25.9.2 + transitivePeerDependencies: + - msw + + web-tree-sitter@0.25.10: {} + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + yoga-layout@3.2.1: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 00000000..ff9eb2d5 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,6 @@ +packages: + - "packages/*" + +onlyBuiltDependencies: + - "@opentui/core" + - esbuild diff --git a/turbo.json b/turbo.json new file mode 100644 index 00000000..37ec6d9b --- /dev/null +++ b/turbo.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://turbo.build/schema.json", + "tasks": { + "test": { + "dependsOn": ["^test"], + "outputs": [], + "inputs": ["src/**", "tests/**", "vitest.config.ts", "package.json", "tsconfig.json"] + }, + "typecheck": { + "dependsOn": ["^typecheck"], + "outputs": [] + } + } +} From 881f5429b8f2d01d1090262e0da3eaaadba0d5c8 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 13:35:59 +0800 Subject: [PATCH 002/675] Add key driven prompt behavior --- README.md | 7 +- UPSTREAM.md | 4 +- packages/acceptance/tests/helpers.test.ts | 22 ++- packages/acceptance/tests/opentui.test.ts | 12 ++ packages/tui/src/environment.ts | 30 +++ packages/tui/src/form.ts | 10 +- packages/tui/src/index.ts | 7 +- packages/tui/src/key.ts | 68 +++++-- packages/tui/src/opentui.ts | 19 +- packages/tui/src/output.ts | 105 ++++++++-- packages/tui/src/prompt.ts | 34 +++- packages/tui/src/prompts/basic.ts | 141 +++++++++++-- packages/tui/src/prompts/choices.ts | 230 +++++++++++++++++++--- packages/tui/src/status.ts | 86 +++++++- packages/tui/src/typed-value.ts | 140 +++++++++++++ packages/tui/src/types.ts | 19 +- packages/tui/tests/choices.test.ts | 35 ++++ packages/tui/tests/typed-value.test.ts | 32 +++ 18 files changed, 902 insertions(+), 99 deletions(-) create mode 100644 packages/tui/src/typed-value.ts create mode 100644 packages/tui/tests/choices.test.ts create mode 100644 packages/tui/tests/typed-value.test.ts diff --git a/README.md b/README.md index 31cf6907..da66d3a9 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,10 @@ using TypeScript-first modules and package boundaries. ## Status -Phase 1 contains the monorepo scaffold, public helper surface, shared prompt -environment, terminal utilities, baseline theme rendering, and local parity -tests. Full upstream feature-test parity is tracked in `UPSTREAM.md`. +The port is being delivered phase by phase on one draft PR. Current code includes +working key-driven text and choice prompts, output helpers, terminal utilities, +OpenTUI adapter wiring, and local parity tests. Full upstream feature-test parity +is tracked in `UPSTREAM.md`. ## Development diff --git a/UPSTREAM.md b/UPSTREAM.md index a809e8b7..e9787f60 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -15,13 +15,13 @@ idiomatic TypeScript and OpenTUI internals. | Prompt lifecycle and fallback behavior | `packages/tui/src/prompt.ts`, `packages/tui/src/environment.ts` | | Key constants | `packages/tui/src/key.ts` | | Terminal helpers | `packages/tui/src/terminal.ts` | -| Text, textarea, number, password | `packages/tui/src/prompts/basic.ts` | +| Text, textarea, number, password | `packages/tui/src/prompts/basic.ts`, `packages/tui/src/typed-value.ts` | | Confirm, select, multiselect, suggest, search, multisearch, autocomplete | `packages/tui/src/prompts/choices.ts` | | Form builder | `packages/tui/src/form.ts` | | Note, notify, table, grid, title, clear | `packages/tui/src/output.ts` | | Spinner, progress, task, stream | `packages/tui/src/status.ts` | | ANSI, wrapping, truncation, Unicode width | `packages/tui/src/strings.ts` | -| OpenTUI testing adapter | `packages/tui/src/opentui.ts` | +| OpenTUI adapter | `packages/tui/src/opentui.ts` | ## Remaining Exact-Parity Work diff --git a/packages/acceptance/tests/helpers.test.ts b/packages/acceptance/tests/helpers.test.ts index dc41245f..f3ae61c6 100644 --- a/packages/acceptance/tests/helpers.test.ts +++ b/packages/acceptance/tests/helpers.test.ts @@ -6,6 +6,10 @@ import { form, multiselect, note, + error, + info, + intro, + outro, number, progress, select, @@ -34,8 +38,8 @@ const scripted = async (lines: string[], callback: () => Promise): Promise describe('Laravel-style helpers', () => { it('reads text and number prompts', async () => { - await expect(scripted(['Taylor'], () => text('Name'))).resolves.toMatchObject({ result: 'Taylor' }); - await expect(scripted(['42'], () => number({ message: 'Age', integer: true }))).resolves.toMatchObject({ result: 42 }); + await expect(scripted(['Tay', 'lor', '\n'], () => text('Name'))).resolves.toMatchObject({ result: 'Taylor' }); + await expect(scripted(['4', '2', '\n'], () => number({ message: 'Age', integer: true }))).resolves.toMatchObject({ result: 42 }); }); it('reads confirm and choice prompts', async () => { @@ -61,7 +65,7 @@ describe('Laravel-style helpers', () => { }); it('runs form builders', async () => { - const response = await scripted(['Ada', 'y'], () => + const response = await scripted(['A', 'd', 'a', '\n', 'y'], () => form(async (builder) => ({ name: await builder.text('Name'), active: await builder.confirm('Active') @@ -75,12 +79,20 @@ describe('Laravel-style helpers', () => { const output = createMemoryOutput(); await withPromptEnvironment({ output, error: output }, async () => { - note('Hello', 'Greeting'); + note('Hello'); + error('Nope'); + info('Facts'); + intro('Start'); + outro('Done'); table({ headers: ['Name'], rows: [['Laravel']] }); title('Demo'); }); - expect(output.text()).toContain('Greeting'); + 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/packages/acceptance/tests/opentui.test.ts b/packages/acceptance/tests/opentui.test.ts index 63cce8a3..7f6aefcb 100644 --- a/packages/acceptance/tests/opentui.test.ts +++ b/packages/acceptance/tests/opentui.test.ts @@ -1,6 +1,7 @@ import { Text } from '@opentui/core'; import { createTestRenderer } from '@opentui/core/testing'; import { describe, expect, it } from 'vitest'; +import { renderOpenTuiTextFrame } from '@ollin/tui'; describe('OpenTUI memory renderer', () => { it('captures rendered text without writing to the real terminal', async () => { @@ -26,4 +27,15 @@ describe('OpenTUI memory renderer', () => { renderer.destroy(); } }); + + it('renders through the package OpenTUI adapter', async () => { + try { + const frame = await renderOpenTuiTextFrame('Prompt', { width: 20, height: 4 }); + + expect(frame.text).toContain('Prompt'); + } catch (error) { + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain('native FFI is not available'); + } + }); }); diff --git a/packages/tui/src/environment.ts b/packages/tui/src/environment.ts index 63ccbada..423ae7a3 100644 --- a/packages/tui/src/environment.ts +++ b/packages/tui/src/environment.ts @@ -9,6 +9,33 @@ const outputFromStream = (stream: NodeJS.WritableStream): PromptOutput => ({ }); const defaultInput: PromptInput = { + async readKey(): Promise { + return new Promise((resolve) => { + const input = defaultStdin; + const wasRaw = input.isRaw; + + const cleanup = (): void => { + input.off('data', onData); + + if (input.isTTY) { + input.setRawMode(wasRaw); + input.pause(); + } + }; + + const onData = (chunk: Buffer): void => { + cleanup(); + resolve(chunk.toString('utf8')); + }; + + input.once('data', onData); + + if (input.isTTY) { + input.setRawMode(true); + input.resume(); + } + }); + }, async readLine(message: string): Promise { const readline = createInterface({ input: defaultStdin, @@ -70,6 +97,9 @@ export const createScriptedInput = (lines: string[]): PromptInput => { const queued = [...lines]; return { + async readKey(): Promise { + return queued.shift() ?? null; + }, async readLine(): Promise { return queued.shift() ?? ''; } diff --git a/packages/tui/src/form.ts b/packages/tui/src/form.ts index 56085871..a98c1c4b 100644 --- a/packages/tui/src/form.ts +++ b/packages/tui/src/form.ts @@ -10,23 +10,23 @@ import type { export class FormBuilder { async text(message: string | TextPromptOptions): Promise { - return text(message); + return typeof message === 'string' ? text(message) : text(message); } async textarea(message: string | TextPromptOptions): Promise { - return textarea(message); + return typeof message === 'string' ? textarea(message) : textarea(message); } async password(message: string | TextPromptOptions): Promise { - return password(message); + return typeof message === 'string' ? password(message) : password(message); } async number(message: string | NumberPromptOptions): Promise { - return number(message); + return typeof message === 'string' ? number(message) : number(message); } async confirm(message: string | ConfirmPromptOptions): Promise { - return confirm(message); + return typeof message === 'string' ? confirm(message) : confirm(message); } async select(options: SelectPromptOptions): Promise { diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index f8a727cd..0bc9adc3 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -1,13 +1,14 @@ export { configurePrompts, createMemoryOutput, createScriptedInput, promptEnvironment, withPromptEnvironment } from '#tui/environment'; export { FormBuilder, form } from '#tui/form'; -export { Key, keyFromEvent } from '#tui/key'; -export { createOpenTuiTextFrame } from '#tui/opentui'; +export { Key, keyFromEvent, oneOf } from '#tui/key'; +export { renderOpenTuiTextFrame } from '#tui/opentui'; export { confirm, select, multiselect, suggest, search, multisearch, autocomplete, pause } from '#tui/prompts/choices'; export { number, password, text, textarea } from '#tui/prompts/basic'; -export { clear, dataTable, grid, note, notify, table, title } from '#tui/output'; +export { alert, clear, dataTable, datatable, error, grid, info, intro, note, notify, outro, table, title, warning } from '#tui/output'; export { PromptValidationError } from '#tui/prompt'; export { Progress, progress, spin, stream, task } from '#tui/status'; export { clearTerminal, cursorToStart, eraseLine, setTerminalTitle, terminalSize } from '#tui/terminal'; +export { applyTypedKey, readTypedValue } from '#tui/typed-value'; export { parseAnsiText, truncate, visibleWidth, wrap } from '#tui/strings'; export type { BasePromptOptions, diff --git a/packages/tui/src/key.ts b/packages/tui/src/key.ts index e25a37dd..6897a6c8 100644 --- a/packages/tui/src/key.ts +++ b/packages/tui/src/key.ts @@ -1,19 +1,41 @@ export const Key = { - backspace: 'backspace', - delete: 'delete', - down: 'down', - end: 'end', - enter: 'enter', - escape: 'escape', - home: 'home', - left: 'left', - right: 'right', - space: 'space', - tab: 'tab', - up: 'up' + up: '\u001B[A', + shiftUp: '\u001B[1;2A', + pageUp: '\u001B[5~', + down: '\u001B[B', + shiftDown: '\u001B[1;2B', + pageDown: '\u001B[6~', + right: '\u001B[C', + left: '\u001B[D', + upArrow: '\u001BOA', + downArrow: '\u001BOB', + rightArrow: '\u001BOC', + leftArrow: '\u001BOD', + escape: '\u001B', + delete: '\u001B[3~', + backspace: '\u007F', + enter: '\n', + space: ' ', + tab: '\t', + shiftTab: '\u001B[Z', + home: ['\u001B[1~', '\u001BOH', '\u001B[H', '\u001B[7~'], + end: ['\u001B[4~', '\u001BOF', '\u001B[F', '\u001B[8~'], + ctrlC: '\u0003', + ctrlP: '\u0010', + ctrlN: '\u000E', + ctrlF: '\u0006', + ctrlB: '\u0002', + ctrlH: '\u0008', + ctrlA: '\u0001', + ctrlD: '\u0004', + ctrlE: '\u0005', + ctrlU: '\u0015', + optionBackspace: '\u001B\u007F' } as const; -export type KeyName = (typeof Key)[keyof typeof Key]; +export type KeyValue = string | readonly string[]; + +export type KeyName = Extract<(typeof Key)[keyof typeof Key], string>; export type KeyboardEventLike = { name?: string; @@ -25,7 +47,9 @@ export type KeyboardEventLike = { export const keyFromEvent = (event: KeyboardEventLike): KeyName | string => { if (event.name && event.name in Key) { - return Key[event.name as keyof typeof Key]; + const key = Key[event.name as keyof typeof Key]; + + return typeof key === 'string' ? key : key[0]; } if (event.sequence === ' ') { @@ -38,3 +62,19 @@ export const keyFromEvent = (event: KeyboardEventLike): KeyName | string => { return event.name ?? event.sequence ?? ''; }; + +export const oneOf = (keys: KeyValue[], match: string): string | undefined => { + for (const key of keys) { + if (Array.isArray(key)) { + const nested = oneOf([...key], match); + + if (nested !== undefined) { + return nested; + } + } else if (key === match) { + return match; + } + } + + return undefined; +}; diff --git a/packages/tui/src/opentui.ts b/packages/tui/src/opentui.ts index 6ec6d0d0..a1541a7a 100644 --- a/packages/tui/src/opentui.ts +++ b/packages/tui/src/opentui.ts @@ -1,5 +1,22 @@ +import { Text } from '@opentui/core'; +import { createTestRenderer } from '@opentui/core/testing'; + export type OpenTuiFrame = { text: string; }; -export const createOpenTuiTextFrame = (text: string): OpenTuiFrame => ({ text }); +export const renderOpenTuiTextFrame = async (text: string, size: { width?: number; height?: number } = {}): Promise => { + const { renderer, renderOnce, captureCharFrame } = await createTestRenderer({ + width: size.width ?? 80, + height: size.height ?? 24 + }); + + try { + renderer.root.add(Text({ content: text })); + await renderOnce(); + + return { text: captureCharFrame() }; + } finally { + renderer.destroy(); + } +}; diff --git a/packages/tui/src/output.ts b/packages/tui/src/output.ts index 124e4be4..a5c12ea9 100644 --- a/packages/tui/src/output.ts +++ b/packages/tui/src/output.ts @@ -1,39 +1,120 @@ +import { spawn } from 'node:child_process'; +import { platform } from 'node:process'; import { promptEnvironment } from '#tui/environment'; import { clearTerminal, setTerminalTitle } from '#tui/terminal'; -import { renderTable } from '#tui/theme'; +import { renderTable, symbols } from '#tui/theme'; import type { TableOptions } from '#tui/types'; const stringify = (value: string | number | boolean | null | undefined): string => { return value === null || value === undefined ? '' : String(value); }; -export const note = (message: string, title?: string): void => { - const heading = title ? `${title}\n` : ''; +export type NoteType = 'alert' | 'error' | 'info' | 'intro' | 'outro' | 'warning' | string; - promptEnvironment().output.write(`${heading}${message}\n`); +const notePrefix = (type?: NoteType | null): string => { + switch (type) { + case 'error': + return symbols.error; + case 'warning': + case 'alert': + return symbols.warning; + case 'info': + return symbols.info; + case 'intro': + case 'outro': + return symbols.success; + default: + return symbols.info; + } }; -export const notify = (message: string, title = 'Notification'): void => { - note(message, title); +export const note = (message: string, type?: NoteType | null): void => { + const prefix = notePrefix(type); + + promptEnvironment().output.write(`${prefix} ${message}\n`); }; -export const table = (options: TableOptions): void => { - const headers = options.headers ?? Object.keys(options.rows[0] ?? {}); - const rows = options.rows.map((row) => { +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'); + +export const notify = (title: string, body = '', subtitle = '', sound = '', icon = ''): void => { + if (platform === 'darwin') { + const script = [ + 'display notification', + JSON.stringify(body), + 'with title', + JSON.stringify(title), + subtitle ? `subtitle ${JSON.stringify(subtitle)}` : '', + sound ? `sound name ${JSON.stringify(sound)}` : '' + ].filter(Boolean).join(' '); + + spawn('osascript', ['-e', script], { detached: true, stdio: 'ignore' }).unref(); + + return; + } + + if (platform === 'linux') { + const args = icon ? ['--icon', icon, title, body] : [title, body]; + + spawn('notify-send', args, { detached: true, stdio: 'ignore' }).unref(); + + return; + } + + note(body ? `${title}: ${body}` : title, 'info'); +}; + +const normalizeTableOptions = (headersOrOptions: TableOptions | string[] = [], rows: TableOptions['rows'] | null = null): Required => { + if (!Array.isArray(headersOrOptions) && 'rows' in headersOrOptions) { + const headers = headersOrOptions.headers ?? Object.keys(headersOrOptions.rows[0] ?? {}); + + return { headers, rows: headersOrOptions.rows }; + } + + return { + headers: headersOrOptions, + rows: rows ?? [] + }; +}; + +export const table = (headersOrOptions: TableOptions | string[] = [], rows: TableOptions['rows'] | null = null): void => { + const options = normalizeTableOptions(headersOrOptions, rows); + const normalizedRows = options.rows.map((row) => { if (Array.isArray(row)) { return row.map(stringify); } - return headers.map((header) => stringify(row[header])); + return options.headers.map((header) => stringify(row[header])); }); - promptEnvironment().output.write(`${renderTable(headers, rows)}\n`); + promptEnvironment().output.write(`${renderTable(options.headers, normalizedRows)}\n`); }; -export const grid = table; +export const grid = (items: Array = [], maxWidth?: number): void => { + const width = maxWidth ?? 4; + const rows: string[][] = []; + + for (let index = 0; index < items.length; index += width) { + rows.push(items.slice(index, index + width).map(String)); + } + + promptEnvironment().output.write(rows.map((row) => row.join(' ')).join('\n')); + promptEnvironment().output.write(items.length > 0 ? '\n' : ''); +}; export const dataTable = table; +export const datatable = table; + export const title = (value: string): void => { setTerminalTitle(value); }; diff --git a/packages/tui/src/prompt.ts b/packages/tui/src/prompt.ts index 71bbde3d..7e2600b8 100644 --- a/packages/tui/src/prompt.ts +++ b/packages/tui/src/prompt.ts @@ -39,12 +39,36 @@ export const promptUntilValid = async ( ): Promise => { const environment = promptEnvironment(); - if (!environment.interactive && options.default !== undefined) { - return options.default; + if (!environment.interactive) { + const value = options.default as T; + const required = ensureRequired(value, options.required); + const validation = required ?? (await validationMessage(value, options.validate)); + + if (validation) { + throw new PromptValidationError(validation); + } + + return value; } while (true) { - const value = await read(); + let value: T; + + try { + value = await read(); + } catch (error) { + if (error instanceof PromptValidationError) { + if (!environment.interactive) { + throw error; + } + + environment.error.write(renderError(error.message)); + continue; + } + + throw error; + } + const required = ensureRequired(value, options.required); const validation = required ?? (await validationMessage(value, options.validate)); @@ -63,5 +87,9 @@ export const promptUntilValid = async ( 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/prompts/basic.ts b/packages/tui/src/prompts/basic.ts index 25193b09..7d6d563c 100644 --- a/packages/tui/src/prompts/basic.ts +++ b/packages/tui/src/prompts/basic.ts @@ -1,4 +1,5 @@ -import { ask, promptUntilValid, PromptValidationError } from '#tui/prompt'; +import { promptUntilValid, PromptValidationError } from '#tui/prompt'; +import { readTypedValue } from '#tui/typed-value'; import type { NumberPromptOptions, TextPromptOptions } from '#tui/types'; const textOptions = (message: string | TextPromptOptions, fallback = ''): TextPromptOptions => { @@ -6,32 +7,136 @@ const textOptions = (message: string | TextPromptOptions, fallback = ''): TextPr return { message, default: fallback }; } - return message; + return { ...message, default: message.default ?? fallback }; }; -export const text = async (message: string | TextPromptOptions): Promise => { - const options = textOptions(message); +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 { + const options = typeof message === 'string' + ? textOptions({ message, label: message, placeholder, default: defaultValue, required, validate, hint, transform }) + : textOptions(message); return promptUntilValid(options, async () => { - const answer = await ask(options.message, options.hint); + const answer = await readTypedValue(options.message, { + default: options.default, + hint: options.hint + }); + const value = answer === '' && options.default !== undefined ? options.default : answer; - return answer === '' && options.default !== undefined ? options.default : answer; + return options.transform ? options.transform(value) : value; }); -}; +} -export const textarea = async (message: string | TextPromptOptions): Promise => { - return text(textOptions(message)); -}; +export function textarea(options: TextPromptOptions & { rows?: number }): Promise; +export function textarea( + label: string, + placeholder?: string, + defaultValue?: string, + required?: boolean | string, + validate?: TextPromptOptions['validate'], + hint?: string, + rows?: number, + transform?: TextPromptOptions['transform'] +): Promise; +export async function textarea( + message: string | (TextPromptOptions & { rows?: number }), + placeholder = '', + defaultValue = '', + required: boolean | string = false, + validate: TextPromptOptions['validate'] = undefined, + hint = '', + _rows = 5, + transform: TextPromptOptions['transform'] = undefined +): Promise { + const options = typeof message === 'string' + ? textOptions({ message, label: message, placeholder, default: defaultValue, required, validate, hint, transform }) + : textOptions(message); -export const password = async (message: string | TextPromptOptions): Promise => { - return text(textOptions(message)); -}; + return promptUntilValid(options, async () => { + const answer = await readTypedValue(options.message, { + default: options.default, + hint: options.hint, + allowNewLine: true + }); + const value = answer === '' && options.default !== undefined ? options.default : answer; -export const number = async (message: string | NumberPromptOptions): Promise => { - const options: NumberPromptOptions = typeof message === 'string' ? { message } : message; + return options.transform ? options.transform(value) : value; + }); +} + +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 { + const options = typeof message === 'string' + ? textOptions({ message, label: message, placeholder, required, validate, hint, transform }) + : textOptions(message); + + return text(options); +} + +export function number(options: NumberPromptOptions): Promise; +export function number( + label: string, + placeholder?: string, + defaultValue?: number, + required?: boolean | string, + validate?: NumberPromptOptions['validate'], + hint?: string, + min?: number, + max?: number, + step?: number +): Promise; +export async function number( + message: string | NumberPromptOptions, + _placeholder = '', + defaultValue = 0, + required: boolean | string = false, + validate: NumberPromptOptions['validate'] = undefined, + hint = '', + min: number | undefined = undefined, + max: number | undefined = undefined, + step: number | undefined = undefined +): Promise { + const options: NumberPromptOptions = typeof message === 'string' + ? { message, label: message, default: defaultValue, required, validate, hint, min, max, step } + : { ...message, default: message.default ?? defaultValue }; return promptUntilValid(options, async () => { - const answer = await ask(options.message, options.hint); + const answer = await readTypedValue(options.message, { + default: options.default === undefined ? undefined : String(options.default), + hint: options.hint + }); if (answer === '' && options.default !== undefined) { return options.default; @@ -51,6 +156,6 @@ export const number = async (message: string | NumberPromptOptions): Promise(choices: Array>, answer: string): Choice | u return choices.find((choice) => choice.label === answer || String(choice.value) === answer); }; -export const confirm = async (message: string | ConfirmPromptOptions): Promise => { - const options: ConfirmPromptOptions = typeof message === 'string' ? { message, default: true } : message; +const firstEnabledIndex = (choices: Array>): number => { + const index = choices.findIndex((choice) => !choice.disabled); + + return index === -1 ? 0 : index; +}; + +const nextEnabledIndex = (choices: Array>, current: number, direction: 1 | -1): number => { + if (choices.length === 0) { + return 0; + } + + let index = current; + + for (let attempts = 0; attempts < choices.length; attempts += 1) { + index = (index + direction + choices.length) % choices.length; + + if (!choices[index]?.disabled) { + return index; + } + } + + return current; +}; + +const renderInteractiveChoices = (message: string, choices: Array>, selected: number, marked: Set = new Set()): void => { + const environment = promptEnvironment(); + + environment.output.write(`${message}\n`); + + for (const [index, choice] of choices.entries()) { + const pointer = index === selected ? '›' : ' '; + const checked = marked.size > 0 ? (marked.has(index) ? '[x]' : '[ ]') : ' '; + const disabled = choice.disabled ? ` (${typeof choice.disabled === 'string' ? choice.disabled : 'disabled'})` : ''; + const hint = choice.hint ? ` ${choice.hint}` : ''; + + environment.output.write(`${pointer} ${checked} ${choice.label}${hint}${disabled}\n`); + } +}; + +const readSelectedChoice = async (message: string, choices: Array>, hint?: string): Promise => { + const environment = promptEnvironment(); + + if (!environment.input.readKey) { + const rendered = renderChoices(choices); + const answer = await ask(`${message}\n${rendered}\n`, hint); + const choice = findChoice(choices, answer); + + if (!choice || choice.disabled) { + throw new PromptValidationError('Please select a valid option.'); + } + + return choice.value; + } + + let selected = firstEnabledIndex(choices); + renderInteractiveChoices(message, choices, selected); + + while (true) { + const key = await environment.input.readKey(); + + if (key === null) { + throw new PromptValidationError('Please select a valid option.'); + } + + const numeric = Number.parseInt(key, 10); + + if (!Number.isNaN(numeric) && choices[numeric - 1] && !choices[numeric - 1]?.disabled) { + return choices[numeric - 1].value; + } + + if (key === Key.down || key === Key.downArrow || key === Key.ctrlN) { + selected = nextEnabledIndex(choices, selected, 1); + renderInteractiveChoices(message, choices, selected); + continue; + } + + if (key === Key.up || key === Key.upArrow || key === Key.ctrlP) { + selected = nextEnabledIndex(choices, selected, -1); + renderInteractiveChoices(message, choices, selected); + continue; + } + + if (key === Key.enter) { + const choice = choices[selected]; + + if (!choice || choice.disabled) { + throw new PromptValidationError('Please select a valid option.'); + } + + return choice.value; + } + } +}; + +const readMultipleChoices = async (message: string, choices: Array>, defaults: T[] = [], hint?: string): Promise => { + const environment = promptEnvironment(); + const selectedValues = new Set(defaults); + + if (!environment.input.readKey) { + const rendered = renderChoices(choices); + const answer = await ask(`${message}\n${rendered}\n`, hint); + + if (answer.trim() === '') { + return defaults; + } + + return answer + .split(',') + .map((part) => findChoice(choices, part.trim())) + .filter((choice): choice is Choice => choice !== undefined && !choice.disabled) + .map((choice) => choice.value); + } + + let selected = firstEnabledIndex(choices); + const marked = new Set(choices.flatMap((choice, index) => selectedValues.has(choice.value) ? [index] : [])); + renderInteractiveChoices(message, choices, selected, marked); + + while (true) { + const key = await environment.input.readKey(); + + if (key === null) { + return [...marked].map((index) => choices[index]?.value).filter((value): value is T => value !== undefined); + } + + if (key.includes(',')) { + return key + .split(',') + .map((part) => findChoice(choices, part.trim())) + .filter((choice): choice is Choice => choice !== undefined && !choice.disabled) + .map((choice) => choice.value); + } + + const numeric = Number.parseInt(key, 10); + + if (!Number.isNaN(numeric) && choices[numeric - 1] && !choices[numeric - 1]?.disabled) { + const index = numeric - 1; + + if (marked.has(index)) { + marked.delete(index); + } else { + marked.add(index); + } + + renderInteractiveChoices(message, choices, selected, marked); + continue; + } + + if (key === Key.down || key === Key.downArrow || key === Key.ctrlN) { + selected = nextEnabledIndex(choices, selected, 1); + renderInteractiveChoices(message, choices, selected, marked); + continue; + } + + if (key === Key.up || key === Key.upArrow || key === Key.ctrlP) { + selected = nextEnabledIndex(choices, selected, -1); + renderInteractiveChoices(message, choices, selected, marked); + continue; + } + + if (key === Key.space) { + if (marked.has(selected)) { + marked.delete(selected); + } else if (!choices[selected]?.disabled) { + marked.add(selected); + } + + renderInteractiveChoices(message, choices, selected, marked); + continue; + } + + if (key === Key.enter) { + return [...marked].map((index) => choices[index]?.value).filter((value): value is T => value !== undefined); + } + } +}; + +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 +): Promise; +export async function confirm( + message: string | ConfirmPromptOptions, + defaultValue = true, + yes = 'Yes', + no = 'No', + required: boolean | string = false, + validate: ConfirmPromptOptions['validate'] = undefined, + hint = '' +): Promise { + const options: ConfirmPromptOptions = typeof message === 'string' + ? { message, label: message, default: defaultValue, yes, no, required, validate, hint } + : { ...message, default: message.default ?? true }; return promptUntilValid(options, async () => { const suffix = options.default === false ? ' [y/N]' : ' [Y/n]'; @@ -46,20 +244,19 @@ export const confirm = async (message: string | ConfirmPromptOptions): Promise(options: SelectPromptOptions): Promise => { const choices = normalizeChoices(options.options); return promptUntilValid(options, async () => { - const rendered = renderChoices(choices); - const answer = await ask(`${options.message}\n${rendered}\n`, options.hint); - const choice = findChoice(choices, answer); - const selected = choice?.value ?? options.default ?? choices.find((candidate) => !candidate.disabled)?.value; + const selected = await readSelectedChoice(options.message, choices, options.hint).catch((error: unknown) => { + if (options.default !== undefined && error instanceof PromptValidationError) { + return options.default; + } - if (selected === undefined) { - throw new PromptValidationError('Please select a valid option.'); - } + throw error; + }); return selected; }); @@ -69,18 +266,7 @@ export const multiselect = async (options: MultiSelectPromptOptions): Prom const choices = normalizeChoices(options.options); return promptUntilValid(options, async () => { - const rendered = renderChoices(choices); - const answer = await ask(`${options.message}\n${rendered}\n`, options.hint); - - if (answer.trim() === '' && options.default !== undefined) { - return options.default; - } - - return answer - .split(',') - .map((part) => findChoice(choices, part.trim())) - .filter((choice): choice is Choice => choice !== undefined && !choice.disabled) - .map((choice) => choice.value); + return readMultipleChoices(options.message, choices, options.default, options.hint); }); }; diff --git a/packages/tui/src/status.ts b/packages/tui/src/status.ts index 7e04af8c..dc274daf 100644 --- a/packages/tui/src/status.ts +++ b/packages/tui/src/status.ts @@ -38,23 +38,89 @@ export class Progress { } } -export const progress = (total: number, message?: string): Progress => new Progress(total, message); +export function progress(total: number, message?: string): Progress; +export function progress(label: string, steps: Iterable | number, callback?: (step: T | number, progress: Progress) => MaybePromise, hint?: string): Progress | Promise; +export function progress( + labelOrTotal: string | number, + stepsOrMessage?: Iterable | number | string, + callback?: (step: T | number, progress: Progress) => MaybePromise, + _hint = '' +): Progress | Promise { + if (typeof labelOrTotal === 'number') { + return new Progress(labelOrTotal, typeof stepsOrMessage === 'string' ? stepsOrMessage : undefined); + } + + const steps = stepsOrMessage ?? 0; + if (typeof steps === 'string') { + throw new Error('Progress steps must be an iterable or a number.'); + } + + const total = typeof steps === 'number' ? steps : Array.from(steps).length; + const bar = new Progress(total, labelOrTotal); + + if (!callback) { + return bar; + } + + return (async () => { + const values = typeof steps === 'number' ? Array.from({ length: steps }, (_, index) => index + 1) : Array.from(steps); + const results: R[] = []; + + for (const value of values) { + results.push(await callback(value, bar)); + bar.advance(); + } + + return results; + })(); +} export type TaskDefinition = { title: string; task: () => MaybePromise; }; -export const task = async (definition: TaskDefinition): Promise => { - promptEnvironment().output.write(`${definition.title}\n`); - const result = await definition.task(); - promptEnvironment().output.write(`Done: ${definition.title}\n`); +export async function task(definition: TaskDefinition): Promise; +export async function task(label: string, callback: () => MaybePromise, limit?: number, keepSummary?: boolean, subLabel?: string): Promise; +export async function task( + definitionOrLabel: TaskDefinition | string, + callback?: () => MaybePromise, + _limit = 10, + _keepSummary = false, + subLabel?: string +): Promise { + const title = typeof definitionOrLabel === 'string' ? definitionOrLabel : definitionOrLabel.title; + const run = typeof definitionOrLabel === 'string' ? callback : definitionOrLabel.task; + + if (!run) { + throw new Error('A task callback is required.'); + } + + promptEnvironment().output.write(`${title}${subLabel ? ` ${subLabel}` : ''}\n`); + const result = await run(); + promptEnvironment().output.write(`Done: ${title}\n`); return result; -}; +} -export const stream = async (source: AsyncIterable | Iterable): Promise => { - for await (const chunk of source) { - promptEnvironment().output.write(chunk); +export class Stream { + write(content: string): this { + promptEnvironment().output.write(content); + + return this; } -}; + + async pipe(source: AsyncIterable | Iterable): Promise { + for await (const chunk of source) { + this.write(chunk); + } + } +} + +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/typed-value.ts b/packages/tui/src/typed-value.ts new file mode 100644 index 00000000..e49948f3 --- /dev/null +++ b/packages/tui/src/typed-value.ts @@ -0,0 +1,140 @@ +import { promptEnvironment } from '#tui/environment'; +import { Key, oneOf } from '#tui/key'; +import { PromptValidationError } from '#tui/prompt'; +import { renderQuestion } from '#tui/theme'; + +export type TypedValueState = { + cursor: number; + value: string; +}; + +const characters = (value: string): string[] => [...value]; + +const fromCharacters = (value: string[]): string => value.join(''); + +const isPrintable = (key: string): boolean => { + return [...key].every((character) => { + const code = character.codePointAt(0) ?? 0; + + return code >= 32 && code !== 127; + }); +}; + +export const applyTypedKey = (state: TypedValueState, key: string, allowNewLine = false): TypedValueState & { submitted: boolean; cancelled: boolean } => { + const value = characters(state.value); + let cursor = state.cursor; + + if (key === Key.ctrlC) { + return { cursor, value: fromCharacters(value), submitted: false, cancelled: true }; + } + + if (key === Key.enter) { + if (!allowNewLine) { + return { cursor, value: fromCharacters(value), submitted: true, cancelled: false }; + } + + value.splice(cursor, 0, '\n'); + cursor += 1; + + return { cursor, value: fromCharacters(value), submitted: false, cancelled: false }; + } + + if (key === Key.left || key === Key.leftArrow || key === Key.ctrlB) { + return { cursor: Math.max(0, cursor - 1), value: fromCharacters(value), submitted: false, cancelled: false }; + } + + if (key === Key.right || key === Key.rightArrow || key === Key.ctrlF) { + return { cursor: Math.min(value.length, cursor + 1), value: fromCharacters(value), submitted: false, cancelled: false }; + } + + if (oneOf([Key.home, Key.ctrlA], key)) { + return { cursor: 0, value: fromCharacters(value), submitted: false, cancelled: false }; + } + + if (oneOf([Key.end, Key.ctrlE], key)) { + return { cursor: value.length, value: fromCharacters(value), submitted: false, cancelled: false }; + } + + if (key === Key.delete) { + value.splice(cursor, 1); + + return { cursor, value: fromCharacters(value), submitted: false, cancelled: false }; + } + + if (key === Key.backspace || key === Key.ctrlH) { + if (cursor === 0) { + return { cursor, value: fromCharacters(value), submitted: false, cancelled: false }; + } + + value.splice(cursor - 1, 1); + + return { cursor: cursor - 1, value: fromCharacters(value), submitted: false, cancelled: false }; + } + + if (key === Key.optionBackspace) { + const before = fromCharacters(value.slice(0, cursor)); + const match = before.match(/(?:[\p{L}\p{M}\p{N}]+|[^\p{L}\p{M}\p{N}\s]+)\s*$/u); + const start = match?.index ?? 0; + + value.splice(start, cursor - start); + + return { cursor: start, value: fromCharacters(value), submitted: false, cancelled: false }; + } + + if (isPrintable(key)) { + for (const character of characters(key)) { + value.splice(cursor, 0, character); + cursor += 1; + } + } + + return { cursor, value: fromCharacters(value), submitted: false, cancelled: false }; +}; + +export const readTypedValue = async (message: string, options: { default?: string; hint?: string; allowNewLine?: boolean } = {}): Promise => { + const environment = promptEnvironment(); + + if (!environment.input.readKey) { + 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 answer === '' && options.default !== undefined ? options.default : answer; + } + + environment.output.write(renderQuestion(message, options.hint)); + + let state: TypedValueState = { + cursor: options.default?.length ?? 0, + value: options.default ?? '' + }; + + while (true) { + const key = await environment.input.readKey(); + + if (key === null) { + return state.value; + } + + const next = applyTypedKey(state, key, options.allowNewLine); + + if (next.cancelled) { + environment.error.write('Cancelled.\n'); + + return state.value; + } + + state = { + cursor: next.cursor, + value: next.value + }; + + if (next.submitted) { + environment.output.write('\n'); + + return state.value; + } + } +}; diff --git a/packages/tui/src/types.ts b/packages/tui/src/types.ts index 3a7ac1bd..da7e9c33 100644 --- a/packages/tui/src/types.ts +++ b/packages/tui/src/types.ts @@ -11,7 +11,8 @@ export type PromptOutput = { }; export type PromptInput = { - readLine(message: string): Promise; + readKey?(): Promise; + readLine?(message: string): Promise; }; export type PromptEnvironment = { @@ -39,30 +40,46 @@ export type Choice = { export type ChoiceInput = Choice | T; export type TextPromptOptions = BasePromptOptions & { + label?: string; placeholder?: string; + transform?: (value: string) => MaybePromise; }; export type NumberPromptOptions = BasePromptOptions & { + label?: string; min?: number; max?: number; integer?: boolean; + step?: number; + transform?: (value: number) => MaybePromise; }; export type ConfirmPromptOptions = BasePromptOptions & { + label?: string; yes?: string; no?: string; }; export type SelectPromptOptions = BasePromptOptions & { + label?: string; options: Array>; + scroll?: number; + info?: string | ((value: T) => string); }; export type MultiSelectPromptOptions = BasePromptOptions & { + label?: string; options: Array>; + scroll?: number; + info?: string | ((value: T[]) => string); }; export type SearchPromptOptions = BasePromptOptions & { + label?: string; options: Array> | ((query: string) => MaybePromise>>); + placeholder?: string; + scroll?: number; + info?: string | ((value: T) => string); }; export type TableOptions = { diff --git a/packages/tui/tests/choices.test.ts b/packages/tui/tests/choices.test.ts new file mode 100644 index 00000000..f38809c3 --- /dev/null +++ b/packages/tui/tests/choices.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; +import { createMemoryOutput, createScriptedInput, Key, multiselect, select, withPromptEnvironment } from '../src/index'; + +describe('choice prompts', () => { + it('selects with arrow keys and enter', async () => { + const output = createMemoryOutput(); + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.enter]), + output, + error: output, + interactive: true + }, + () => select({ message: 'Pick one', options: ['first', 'second'] }) + ); + + expect(result).toBe('second'); + expect(output.text()).toContain('Pick one'); + }); + + it('toggles multiselect choices with the space bar', async () => { + const output = createMemoryOutput(); + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.space, Key.down, Key.space, Key.enter]), + output, + error: output, + interactive: true + }, + () => multiselect({ message: 'Pick many', options: ['first', 'second'] }) + ); + + expect(result).toEqual(['first', 'second']); + }); +}); diff --git a/packages/tui/tests/typed-value.test.ts b/packages/tui/tests/typed-value.test.ts new file mode 100644 index 00000000..4ea09e57 --- /dev/null +++ b/packages/tui/tests/typed-value.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { applyTypedKey, Key } from '../src/index'; + +const apply = (keys: string[]): string => { + let state = { cursor: 0, value: '' }; + + for (const key of keys) { + const next = applyTypedKey(state, key); + state = { cursor: next.cursor, value: next.value }; + } + + return state.value; +}; + +describe('typed value editing', () => { + it('inserts buffered characters', () => { + expect(apply(['Je', 'ss'])).toBe('Jess'); + }); + + it('handles backspace and delete', () => { + expect(apply(['J', 'e', 'z', Key.backspace, 's', 's'])).toBe('Jess'); + expect(apply(['J', 'e', 'z', Key.left, Key.delete, 's', 's'])).toBe('Jess'); + }); + + it('supports emacs-style movement keys', () => { + expect(apply(['J', 'z', 'e', Key.ctrlB, Key.ctrlH, Key.ctrlF, 's', 's'])).toBe('Jess'); + }); + + it('moves to the start and end of a line', () => { + expect(apply(['A', 'r', Key.home[0], 'J', Key.end[0], 'c', 'h', 'e', 'r'])).toBe('JArcher'); + }); +}); From 005f54e24abfc24a93b81524091df2bb27318cc6 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 13:37:44 +0800 Subject: [PATCH 003/675] Add suggest completion behavior --- UPSTREAM.md | 4 +- packages/tui/src/prompts/choices.ts | 181 ++++++++++++++++++++++++++-- packages/tui/src/types.ts | 1 + packages/tui/tests/suggest.test.ts | 35 ++++++ 4 files changed, 212 insertions(+), 9 deletions(-) create mode 100644 packages/tui/tests/suggest.test.ts diff --git a/UPSTREAM.md b/UPSTREAM.md index e9787f60..a2994c82 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -28,7 +28,7 @@ idiomatic TypeScript and OpenTUI internals. The following upstream files still need full line-by-line behavioral mapping in later phases: -- `src/AutoCompletePrompt.php` +- `src/AutoCompletePrompt.php` advanced ghost-text rendering - `src/DataTablePrompt.php` - `src/FormBuilder.php` - `src/MultiSearchPrompt.php` @@ -36,7 +36,7 @@ later phases: - `src/NotifyPrompt.php` - `src/Progress.php` - `src/Prompt.php` -- `src/SearchPrompt.php` +- `src/SearchPrompt.php` full search lifecycle and highlighted result labels - `src/Spinner.php` - `src/Stream.php` - `src/Task.php` diff --git a/packages/tui/src/prompts/choices.ts b/packages/tui/src/prompts/choices.ts index f37b7fda..2fb8741a 100644 --- a/packages/tui/src/prompts/choices.ts +++ b/packages/tui/src/prompts/choices.ts @@ -1,11 +1,13 @@ import { ask, promptUntilValid, PromptValidationError } from '#tui/prompt'; import { promptEnvironment } from '#tui/environment'; -import { Key } from '#tui/key'; +import { Key, oneOf } from '#tui/key'; import { renderChoices } from '#tui/theme'; +import { applyTypedKey } from '#tui/typed-value'; import type { Choice, ChoiceInput, ConfirmPromptOptions, + MaybePromise, MultiSelectPromptOptions, SearchPromptOptions, SelectPromptOptions, @@ -270,15 +272,154 @@ export const multiselect = async (options: MultiSelectPromptOptions): Prom }); }; -export const suggest = async (message: string | TextPromptOptions): Promise => { - const options: TextPromptOptions = typeof message === 'string' ? { message } : message; +type SuggestOptions = TextPromptOptions & { + options: string[] | ((query: string) => MaybePromise); + scroll?: number; + info?: string | ((value: string) => string); +}; + +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 +): SuggestOptions => { + if (typeof message === 'string') { + return { message, label: message, options, placeholder, default: defaultValue, scroll, required, validate, hint, transform }; + } + + return { ...message, default: message.default ?? '' }; +}; + +const resolveSuggestions = async (source: SuggestOptions['options'], query: string): Promise => { + const options = typeof source === 'function' ? await source(query) : source; + + if (typeof source === 'function') { + return [...options]; + } + + return options.filter((option: string) => option.toLowerCase().startsWith(query.toLowerCase())); +}; + +const readSuggestionValue = async (options: SuggestOptions): Promise => { + const environment = promptEnvironment(); + + if (!environment.input.readKey) { + return ask(options.message, options.hint); + } + + let state = { + cursor: options.default?.length ?? 0, + value: options.default ?? '' + }; + let highlighted: number | null = null; + let matches: string[] = await resolveSuggestions(options.options, state.value); + + environment.output.write(`${options.message}\n`); + + while (true) { + const key = await environment.input.readKey(); + + if (key === null) { + return state.value; + } + + if (key === Key.tab) { + matches = await resolveSuggestions(options.options, state.value); + const match = matches[highlighted ?? 0]; + + if (match !== undefined) { + state = { cursor: match.length, value: match }; + } + + continue; + } + + if (key === Key.down || key === Key.downArrow || key === Key.ctrlN || key === Key.shiftTab) { + matches = await resolveSuggestions(options.options, state.value); + highlighted = matches.length === 0 ? null : ((highlighted ?? -1) + 1) % matches.length; + continue; + } + + if (key === Key.up || key === Key.upArrow || key === Key.ctrlP) { + matches = await resolveSuggestions(options.options, state.value); + highlighted = matches.length === 0 ? null : ((highlighted ?? matches.length) - 1 + matches.length) % matches.length; + continue; + } + + if (oneOf([Key.home, Key.ctrlA], key) && highlighted !== null) { + highlighted = 0; + continue; + } + + if (oneOf([Key.end, Key.ctrlE], key) && highlighted !== null) { + highlighted = Math.max(0, matches.length - 1); + continue; + } + + if (key === Key.enter) { + if (highlighted !== null && matches[highlighted] !== undefined) { + return matches[highlighted]; + } + + return state.value; + } + + const next = applyTypedKey(state, key); + + if (next.submitted) { + return state.value; + } + + if (next.cancelled) { + environment.error.write('Cancelled.\n'); + + return state.value; + } + + state = { cursor: next.cursor, value: next.value }; + highlighted = null; + matches = await resolveSuggestions(options.options, state.value); + } +}; + +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'] +): 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 +): Promise { + const options = suggestOptions(message, source, placeholder, defaultValue, scroll, required, validate, hint, transform); return promptUntilValid(options, async () => { - const answer = await ask(options.message, options.hint); + const answer = await readSuggestionValue(options); + const value = answer === '' && options.default !== undefined ? options.default : answer; - return answer === '' && options.default !== undefined ? options.default : answer; + return options.transform ? options.transform(value) : value; }); -}; +} export const search = async (options: SearchPromptOptions): Promise => { return promptUntilValid(options, async () => { @@ -314,7 +455,33 @@ export const multisearch = async (options: SearchPromptOptions): Promise }); }; -export const autocomplete = async (options: SearchPromptOptions): Promise => search(options); +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'] +): 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 +): Promise { + return suggest( + typeof message === 'string' + ? { message, label: message, options: source, placeholder, default: defaultValue, required, validate, hint, transform } + : message + ); +} export const pause = async (message = 'Press enter to continue'): Promise => { await ask(message); diff --git a/packages/tui/src/types.ts b/packages/tui/src/types.ts index da7e9c33..6fe022a0 100644 --- a/packages/tui/src/types.ts +++ b/packages/tui/src/types.ts @@ -41,6 +41,7 @@ export type ChoiceInput = Choice | T; export type TextPromptOptions = BasePromptOptions & { label?: string; + options?: string[] | ((query: string) => MaybePromise); placeholder?: string; transform?: (value: string) => MaybePromise; }; diff --git a/packages/tui/tests/suggest.test.ts b/packages/tui/tests/suggest.test.ts new file mode 100644 index 00000000..d2d1b62e --- /dev/null +++ b/packages/tui/tests/suggest.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; +import { createMemoryOutput, createScriptedInput, Key, suggest, withPromptEnvironment } from '../src/index'; + +const runSuggest = async (keys: string[], options: string[] | ((query: string) => string[])): Promise => { + const output = createMemoryOutput(); + + return withPromptEnvironment( + { + input: createScriptedInput(keys), + output, + error: output, + interactive: true + }, + () => suggest('Favorite color?', options) + ); +}; + +describe('suggest prompt', () => { + it('accepts arbitrary typed input', async () => { + await expect(runSuggest(['B', 'l', 'a', 'c', 'k', Key.enter], ['Red', 'Green', 'Blue'])).resolves.toBe('Black'); + }); + + it('completes input using tab', async () => { + await expect(runSuggest(['b', Key.tab, Key.enter], ['Red', 'Green', 'Blue'])).resolves.toBe('Blue'); + }); + + it('navigates matches using arrow and emacs keys', async () => { + await expect(runSuggest(['b', Key.down, Key.down, Key.down, Key.up, Key.enter], ['Red', 'Blue', 'Black', 'Blurple'])).resolves.toBe('Black'); + await expect(runSuggest(['b', Key.ctrlN, Key.ctrlN, Key.ctrlN, Key.ctrlP, Key.enter], ['Red', 'Blue', 'Black', 'Blurple'])).resolves.toBe('Black'); + }); + + it('supports callback options', async () => { + await expect(runSuggest(['e', 'e', Key.down, Key.enter], (value) => ['Red', 'Green', 'Blue'].filter((option) => option.toLowerCase().includes(value.toLowerCase())))).resolves.toBe('Green'); + }); +}); From 8ad3af5ac1b023bc9937b37c33160d3ba7d6361c Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 13:40:25 +0800 Subject: [PATCH 004/675] Add search selection behavior --- UPSTREAM.md | 4 +- packages/tui/src/prompt.ts | 8 +- packages/tui/src/prompts/choices.ts | 158 ++++++++++++++++++++++++++-- packages/tui/src/types.ts | 2 +- packages/tui/tests/search.test.ts | 75 +++++++++++++ 5 files changed, 233 insertions(+), 14 deletions(-) create mode 100644 packages/tui/tests/search.test.ts diff --git a/UPSTREAM.md b/UPSTREAM.md index a2994c82..0681ada9 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -31,12 +31,12 @@ later phases: - `src/AutoCompletePrompt.php` advanced ghost-text rendering - `src/DataTablePrompt.php` - `src/FormBuilder.php` -- `src/MultiSearchPrompt.php` +- `src/MultiSearchPrompt.php` full rendering and selected-result summary parity - `src/MultiSelectPrompt.php` - `src/NotifyPrompt.php` - `src/Progress.php` - `src/Prompt.php` -- `src/SearchPrompt.php` full search lifecycle and highlighted result labels +- `src/SearchPrompt.php` full rendering and highlighted result label parity - `src/Spinner.php` - `src/Stream.php` - `src/Task.php` diff --git a/packages/tui/src/prompt.ts b/packages/tui/src/prompt.ts index 7e2600b8..a2261ddd 100644 --- a/packages/tui/src/prompt.ts +++ b/packages/tui/src/prompt.ts @@ -35,7 +35,7 @@ export const ensureRequired = (value: T, required?: boolean | string): string export const promptUntilValid = async ( options: BasePromptOptions, - read: () => Promise + read: (attempt: number) => Promise ): Promise => { const environment = promptEnvironment(); @@ -51,11 +51,13 @@ export const promptUntilValid = async ( return value; } + let attempt = 0; + while (true) { let value: T; try { - value = await read(); + value = await read(attempt); } catch (error) { if (error instanceof PromptValidationError) { if (!environment.interactive) { @@ -63,6 +65,7 @@ export const promptUntilValid = async ( } environment.error.write(renderError(error.message)); + attempt += 1; continue; } @@ -81,6 +84,7 @@ export const promptUntilValid = async ( } environment.error.write(renderError(validation)); + attempt += 1; } }; diff --git a/packages/tui/src/prompts/choices.ts b/packages/tui/src/prompts/choices.ts index 2fb8741a..111c15df 100644 --- a/packages/tui/src/prompts/choices.ts +++ b/packages/tui/src/prompts/choices.ts @@ -27,6 +27,17 @@ export const normalizeChoices = (options: Array>): Array(options: Array> | Record): Array> => { + if (Array.isArray(options)) { + return normalizeChoices(options); + } + + return Object.entries(options).map(([value, label]) => ({ + label, + value: value as T + })); +}; + const findChoice = (choices: Array>, answer: string): Choice | undefined => { const index = Number.parseInt(answer, 10); @@ -422,12 +433,8 @@ export async function suggest( } export const search = async (options: SearchPromptOptions): Promise => { - return promptUntilValid(options, async () => { - const query = await ask(options.message, options.hint); - const source = typeof options.options === 'function' ? await options.options(query) : options.options; - const choices = normalizeChoices(source); - const choice = findChoice(choices, query) ?? choices.find((candidate) => !candidate.disabled); - const selected = choice?.value ?? options.default ?? choices[0]?.value; + return promptUntilValid(options, async (attempt) => { + const selected = await readSearchChoice(options, attempt); if (selected === undefined) { throw new PromptValidationError('Please select a valid option.'); @@ -439,9 +446,94 @@ export const search = async (options: SearchPromptOptions): Promise => export const multisearch = async (options: SearchPromptOptions): Promise => { return promptUntilValid(options, async () => { + return readMultiSearchChoices(options); + }); +}; + +const resolveSearchChoices = async (source: SearchPromptOptions['options'], query: string): Promise>> => { + const options = typeof source === 'function' ? await source(query) : source; + + return normalizeSearchChoices(options); +}; + +const readSearchChoice = async (options: SearchPromptOptions, attempt = 0): Promise => { + const environment = promptEnvironment(); + + if (!environment.input.readKey) { + const query = await ask(options.message, options.hint); + const choices = await resolveSearchChoices(options.options, query); + const choice = findChoice(choices, query) ?? choices.find((candidate) => !candidate.disabled); + + return choice?.value ?? options.default ?? choices[0]?.value; + } + + let state = { cursor: 0, value: '' }; + let choices = await resolveSearchChoices(options.options, state.value); + let highlighted: number | null = null; + + environment.output.write(`${options.message}\n`); + + while (true) { + const key = await environment.input.readKey(); + + if (key === null) { + return options.default; + } + + if (key === Key.down || key === Key.downArrow || key === Key.ctrlN || key === Key.tab) { + choices = await resolveSearchChoices(options.options, state.value); + highlighted = choices.length === 0 ? null : ((highlighted ?? (attempt > 0 ? 0 : -1)) + 1) % choices.length; + renderInteractiveChoices(options.message, choices, highlighted ?? 0); + continue; + } + + if (key === Key.up || key === Key.upArrow || key === Key.ctrlP || key === Key.shiftTab) { + choices = await resolveSearchChoices(options.options, state.value); + highlighted = choices.length === 0 ? null : ((highlighted ?? choices.length) - 1 + choices.length) % choices.length; + renderInteractiveChoices(options.message, choices, highlighted ?? 0); + continue; + } + + if (oneOf([Key.home, Key.ctrlA], key) && highlighted !== null) { + highlighted = 0; + continue; + } + + if (oneOf([Key.end, Key.ctrlE], key) && highlighted !== null) { + highlighted = Math.max(0, choices.length - 1); + continue; + } + + if (key === Key.enter) { + if (highlighted !== null) { + return choices[highlighted]?.value; + } + + choices = await resolveSearchChoices(options.options, state.value); + highlighted = choices.length > 0 ? 0 : null; + continue; + } + + const next = applyTypedKey(state, key); + + if (next.cancelled) { + environment.error.write('Cancelled.\n'); + + return options.default; + } + + state = { cursor: next.cursor, value: next.value }; + highlighted = null; + choices = await resolveSearchChoices(options.options, state.value); + } +}; + +const readMultiSearchChoices = async (options: SearchPromptOptions): Promise => { + const environment = promptEnvironment(); + + if (!environment.input.readKey) { const query = await ask(options.message, options.hint); - const source = typeof options.options === 'function' ? await options.options(query) : options.options; - const choices = normalizeChoices(source); + const choices = await resolveSearchChoices(options.options, query); if (query.trim() === '' && options.default !== undefined) { return options.default; @@ -452,7 +544,55 @@ export const multisearch = async (options: SearchPromptOptions): Promise .map((part) => findChoice(choices, part.trim())) .filter((choice): choice is Choice => choice !== undefined && !choice.disabled) .flatMap((choice) => choice.value); - }); + } + + let state = { cursor: 0, value: '' }; + let choices = await resolveSearchChoices(options.options, state.value); + let highlighted: number | null = null; + const selected = new Map(); + + environment.output.write(`${options.message}\n`); + + while (true) { + const key = await environment.input.readKey(); + + if (key === null || key === Key.enter) { + return [...selected.keys()]; + } + + if (key === Key.down || key === Key.downArrow || key === Key.ctrlN || key === Key.tab) { + choices = await resolveSearchChoices(options.options, state.value); + highlighted = choices.length === 0 ? null : ((highlighted ?? -1) + 1) % choices.length; + renderInteractiveChoices(options.message, choices, highlighted ?? 0); + continue; + } + + if (key === Key.up || key === Key.upArrow || key === Key.ctrlP || key === Key.shiftTab) { + choices = await resolveSearchChoices(options.options, state.value); + highlighted = choices.length === 0 ? null : ((highlighted ?? choices.length) - 1 + choices.length) % choices.length; + renderInteractiveChoices(options.message, choices, highlighted ?? 0); + continue; + } + + if (key === Key.space && highlighted !== null) { + const choice = choices[highlighted]; + + if (choice) { + if (selected.has(choice.value as T)) { + selected.delete(choice.value as T); + } else { + selected.set(choice.value as T, choice.label); + } + } + + continue; + } + + const next = applyTypedKey(state, key); + state = { cursor: next.cursor, value: next.value }; + choices = await resolveSearchChoices(options.options, state.value); + highlighted = null; + } }; export function autocomplete(options: SuggestOptions): Promise; diff --git a/packages/tui/src/types.ts b/packages/tui/src/types.ts index 6fe022a0..48d0f036 100644 --- a/packages/tui/src/types.ts +++ b/packages/tui/src/types.ts @@ -77,7 +77,7 @@ export type MultiSelectPromptOptions = BasePromptOptions & { export type SearchPromptOptions = BasePromptOptions & { label?: string; - options: Array> | ((query: string) => MaybePromise>>); + options: Array> | Record | ((query: string) => MaybePromise> | Record>); placeholder?: string; scroll?: number; info?: string | ((value: T) => string); diff --git a/packages/tui/tests/search.test.ts b/packages/tui/tests/search.test.ts new file mode 100644 index 00000000..fa7fc95a --- /dev/null +++ b/packages/tui/tests/search.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; +import { createMemoryOutput, createScriptedInput, Key, multisearch, search, withPromptEnvironment } from '../src/index'; + +const colors = (value: string): Record => { + const options = { + red: 'Red', + green: 'Green', + blue: 'Blue' + }; + + return Object.fromEntries(Object.entries(options).filter(([, label]) => label.toLowerCase().includes(value.toLowerCase()))); +}; + +describe('search prompt', () => { + it('returns keys from associative options', async () => { + const output = createMemoryOutput(); + const result = await withPromptEnvironment( + { + input: createScriptedInput(['u', 'e', Key.down, Key.enter]), + output, + error: output, + interactive: true + }, + () => search({ message: 'Favorite color?', options: colors }) + ); + + expect(result).toBe('blue'); + }); + + it('validates selected values and allows another selection', async () => { + const output = createMemoryOutput(); + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.enter, Key.down, Key.enter]), + output, + error: output, + interactive: true + }, + () => + search({ + message: 'Favorite color?', + options: () => ({ red: 'Red', green: 'Green', blue: 'Blue' }), + validate: (value) => (value === 'red' ? 'Please choose green.' : null) + }) + ); + + expect(result).toBe('green'); + expect(output.text()).toContain('Please choose green.'); + }); +}); + +describe('multisearch prompt', () => { + it('toggles highlighted search results', async () => { + const output = createMemoryOutput(); + const result = await withPromptEnvironment( + { + input: createScriptedInput(['V', Key.down, Key.space, Key.backspace, 'G', 'r', Key.down, Key.space, Key.enter]), + output, + error: output, + interactive: true + }, + () => + multisearch({ + message: 'Favorite colors?', + options: (value) => { + const options = { green: 'Green', violet: 'Violet' }; + + return Object.fromEntries(Object.entries(options).filter(([, label]) => label.toLowerCase().includes(value.toLowerCase()))); + } + }) + ); + + expect(result).toEqual(['violet', 'green']); + }); +}); From 1d319d7f274f6091bbd40aea03cc916240bb2b79 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 13:41:51 +0800 Subject: [PATCH 005/675] Improve progress runtime behavior --- UPSTREAM.md | 2 +- packages/acceptance/tests/helpers.test.ts | 3 +- packages/tui/src/status.ts | 56 ++++++++++++++++++++--- packages/tui/tests/progress.test.ts | 32 +++++++++++++ 4 files changed, 84 insertions(+), 9 deletions(-) create mode 100644 packages/tui/tests/progress.test.ts diff --git a/UPSTREAM.md b/UPSTREAM.md index 0681ada9..ba908638 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -34,7 +34,7 @@ later phases: - `src/MultiSearchPrompt.php` full rendering and selected-result summary parity - `src/MultiSelectPrompt.php` - `src/NotifyPrompt.php` -- `src/Progress.php` +- `src/Progress.php` exact box drawing and signal handling parity - `src/Prompt.php` - `src/SearchPrompt.php` full rendering and highlighted result label parity - `src/Spinner.php` diff --git a/packages/acceptance/tests/helpers.test.ts b/packages/acceptance/tests/helpers.test.ts index f3ae61c6..b2a5fc5c 100644 --- a/packages/acceptance/tests/helpers.test.ts +++ b/packages/acceptance/tests/helpers.test.ts @@ -109,7 +109,8 @@ describe('Laravel-style helpers', () => { }); expect(output.text()).toContain('Working'); - expect(output.text()).toContain('Files: 2/2'); + expect(output.text()).toContain('Files:'); + expect(output.text()).toContain('2 / 2'); expect(output.text()).toContain('ab'); }); }); diff --git a/packages/tui/src/status.ts b/packages/tui/src/status.ts index dc274daf..91af6a9a 100644 --- a/packages/tui/src/status.ts +++ b/packages/tui/src/status.ts @@ -17,25 +17,64 @@ export const spin = async (callback: () => MaybePromise, options: StatusOp export class Progress { #current = 0; + #label: string; + #hint: string; constructor( readonly total: number, - readonly message = 'Progress' - ) {} + message = 'Progress', + hint = '' + ) { + if (total <= 0) { + throw new Error('Progress bar must have at least one item.'); + } + + this.#label = message; + this.#hint = hint; + } + + start(): void { + this.render(); + } advance(step = 1): void { this.#current = Math.min(this.total, this.#current + step); - promptEnvironment().output.write(`${this.message}: ${this.#current}/${this.total}\n`); + this.render(); } finish(): void { this.#current = this.total; - promptEnvironment().output.write(`${this.message}: ${this.total}/${this.total}\n`); + this.render(); + } + + label(value: string): this { + this.#label = value; + + return this; + } + + hint(value: string): this { + this.#hint = value; + + return this; + } + + percentage(): number { + return this.#current / this.total; } value(): number { return this.#current; } + + render(): void { + const width = 20; + const filled = Math.round(width * this.percentage()); + const bar = `${'█'.repeat(filled)}${' '.repeat(width - filled)}`; + const hint = this.#hint ? ` ${this.#hint}` : ''; + + promptEnvironment().output.write(`${this.#label}: ${bar} ${this.#current} / ${this.total}${hint}\n`); + } } export function progress(total: number, message?: string): Progress; @@ -55,22 +94,25 @@ export function progress( throw new Error('Progress steps must be an iterable or a number.'); } - const total = typeof steps === 'number' ? steps : Array.from(steps).length; - const bar = new Progress(total, labelOrTotal); + const values = typeof steps === 'number' ? Array.from({ length: steps }, (_, index) => index) : Array.from(steps); + const bar = new Progress(values.length, labelOrTotal, _hint); if (!callback) { return bar; } return (async () => { - const values = typeof steps === 'number' ? Array.from({ length: steps }, (_, index) => index + 1) : Array.from(steps); const results: R[] = []; + bar.start(); + for (const value of values) { results.push(await callback(value, bar)); bar.advance(); } + bar.finish(); + return results; })(); } diff --git a/packages/tui/tests/progress.test.ts b/packages/tui/tests/progress.test.ts new file mode 100644 index 00000000..8e3d976a --- /dev/null +++ b/packages/tui/tests/progress.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { createMemoryOutput, progress, withPromptEnvironment } from '../src/index'; + +describe('progress helper', () => { + it('maps iterable steps and returns callback results', async () => { + const output = createMemoryOutput(); + const result = await withPromptEnvironment({ output, error: output }, async () => { + return progress('Uppercasing States', ['Alabama', 'Alaska'], (state) => String(state).toUpperCase()); + }); + + expect(result).toEqual(['ALABAMA', 'ALASKA']); + expect(output.text()).toContain('Uppercasing States'); + expect(output.text()).toContain('2 / 2'); + }); + + it('supports manual progress updates with label and hint changes', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + const bar = progress('Adding States', 2); + + bar.start(); + bar.label('ALABAMA').hint('alabama').advance(); + bar.label('ALASKA').hint('alaska').advance(); + bar.finish(); + }); + + expect(output.text()).toContain('Adding States'); + expect(output.text()).toContain('ALABAMA'); + expect(output.text()).toContain('alaska'); + }); +}); From 9563b3da848219999e88f289babb901ee661eb74 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 13:45:06 +0800 Subject: [PATCH 006/675] Split prompt status concerns --- UPSTREAM.md | 3 +- packages/tui/src/concerns/choices.ts | 75 ++++++++++++ packages/tui/src/index.ts | 3 +- packages/tui/src/prompts/choices.ts | 106 ++++------------ packages/tui/src/status.ts | 173 +-------------------------- packages/tui/src/status/progress.ts | 104 ++++++++++++++++ packages/tui/src/status/spinner.ts | 16 +++ packages/tui/src/status/stream.ts | 23 ++++ packages/tui/src/status/task.ts | 70 +++++++++++ packages/tui/src/types.ts | 8 ++ packages/tui/tests/task.test.ts | 35 ++++++ 11 files changed, 362 insertions(+), 254 deletions(-) create mode 100644 packages/tui/src/concerns/choices.ts create mode 100644 packages/tui/src/status/progress.ts create mode 100644 packages/tui/src/status/spinner.ts create mode 100644 packages/tui/src/status/stream.ts create mode 100644 packages/tui/src/status/task.ts create mode 100644 packages/tui/tests/task.test.ts diff --git a/UPSTREAM.md b/UPSTREAM.md index ba908638..cd5db903 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -17,9 +17,10 @@ idiomatic TypeScript and OpenTUI internals. | Terminal helpers | `packages/tui/src/terminal.ts` | | Text, textarea, number, password | `packages/tui/src/prompts/basic.ts`, `packages/tui/src/typed-value.ts` | | Confirm, select, multiselect, suggest, search, multisearch, autocomplete | `packages/tui/src/prompts/choices.ts` | +| Choice normalization, matching, navigation, and list rendering concerns | `packages/tui/src/concerns/choices.ts` | | Form builder | `packages/tui/src/form.ts` | | Note, notify, table, grid, title, clear | `packages/tui/src/output.ts` | -| Spinner, progress, task, stream | `packages/tui/src/status.ts` | +| Spinner, progress, task, stream | `packages/tui/src/status/*`, `packages/tui/src/status.ts` | | ANSI, wrapping, truncation, Unicode width | `packages/tui/src/strings.ts` | | OpenTUI adapter | `packages/tui/src/opentui.ts` | diff --git a/packages/tui/src/concerns/choices.ts b/packages/tui/src/concerns/choices.ts new file mode 100644 index 00000000..26b5ac87 --- /dev/null +++ b/packages/tui/src/concerns/choices.ts @@ -0,0 +1,75 @@ +import { promptEnvironment } from '#tui/environment'; +import type { Choice, ChoiceInput } from '#tui/types'; + +export const normalizeChoices = (options: Array>): Array> => { + return options.map((choice) => { + if (typeof choice === 'object' && choice !== null && 'value' in choice && 'label' in choice) { + return choice; + } + + return { + label: String(choice), + value: choice as T + }; + }); +}; + +export const normalizeSearchChoices = (options: Array> | Record): Array> => { + if (Array.isArray(options)) { + return normalizeChoices(options); + } + + return Object.entries(options).map(([value, label]) => ({ + label, + value: value as T + })); +}; + +export const findChoice = (choices: Array>, answer: string): Choice | undefined => { + if (/^\d+$/u.test(answer)) { + const index = Number.parseInt(answer, 10); + + return choices[index - 1]; + } + + return choices.find((choice) => choice.label === answer || String(choice.value) === answer); +}; + +export const firstEnabledIndex = (choices: Array>): number => { + const index = choices.findIndex((choice) => !choice.disabled); + + return index === -1 ? 0 : index; +}; + +export const nextEnabledIndex = (choices: Array>, current: number, direction: 1 | -1): number => { + if (choices.length === 0) { + return 0; + } + + let index = current; + + for (let attempts = 0; attempts < choices.length; attempts += 1) { + index = (index + direction + choices.length) % choices.length; + + if (!choices[index]?.disabled) { + return index; + } + } + + return current; +}; + +export const renderInteractiveChoices = (message: string, choices: Array>, selected: number, marked: Set = new Set()): void => { + const environment = promptEnvironment(); + + environment.output.write(`${message}\n`); + + for (const [index, choice] of choices.entries()) { + const pointer = index === selected ? '›' : ' '; + const checked = marked.size > 0 ? (marked.has(index) ? '[x]' : '[ ]') : ' '; + const disabled = choice.disabled ? ` (${typeof choice.disabled === 'string' ? choice.disabled : 'disabled'})` : ''; + const hint = choice.hint ? ` ${choice.hint}` : ''; + + environment.output.write(`${pointer} ${checked} ${choice.label}${hint}${disabled}\n`); + } +}; diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index 0bc9adc3..2b5155cc 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -6,7 +6,7 @@ export { confirm, select, multiselect, suggest, search, multisearch, autocomplet export { number, password, text, textarea } from '#tui/prompts/basic'; export { alert, clear, dataTable, datatable, error, grid, info, intro, note, notify, outro, table, title, warning } from '#tui/output'; export { PromptValidationError } from '#tui/prompt'; -export { Progress, progress, spin, stream, task } from '#tui/status'; +export { Logger, Progress, Stream, progress, spin, stream, task } from '#tui/status'; export { clearTerminal, cursorToStart, eraseLine, setTerminalTitle, terminalSize } from '#tui/terminal'; export { applyTypedKey, readTypedValue } from '#tui/typed-value'; export { parseAnsiText, truncate, visibleWidth, wrap } from '#tui/strings'; @@ -16,6 +16,7 @@ export type { ChoiceInput, ConfirmPromptOptions, MaybePromise, + MultiSearchPromptOptions, MultiSelectPromptOptions, NumberPromptOptions, PromptEnvironment, diff --git a/packages/tui/src/prompts/choices.ts b/packages/tui/src/prompts/choices.ts index 111c15df..3c5a1f70 100644 --- a/packages/tui/src/prompts/choices.ts +++ b/packages/tui/src/prompts/choices.ts @@ -3,90 +3,18 @@ import { promptEnvironment } from '#tui/environment'; import { Key, oneOf } from '#tui/key'; import { renderChoices } from '#tui/theme'; import { applyTypedKey } from '#tui/typed-value'; +import { findChoice, firstEnabledIndex, nextEnabledIndex, normalizeChoices, normalizeSearchChoices, renderInteractiveChoices } from '#tui/concerns/choices'; import type { Choice, - ChoiceInput, ConfirmPromptOptions, MaybePromise, + MultiSearchPromptOptions, MultiSelectPromptOptions, SearchPromptOptions, SelectPromptOptions, TextPromptOptions } from '#tui/types'; -export const normalizeChoices = (options: Array>): Array> => { - return options.map((choice) => { - if (typeof choice === 'object' && choice !== null && 'value' in choice && 'label' in choice) { - return choice; - } - - return { - label: String(choice), - value: choice as T - }; - }); -}; - -const normalizeSearchChoices = (options: Array> | Record): Array> => { - if (Array.isArray(options)) { - return normalizeChoices(options); - } - - return Object.entries(options).map(([value, label]) => ({ - label, - value: value as T - })); -}; - -const findChoice = (choices: Array>, answer: string): Choice | undefined => { - const index = Number.parseInt(answer, 10); - - if (!Number.isNaN(index)) { - return choices[index - 1]; - } - - return choices.find((choice) => choice.label === answer || String(choice.value) === answer); -}; - -const firstEnabledIndex = (choices: Array>): number => { - const index = choices.findIndex((choice) => !choice.disabled); - - return index === -1 ? 0 : index; -}; - -const nextEnabledIndex = (choices: Array>, current: number, direction: 1 | -1): number => { - if (choices.length === 0) { - return 0; - } - - let index = current; - - for (let attempts = 0; attempts < choices.length; attempts += 1) { - index = (index + direction + choices.length) % choices.length; - - if (!choices[index]?.disabled) { - return index; - } - } - - return current; -}; - -const renderInteractiveChoices = (message: string, choices: Array>, selected: number, marked: Set = new Set()): void => { - const environment = promptEnvironment(); - - environment.output.write(`${message}\n`); - - for (const [index, choice] of choices.entries()) { - const pointer = index === selected ? '›' : ' '; - const checked = marked.size > 0 ? (marked.has(index) ? '[x]' : '[ ]') : ' '; - const disabled = choice.disabled ? ` (${typeof choice.disabled === 'string' ? choice.disabled : 'disabled'})` : ''; - const hint = choice.hint ? ` ${choice.hint}` : ''; - - environment.output.write(`${pointer} ${checked} ${choice.label}${hint}${disabled}\n`); - } -}; - const readSelectedChoice = async (message: string, choices: Array>, hint?: string): Promise => { const environment = promptEnvironment(); @@ -112,7 +40,7 @@ const readSelectedChoice = async (message: string, choices: Array>, throw new PromptValidationError('Please select a valid option.'); } - const numeric = Number.parseInt(key, 10); + const numeric = /^\d+$/u.test(key) ? Number.parseInt(key, 10) : Number.NaN; if (!Number.isNaN(numeric) && choices[numeric - 1] && !choices[numeric - 1]?.disabled) { return choices[numeric - 1].value; @@ -173,14 +101,19 @@ const readMultipleChoices = async (message: string, choices: Array> } if (key.includes(',')) { - return key + const selectedChoices = key .split(',') .map((part) => findChoice(choices, part.trim())) - .filter((choice): choice is Choice => choice !== undefined && !choice.disabled) - .map((choice) => choice.value); + .filter((choice): choice is Choice => choice !== undefined && !choice.disabled); + + if (selectedChoices.length !== key.split(',').length) { + throw new PromptValidationError('Please select valid options.'); + } + + return selectedChoices.map((choice) => choice.value); } - const numeric = Number.parseInt(key, 10); + const numeric = /^\d+$/u.test(key) ? Number.parseInt(key, 10) : Number.NaN; if (!Number.isNaN(numeric) && choices[numeric - 1] && !choices[numeric - 1]?.disabled) { const index = numeric - 1; @@ -444,7 +377,7 @@ export const search = async (options: SearchPromptOptions): Promise => }); }; -export const multisearch = async (options: SearchPromptOptions): Promise => { +export const multisearch = async (options: MultiSearchPromptOptions): Promise => { return promptUntilValid(options, async () => { return readMultiSearchChoices(options); }); @@ -528,7 +461,7 @@ const readSearchChoice = async (options: SearchPromptOptions, attempt = 0) } }; -const readMultiSearchChoices = async (options: SearchPromptOptions): Promise => { +const readMultiSearchChoices = async (options: MultiSearchPromptOptions): Promise => { const environment = promptEnvironment(); if (!environment.input.readKey) { @@ -539,11 +472,16 @@ const readMultiSearchChoices = async (options: SearchPromptOptions): Pro return options.default; } - return query + const selectedChoices = query .split(',') .map((part) => findChoice(choices, part.trim())) - .filter((choice): choice is Choice => choice !== undefined && !choice.disabled) - .flatMap((choice) => choice.value); + .filter((choice): choice is Choice => choice !== undefined && !choice.disabled); + + if (selectedChoices.length !== query.split(',').length) { + throw new PromptValidationError('Please select valid options.'); + } + + return selectedChoices.map((choice) => choice.value); } let state = { cursor: 0, value: '' }; diff --git a/packages/tui/src/status.ts b/packages/tui/src/status.ts index 91af6a9a..ffaaf30f 100644 --- a/packages/tui/src/status.ts +++ b/packages/tui/src/status.ts @@ -1,168 +1,5 @@ -import { promptEnvironment } from '#tui/environment'; -import type { MaybePromise, StatusOptions } from '#tui/types'; - -export const spin = async (callback: () => MaybePromise, options: StatusOptions = { message: 'Loading' }): Promise => { - promptEnvironment().output.write(`${options.message}...\n`); - - try { - const result = await callback(); - promptEnvironment().output.write(`Done: ${options.message}\n`); - - return result; - } catch (error) { - promptEnvironment().error.write(`Failed: ${options.message}\n`); - throw error; - } -}; - -export class Progress { - #current = 0; - #label: string; - #hint: string; - - constructor( - readonly total: number, - message = 'Progress', - hint = '' - ) { - if (total <= 0) { - throw new Error('Progress bar must have at least one item.'); - } - - this.#label = message; - this.#hint = hint; - } - - start(): void { - this.render(); - } - - advance(step = 1): void { - this.#current = Math.min(this.total, this.#current + step); - this.render(); - } - - finish(): void { - this.#current = this.total; - this.render(); - } - - label(value: string): this { - this.#label = value; - - return this; - } - - hint(value: string): this { - this.#hint = value; - - return this; - } - - percentage(): number { - return this.#current / this.total; - } - - value(): number { - return this.#current; - } - - render(): void { - const width = 20; - const filled = Math.round(width * this.percentage()); - const bar = `${'█'.repeat(filled)}${' '.repeat(width - filled)}`; - const hint = this.#hint ? ` ${this.#hint}` : ''; - - promptEnvironment().output.write(`${this.#label}: ${bar} ${this.#current} / ${this.total}${hint}\n`); - } -} - -export function progress(total: number, message?: string): Progress; -export function progress(label: string, steps: Iterable | number, callback?: (step: T | number, progress: Progress) => MaybePromise, hint?: string): Progress | Promise; -export function progress( - labelOrTotal: string | number, - stepsOrMessage?: Iterable | number | string, - callback?: (step: T | number, progress: Progress) => MaybePromise, - _hint = '' -): Progress | Promise { - if (typeof labelOrTotal === 'number') { - return new Progress(labelOrTotal, typeof stepsOrMessage === 'string' ? stepsOrMessage : undefined); - } - - const steps = stepsOrMessage ?? 0; - if (typeof steps === 'string') { - throw new Error('Progress steps must be an iterable or a number.'); - } - - const values = typeof steps === 'number' ? Array.from({ length: steps }, (_, index) => index) : Array.from(steps); - const bar = new Progress(values.length, labelOrTotal, _hint); - - if (!callback) { - return bar; - } - - return (async () => { - const results: R[] = []; - - bar.start(); - - for (const value of values) { - results.push(await callback(value, bar)); - bar.advance(); - } - - bar.finish(); - - return results; - })(); -} - -export type TaskDefinition = { - title: string; - task: () => MaybePromise; -}; - -export async function task(definition: TaskDefinition): Promise; -export async function task(label: string, callback: () => MaybePromise, limit?: number, keepSummary?: boolean, subLabel?: string): Promise; -export async function task( - definitionOrLabel: TaskDefinition | string, - callback?: () => MaybePromise, - _limit = 10, - _keepSummary = false, - subLabel?: string -): Promise { - const title = typeof definitionOrLabel === 'string' ? definitionOrLabel : definitionOrLabel.title; - const run = typeof definitionOrLabel === 'string' ? callback : definitionOrLabel.task; - - if (!run) { - throw new Error('A task callback is required.'); - } - - promptEnvironment().output.write(`${title}${subLabel ? ` ${subLabel}` : ''}\n`); - const result = await run(); - promptEnvironment().output.write(`Done: ${title}\n`); - - return result; -} - -export class Stream { - write(content: string): this { - promptEnvironment().output.write(content); - - return this; - } - - async pipe(source: AsyncIterable | Iterable): Promise { - for await (const chunk of source) { - this.write(chunk); - } - } -} - -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); -} +export { progress, Progress } 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/progress.ts b/packages/tui/src/status/progress.ts new file mode 100644 index 00000000..5a0d4b98 --- /dev/null +++ b/packages/tui/src/status/progress.ts @@ -0,0 +1,104 @@ +import { promptEnvironment } from '#tui/environment'; +import type { MaybePromise } from '#tui/types'; + +export class Progress { + #current = 0; + #label: string; + #hint: string; + + constructor( + readonly total: number, + message = 'Progress', + hint = '' + ) { + if (total <= 0) { + throw new Error('Progress bar must have at least one item.'); + } + + this.#label = message; + this.#hint = hint; + } + + start(): void { + this.render(); + } + + advance(step = 1): void { + this.#current = Math.min(this.total, this.#current + step); + this.render(); + } + + finish(): void { + this.#current = this.total; + this.render(); + } + + label(value: string): this { + this.#label = value; + + return this; + } + + hint(value: string): this { + this.#hint = value; + + return this; + } + + percentage(): number { + return this.#current / this.total; + } + + value(): number { + return this.#current; + } + + render(): void { + const width = 20; + const filled = Math.round(width * this.percentage()); + const bar = `${'█'.repeat(filled)}${' '.repeat(width - filled)}`; + const hint = this.#hint ? ` ${this.#hint}` : ''; + + promptEnvironment().output.write(`${this.#label}: ${bar} ${this.#current} / ${this.total}${hint}\n`); + } +} + +export function progress(total: number, message?: string): Progress; +export function progress(label: string, steps: Iterable | number, callback?: (step: T | number, progress: Progress) => MaybePromise, hint?: string): Progress | Promise; +export function progress( + labelOrTotal: string | number, + stepsOrMessage?: Iterable | number | string, + callback?: (step: T | number, progress: Progress) => MaybePromise, + hint = '' +): Progress | Promise { + if (typeof labelOrTotal === 'number') { + return new Progress(labelOrTotal, typeof stepsOrMessage === 'string' ? stepsOrMessage : undefined); + } + + const steps = stepsOrMessage ?? 0; + if (typeof steps === 'string') { + throw new Error('Progress steps must be an iterable or a number.'); + } + + const values = typeof steps === 'number' ? Array.from({ length: steps }, (_, index) => index) : Array.from(steps); + const bar = new Progress(values.length, labelOrTotal, hint); + + if (!callback) { + return bar; + } + + return (async () => { + const results: R[] = []; + + bar.start(); + + for (const value of values) { + results.push(await callback(value, bar)); + bar.advance(); + } + + bar.finish(); + + return results; + })(); +} diff --git a/packages/tui/src/status/spinner.ts b/packages/tui/src/status/spinner.ts new file mode 100644 index 00000000..8632273d --- /dev/null +++ b/packages/tui/src/status/spinner.ts @@ -0,0 +1,16 @@ +import { promptEnvironment } from '#tui/environment'; +import type { MaybePromise, StatusOptions } from '#tui/types'; + +export const spin = async (callback: () => MaybePromise, options: StatusOptions = { message: 'Loading' }): Promise => { + promptEnvironment().output.write(`${options.message}...\n`); + + try { + const result = await callback(); + promptEnvironment().output.write(`Done: ${options.message}\n`); + + return result; + } catch (error) { + promptEnvironment().error.write(`Failed: ${options.message}\n`); + throw error; + } +}; diff --git a/packages/tui/src/status/stream.ts b/packages/tui/src/status/stream.ts new file mode 100644 index 00000000..4520defa --- /dev/null +++ b/packages/tui/src/status/stream.ts @@ -0,0 +1,23 @@ +import { promptEnvironment } from '#tui/environment'; + +export class Stream { + write(content: string): this { + promptEnvironment().output.write(content); + + return this; + } + + async pipe(source: AsyncIterable | Iterable): Promise { + for await (const chunk of source) { + this.write(chunk); + } + } +} + +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/task.ts b/packages/tui/src/status/task.ts new file mode 100644 index 00000000..7ee9c1dd --- /dev/null +++ b/packages/tui/src/status/task.ts @@ -0,0 +1,70 @@ +import { promptEnvironment } from '#tui/environment'; +import type { MaybePromise } from '#tui/types'; + +export type TaskDefinition = { + title: string; + limit?: number; + task: (logger: Logger) => MaybePromise; +}; + +export class Logger { + readonly lines: string[] = []; + + constructor(private readonly limit: number) {} + + log(message: string): void { + for (const line of message.split(/\r?\n/u).filter((value) => value.length > 0)) { + this.lines.push(line.replaceAll(/\u001B\[1G|\u001B\[2K/gu, '')); + } + + while (this.lines.length > this.limit) { + this.lines.shift(); + } + } + + info(message: string): void { + this.log(message); + } + + success(message: string): void { + this.log(message); + } + + warning(message: string): void { + this.log(message); + } + + error(message: string): void { + this.log(message); + } +} + +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 { + const title = typeof definitionOrLabel === 'string' ? definitionOrLabel : definitionOrLabel.title; + const run = typeof definitionOrLabel === 'string' ? callback : definitionOrLabel.task; + + if (!run) { + throw new Error('A task callback is required.'); + } + + const logger = new Logger(typeof definitionOrLabel === 'string' ? limit : (definitionOrLabel.limit ?? limit)); + + promptEnvironment().output.write(`${title}${subLabel ? ` ${subLabel}` : ''}\n`); + const result = await run(logger); + + for (const line of logger.lines) { + promptEnvironment().output.write(`${line}\n`); + } + + promptEnvironment().output.write(`Done: ${title}\n`); + + return result; +} diff --git a/packages/tui/src/types.ts b/packages/tui/src/types.ts index 48d0f036..40aee145 100644 --- a/packages/tui/src/types.ts +++ b/packages/tui/src/types.ts @@ -83,6 +83,14 @@ export type SearchPromptOptions = BasePromptOptions & { info?: string | ((value: T) => string); }; +export type MultiSearchPromptOptions = BasePromptOptions & { + label?: string; + options: Array> | Record | ((query: string) => MaybePromise> | Record>); + placeholder?: string; + scroll?: number; + info?: string | ((value: T[]) => string); +}; + export type TableOptions = { headers?: string[]; rows: Array | Record>; diff --git a/packages/tui/tests/task.test.ts b/packages/tui/tests/task.test.ts new file mode 100644 index 00000000..1a3440de --- /dev/null +++ b/packages/tui/tests/task.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; +import { createMemoryOutput, task, withPromptEnvironment } from '../src/index'; + +describe('task helper', () => { + it('passes a bounded logger to the callback and returns the callback value', async () => { + const output = createMemoryOutput(); + const result = await withPromptEnvironment({ output, error: output }, async () => { + return task('Running...', (logger) => { + logger.log('line one'); + logger.log('line two'); + logger.log('line three'); + + return 'done'; + }, 2); + }); + + expect(result).toBe('done'); + expect(output.text()).toContain('Running...'); + expect(output.text()).not.toContain('line one'); + expect(output.text()).toContain('line two'); + expect(output.text()).toContain('line three'); + }); + + it('strips cursor reset control sequences from log lines', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + await task('Running...', (logger) => { + logger.log('before\u001B[1G\u001B[2Kafter'); + }); + }); + + expect(output.text()).toContain('beforeafter'); + }); +}); From 850d54b2962f3684ec134eb7d36ad12bd8a35d17 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 13:47:13 +0800 Subject: [PATCH 007/675] Clean upstream naming references --- LICENSE.md | 2 +- README.md | 6 +++--- UPSTREAM.md | 6 +++--- packages/acceptance/tests/helpers.test.ts | 6 +++--- packages/tui/tests/strings.test.ts | 6 +++--- 5 files changed, 13 insertions(+), 13 deletions(-) diff --git a/LICENSE.md b/LICENSE.md index 58d40187..f7591733 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -2,7 +2,7 @@ MIT License Copyright (c) 2026 Oullin -Portions of this package are derived from Laravel Prompts, Copyright (c) Taylor Otwell. +Portions of this package are derived from upstream MIT-licensed prompt toolkit code, Copyright (c) Taylor Otwell. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index da66d3a9..e2cbdfab 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ # `@ollin/tui` -An idiomatic TypeScript/OpenTUI port of `laravel/prompts`. +An idiomatic TypeScript/OpenTUI prompt toolkit. -This repository targets behavioral parity with `laravel/prompts@v0.3.18` while -using TypeScript-first modules and package boundaries. +This repository targets behavioral parity with the upstream prompt toolkit +release `v0.3.18` while using TypeScript-first modules and package boundaries. ## Status diff --git a/UPSTREAM.md b/UPSTREAM.md index cd5db903..03cf4655 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -1,11 +1,11 @@ # Upstream Parity Map -Target upstream: `laravel/prompts@v0.3.18` +Target upstream release: `v0.3.18` Tree: `a19af51bb144bf87f08397921fa619f85c7d4e72` -This port keeps Laravel Prompts behavior as the source of truth while using -idiomatic TypeScript and OpenTUI internals. +This port keeps upstream behavior as the source of truth while using idiomatic +TypeScript and OpenTUI internals. ## Implemented In Phase 1 diff --git a/packages/acceptance/tests/helpers.test.ts b/packages/acceptance/tests/helpers.test.ts index b2a5fc5c..dfdcac9f 100644 --- a/packages/acceptance/tests/helpers.test.ts +++ b/packages/acceptance/tests/helpers.test.ts @@ -36,7 +36,7 @@ const scripted = async (lines: string[], callback: () => Promise): Promise return { output: output.text(), result }; }; -describe('Laravel-style helpers', () => { +describe('prompt helpers', () => { it('reads text and number prompts', async () => { await expect(scripted(['Tay', 'lor', '\n'], () => text('Name'))).resolves.toMatchObject({ result: 'Taylor' }); await expect(scripted(['4', '2', '\n'], () => number({ message: 'Age', integer: true }))).resolves.toMatchObject({ result: 42 }); @@ -49,7 +49,7 @@ describe('Laravel-style helpers', () => { scripted(['2'], () => select({ message: 'Framework', - options: ['Laravel', 'Vue'] + options: ['Ollin', 'Vue'] }) ) ).resolves.toMatchObject({ result: 'Vue' }); @@ -84,7 +84,7 @@ describe('Laravel-style helpers', () => { info('Facts'); intro('Start'); outro('Done'); - table({ headers: ['Name'], rows: [['Laravel']] }); + table({ headers: ['Name'], rows: [['Ollin']] }); title('Demo'); }); diff --git a/packages/tui/tests/strings.test.ts b/packages/tui/tests/strings.test.ts index 52b1fc19..a2c9e77a 100644 --- a/packages/tui/tests/strings.test.ts +++ b/packages/tui/tests/strings.test.ts @@ -3,15 +3,15 @@ import { parseAnsiText, truncate, visibleWidth, wrap } from '../src/index'; describe('string utilities', () => { it('measures visible width without ANSI control sequences', () => { - expect(visibleWidth('\u001B[31mLaravel\u001B[39m')).toBe(7); + expect(visibleWidth('\u001B[31mPrompts\u001B[39m')).toBe(7); }); it('truncates by visible width', () => { - expect(truncate('Laravel Prompts', 10)).toBe('Laravel...'); + expect(truncate('Ollin Prompts', 10)).toBe('Ollin P...'); }); it('wraps words to the requested width', () => { - expect(wrap('Laravel Prompts', 7)).toEqual(['Laravel', 'Prompts']); + expect(wrap('Ollin Prompts', 7)).toEqual(['Ollin', 'Prompts']); }); it('strips ANSI text', () => { From 3c97bf93950e0d03ecc40f2d3691512ce6f74673 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 13:51:33 +0800 Subject: [PATCH 008/675] Split prompt domains --- UPSTREAM.md | 5 +- packages/tui/src/prompts/choices.ts | 571 +--------------------------- packages/tui/src/prompts/pause.ts | 5 + packages/tui/src/prompts/search.ts | 172 +++++++++ packages/tui/src/prompts/select.ts | 201 ++++++++++ packages/tui/src/prompts/suggest.ts | 182 +++++++++ 6 files changed, 569 insertions(+), 567 deletions(-) create mode 100644 packages/tui/src/prompts/pause.ts create mode 100644 packages/tui/src/prompts/search.ts create mode 100644 packages/tui/src/prompts/select.ts create mode 100644 packages/tui/src/prompts/suggest.ts diff --git a/UPSTREAM.md b/UPSTREAM.md index 03cf4655..3bdaa7df 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -16,7 +16,10 @@ TypeScript and OpenTUI internals. | Key constants | `packages/tui/src/key.ts` | | Terminal helpers | `packages/tui/src/terminal.ts` | | Text, textarea, number, password | `packages/tui/src/prompts/basic.ts`, `packages/tui/src/typed-value.ts` | -| Confirm, select, multiselect, suggest, search, multisearch, autocomplete | `packages/tui/src/prompts/choices.ts` | +| Confirm, select, multiselect | `packages/tui/src/prompts/select.ts` | +| Suggest, autocomplete | `packages/tui/src/prompts/suggest.ts` | +| Search, multisearch | `packages/tui/src/prompts/search.ts` | +| Pause | `packages/tui/src/prompts/pause.ts` | | Choice normalization, matching, navigation, and list rendering concerns | `packages/tui/src/concerns/choices.ts` | | Form builder | `packages/tui/src/form.ts` | | Note, notify, table, grid, title, clear | `packages/tui/src/output.ts` | diff --git a/packages/tui/src/prompts/choices.ts b/packages/tui/src/prompts/choices.ts index 3c5a1f70..66208d82 100644 --- a/packages/tui/src/prompts/choices.ts +++ b/packages/tui/src/prompts/choices.ts @@ -1,566 +1,5 @@ -import { ask, promptUntilValid, PromptValidationError } from '#tui/prompt'; -import { promptEnvironment } from '#tui/environment'; -import { Key, oneOf } from '#tui/key'; -import { renderChoices } from '#tui/theme'; -import { applyTypedKey } from '#tui/typed-value'; -import { findChoice, firstEnabledIndex, nextEnabledIndex, normalizeChoices, normalizeSearchChoices, renderInteractiveChoices } from '#tui/concerns/choices'; -import type { - Choice, - ConfirmPromptOptions, - MaybePromise, - MultiSearchPromptOptions, - MultiSelectPromptOptions, - SearchPromptOptions, - SelectPromptOptions, - TextPromptOptions -} from '#tui/types'; - -const readSelectedChoice = async (message: string, choices: Array>, hint?: string): Promise => { - const environment = promptEnvironment(); - - if (!environment.input.readKey) { - const rendered = renderChoices(choices); - const answer = await ask(`${message}\n${rendered}\n`, hint); - const choice = findChoice(choices, answer); - - if (!choice || choice.disabled) { - throw new PromptValidationError('Please select a valid option.'); - } - - return choice.value; - } - - let selected = firstEnabledIndex(choices); - renderInteractiveChoices(message, choices, selected); - - while (true) { - const key = await environment.input.readKey(); - - if (key === null) { - throw new PromptValidationError('Please select a valid option.'); - } - - const numeric = /^\d+$/u.test(key) ? Number.parseInt(key, 10) : Number.NaN; - - if (!Number.isNaN(numeric) && choices[numeric - 1] && !choices[numeric - 1]?.disabled) { - return choices[numeric - 1].value; - } - - if (key === Key.down || key === Key.downArrow || key === Key.ctrlN) { - selected = nextEnabledIndex(choices, selected, 1); - renderInteractiveChoices(message, choices, selected); - continue; - } - - if (key === Key.up || key === Key.upArrow || key === Key.ctrlP) { - selected = nextEnabledIndex(choices, selected, -1); - renderInteractiveChoices(message, choices, selected); - continue; - } - - if (key === Key.enter) { - const choice = choices[selected]; - - if (!choice || choice.disabled) { - throw new PromptValidationError('Please select a valid option.'); - } - - return choice.value; - } - } -}; - -const readMultipleChoices = async (message: string, choices: Array>, defaults: T[] = [], hint?: string): Promise => { - const environment = promptEnvironment(); - const selectedValues = new Set(defaults); - - if (!environment.input.readKey) { - const rendered = renderChoices(choices); - const answer = await ask(`${message}\n${rendered}\n`, hint); - - if (answer.trim() === '') { - return defaults; - } - - return answer - .split(',') - .map((part) => findChoice(choices, part.trim())) - .filter((choice): choice is Choice => choice !== undefined && !choice.disabled) - .map((choice) => choice.value); - } - - let selected = firstEnabledIndex(choices); - const marked = new Set(choices.flatMap((choice, index) => selectedValues.has(choice.value) ? [index] : [])); - renderInteractiveChoices(message, choices, selected, marked); - - while (true) { - const key = await environment.input.readKey(); - - if (key === null) { - return [...marked].map((index) => choices[index]?.value).filter((value): value is T => value !== undefined); - } - - if (key.includes(',')) { - const selectedChoices = key - .split(',') - .map((part) => findChoice(choices, part.trim())) - .filter((choice): choice is Choice => choice !== undefined && !choice.disabled); - - if (selectedChoices.length !== key.split(',').length) { - throw new PromptValidationError('Please select valid options.'); - } - - return selectedChoices.map((choice) => choice.value); - } - - const numeric = /^\d+$/u.test(key) ? Number.parseInt(key, 10) : Number.NaN; - - if (!Number.isNaN(numeric) && choices[numeric - 1] && !choices[numeric - 1]?.disabled) { - const index = numeric - 1; - - if (marked.has(index)) { - marked.delete(index); - } else { - marked.add(index); - } - - renderInteractiveChoices(message, choices, selected, marked); - continue; - } - - if (key === Key.down || key === Key.downArrow || key === Key.ctrlN) { - selected = nextEnabledIndex(choices, selected, 1); - renderInteractiveChoices(message, choices, selected, marked); - continue; - } - - if (key === Key.up || key === Key.upArrow || key === Key.ctrlP) { - selected = nextEnabledIndex(choices, selected, -1); - renderInteractiveChoices(message, choices, selected, marked); - continue; - } - - if (key === Key.space) { - if (marked.has(selected)) { - marked.delete(selected); - } else if (!choices[selected]?.disabled) { - marked.add(selected); - } - - renderInteractiveChoices(message, choices, selected, marked); - continue; - } - - if (key === Key.enter) { - return [...marked].map((index) => choices[index]?.value).filter((value): value is T => value !== undefined); - } - } -}; - -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 -): Promise; -export async function confirm( - message: string | ConfirmPromptOptions, - defaultValue = true, - yes = 'Yes', - no = 'No', - required: boolean | string = false, - validate: ConfirmPromptOptions['validate'] = undefined, - hint = '' -): Promise { - const options: ConfirmPromptOptions = typeof message === 'string' - ? { message, label: message, default: defaultValue, yes, no, required, validate, hint } - : { ...message, default: message.default ?? true }; - - return promptUntilValid(options, async () => { - const suffix = options.default === false ? ' [y/N]' : ' [Y/n]'; - const answer = (await ask(`${options.message}${suffix}`, options.hint)).trim().toLowerCase(); - - if (answer === '' && options.default !== undefined) { - return options.default; - } - - return ['y', 'yes', options.yes?.toLowerCase()].includes(answer); - }); -} - -export const select = async (options: SelectPromptOptions): Promise => { - const choices = normalizeChoices(options.options); - - return promptUntilValid(options, async () => { - const selected = await readSelectedChoice(options.message, choices, options.hint).catch((error: unknown) => { - if (options.default !== undefined && error instanceof PromptValidationError) { - return options.default; - } - - throw error; - }); - - return selected; - }); -}; - -export const multiselect = async (options: MultiSelectPromptOptions): Promise => { - const choices = normalizeChoices(options.options); - - return promptUntilValid(options, async () => { - return readMultipleChoices(options.message, choices, options.default, options.hint); - }); -}; - -type SuggestOptions = TextPromptOptions & { - options: string[] | ((query: string) => MaybePromise); - scroll?: number; - info?: string | ((value: string) => string); -}; - -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 -): SuggestOptions => { - if (typeof message === 'string') { - return { message, label: message, options, placeholder, default: defaultValue, scroll, required, validate, hint, transform }; - } - - return { ...message, default: message.default ?? '' }; -}; - -const resolveSuggestions = async (source: SuggestOptions['options'], query: string): Promise => { - const options = typeof source === 'function' ? await source(query) : source; - - if (typeof source === 'function') { - return [...options]; - } - - return options.filter((option: string) => option.toLowerCase().startsWith(query.toLowerCase())); -}; - -const readSuggestionValue = async (options: SuggestOptions): Promise => { - const environment = promptEnvironment(); - - if (!environment.input.readKey) { - return ask(options.message, options.hint); - } - - let state = { - cursor: options.default?.length ?? 0, - value: options.default ?? '' - }; - let highlighted: number | null = null; - let matches: string[] = await resolveSuggestions(options.options, state.value); - - environment.output.write(`${options.message}\n`); - - while (true) { - const key = await environment.input.readKey(); - - if (key === null) { - return state.value; - } - - if (key === Key.tab) { - matches = await resolveSuggestions(options.options, state.value); - const match = matches[highlighted ?? 0]; - - if (match !== undefined) { - state = { cursor: match.length, value: match }; - } - - continue; - } - - if (key === Key.down || key === Key.downArrow || key === Key.ctrlN || key === Key.shiftTab) { - matches = await resolveSuggestions(options.options, state.value); - highlighted = matches.length === 0 ? null : ((highlighted ?? -1) + 1) % matches.length; - continue; - } - - if (key === Key.up || key === Key.upArrow || key === Key.ctrlP) { - matches = await resolveSuggestions(options.options, state.value); - highlighted = matches.length === 0 ? null : ((highlighted ?? matches.length) - 1 + matches.length) % matches.length; - continue; - } - - if (oneOf([Key.home, Key.ctrlA], key) && highlighted !== null) { - highlighted = 0; - continue; - } - - if (oneOf([Key.end, Key.ctrlE], key) && highlighted !== null) { - highlighted = Math.max(0, matches.length - 1); - continue; - } - - if (key === Key.enter) { - if (highlighted !== null && matches[highlighted] !== undefined) { - return matches[highlighted]; - } - - return state.value; - } - - const next = applyTypedKey(state, key); - - if (next.submitted) { - return state.value; - } - - if (next.cancelled) { - environment.error.write('Cancelled.\n'); - - return state.value; - } - - state = { cursor: next.cursor, value: next.value }; - highlighted = null; - matches = await resolveSuggestions(options.options, state.value); - } -}; - -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'] -): 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 -): Promise { - const options = suggestOptions(message, source, placeholder, defaultValue, scroll, required, validate, hint, transform); - - return promptUntilValid(options, async () => { - const answer = await readSuggestionValue(options); - const value = answer === '' && options.default !== undefined ? options.default : answer; - - return options.transform ? options.transform(value) : value; - }); -} - -export const search = async (options: SearchPromptOptions): Promise => { - return promptUntilValid(options, async (attempt) => { - const selected = await readSearchChoice(options, attempt); - - if (selected === undefined) { - throw new PromptValidationError('Please select a valid option.'); - } - - return selected; - }); -}; - -export const multisearch = async (options: MultiSearchPromptOptions): Promise => { - return promptUntilValid(options, async () => { - return readMultiSearchChoices(options); - }); -}; - -const resolveSearchChoices = async (source: SearchPromptOptions['options'], query: string): Promise>> => { - const options = typeof source === 'function' ? await source(query) : source; - - return normalizeSearchChoices(options); -}; - -const readSearchChoice = async (options: SearchPromptOptions, attempt = 0): Promise => { - const environment = promptEnvironment(); - - if (!environment.input.readKey) { - const query = await ask(options.message, options.hint); - const choices = await resolveSearchChoices(options.options, query); - const choice = findChoice(choices, query) ?? choices.find((candidate) => !candidate.disabled); - - return choice?.value ?? options.default ?? choices[0]?.value; - } - - let state = { cursor: 0, value: '' }; - let choices = await resolveSearchChoices(options.options, state.value); - let highlighted: number | null = null; - - environment.output.write(`${options.message}\n`); - - while (true) { - const key = await environment.input.readKey(); - - if (key === null) { - return options.default; - } - - if (key === Key.down || key === Key.downArrow || key === Key.ctrlN || key === Key.tab) { - choices = await resolveSearchChoices(options.options, state.value); - highlighted = choices.length === 0 ? null : ((highlighted ?? (attempt > 0 ? 0 : -1)) + 1) % choices.length; - renderInteractiveChoices(options.message, choices, highlighted ?? 0); - continue; - } - - if (key === Key.up || key === Key.upArrow || key === Key.ctrlP || key === Key.shiftTab) { - choices = await resolveSearchChoices(options.options, state.value); - highlighted = choices.length === 0 ? null : ((highlighted ?? choices.length) - 1 + choices.length) % choices.length; - renderInteractiveChoices(options.message, choices, highlighted ?? 0); - continue; - } - - if (oneOf([Key.home, Key.ctrlA], key) && highlighted !== null) { - highlighted = 0; - continue; - } - - if (oneOf([Key.end, Key.ctrlE], key) && highlighted !== null) { - highlighted = Math.max(0, choices.length - 1); - continue; - } - - if (key === Key.enter) { - if (highlighted !== null) { - return choices[highlighted]?.value; - } - - choices = await resolveSearchChoices(options.options, state.value); - highlighted = choices.length > 0 ? 0 : null; - continue; - } - - const next = applyTypedKey(state, key); - - if (next.cancelled) { - environment.error.write('Cancelled.\n'); - - return options.default; - } - - state = { cursor: next.cursor, value: next.value }; - highlighted = null; - choices = await resolveSearchChoices(options.options, state.value); - } -}; - -const readMultiSearchChoices = async (options: MultiSearchPromptOptions): Promise => { - const environment = promptEnvironment(); - - if (!environment.input.readKey) { - const query = await ask(options.message, options.hint); - const choices = await resolveSearchChoices(options.options, query); - - if (query.trim() === '' && options.default !== undefined) { - return options.default; - } - - const selectedChoices = query - .split(',') - .map((part) => findChoice(choices, part.trim())) - .filter((choice): choice is Choice => choice !== undefined && !choice.disabled); - - if (selectedChoices.length !== query.split(',').length) { - throw new PromptValidationError('Please select valid options.'); - } - - return selectedChoices.map((choice) => choice.value); - } - - let state = { cursor: 0, value: '' }; - let choices = await resolveSearchChoices(options.options, state.value); - let highlighted: number | null = null; - const selected = new Map(); - - environment.output.write(`${options.message}\n`); - - while (true) { - const key = await environment.input.readKey(); - - if (key === null || key === Key.enter) { - return [...selected.keys()]; - } - - if (key === Key.down || key === Key.downArrow || key === Key.ctrlN || key === Key.tab) { - choices = await resolveSearchChoices(options.options, state.value); - highlighted = choices.length === 0 ? null : ((highlighted ?? -1) + 1) % choices.length; - renderInteractiveChoices(options.message, choices, highlighted ?? 0); - continue; - } - - if (key === Key.up || key === Key.upArrow || key === Key.ctrlP || key === Key.shiftTab) { - choices = await resolveSearchChoices(options.options, state.value); - highlighted = choices.length === 0 ? null : ((highlighted ?? choices.length) - 1 + choices.length) % choices.length; - renderInteractiveChoices(options.message, choices, highlighted ?? 0); - continue; - } - - if (key === Key.space && highlighted !== null) { - const choice = choices[highlighted]; - - if (choice) { - if (selected.has(choice.value as T)) { - selected.delete(choice.value as T); - } else { - selected.set(choice.value as T, choice.label); - } - } - - continue; - } - - const next = applyTypedKey(state, key); - state = { cursor: next.cursor, value: next.value }; - choices = await resolveSearchChoices(options.options, state.value); - highlighted = null; - } -}; - -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'] -): 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 -): Promise { - return suggest( - typeof message === 'string' - ? { message, label: message, options: source, placeholder, default: defaultValue, required, validate, hint, transform } - : message - ); -} - -export const pause = async (message = 'Press enter to continue'): Promise => { - await ask(message); -}; +export { confirm, multiselect, select } from '#tui/prompts/select'; +export { autocomplete, suggest } from '#tui/prompts/suggest'; +export { multisearch, search } from '#tui/prompts/search'; +export { pause } from '#tui/prompts/pause'; +export type { SuggestOptions } from '#tui/prompts/suggest'; diff --git a/packages/tui/src/prompts/pause.ts b/packages/tui/src/prompts/pause.ts new file mode 100644 index 00000000..59beb76d --- /dev/null +++ b/packages/tui/src/prompts/pause.ts @@ -0,0 +1,5 @@ +import { ask } from '#tui/prompt'; + +export const pause = async (message = 'Press enter to continue'): Promise => { + await ask(message); +}; diff --git a/packages/tui/src/prompts/search.ts b/packages/tui/src/prompts/search.ts new file mode 100644 index 00000000..8dbb086c --- /dev/null +++ b/packages/tui/src/prompts/search.ts @@ -0,0 +1,172 @@ +import { promptEnvironment } from '#tui/environment'; +import { Key, oneOf } from '#tui/key'; +import { ask, promptUntilValid, PromptValidationError } from '#tui/prompt'; +import { applyTypedKey } from '#tui/typed-value'; +import { findChoice, normalizeSearchChoices, renderInteractiveChoices } from '#tui/concerns/choices'; +import type { Choice, MultiSearchPromptOptions, SearchPromptOptions } from '#tui/types'; + +const resolveSearchChoices = async (source: SearchPromptOptions['options'], query: string): Promise>> => { + const options = typeof source === 'function' ? await source(query) : source; + + return normalizeSearchChoices(options); +}; + +const readSearchChoice = async (options: SearchPromptOptions, attempt = 0): Promise => { + const environment = promptEnvironment(); + + if (!environment.input.readKey) { + const query = await ask(options.message, options.hint); + const choices = await resolveSearchChoices(options.options, query); + const choice = findChoice(choices, query) ?? choices.find((candidate) => !candidate.disabled); + + return choice?.value ?? options.default ?? choices[0]?.value; + } + + let state = { cursor: 0, value: '' }; + let choices = await resolveSearchChoices(options.options, state.value); + let highlighted: number | null = null; + + environment.output.write(`${options.message}\n`); + + while (true) { + const key = await environment.input.readKey(); + + if (key === null) { + return options.default; + } + + if (key === Key.down || key === Key.downArrow || key === Key.ctrlN || key === Key.tab) { + choices = await resolveSearchChoices(options.options, state.value); + highlighted = choices.length === 0 ? null : ((highlighted ?? (attempt > 0 ? 0 : -1)) + 1) % choices.length; + renderInteractiveChoices(options.message, choices, highlighted ?? 0); + continue; + } + + if (key === Key.up || key === Key.upArrow || key === Key.ctrlP || key === Key.shiftTab) { + choices = await resolveSearchChoices(options.options, state.value); + highlighted = choices.length === 0 ? null : ((highlighted ?? choices.length) - 1 + choices.length) % choices.length; + renderInteractiveChoices(options.message, choices, highlighted ?? 0); + continue; + } + + if (oneOf([Key.home, Key.ctrlA], key) && highlighted !== null) { + highlighted = 0; + continue; + } + + if (oneOf([Key.end, Key.ctrlE], key) && highlighted !== null) { + highlighted = Math.max(0, choices.length - 1); + continue; + } + + if (key === Key.enter) { + if (highlighted !== null) { + return choices[highlighted]?.value; + } + + choices = await resolveSearchChoices(options.options, state.value); + highlighted = choices.length > 0 ? 0 : null; + continue; + } + + const next = applyTypedKey(state, key); + + if (next.cancelled) { + environment.error.write('Cancelled.\n'); + + return options.default; + } + + state = { cursor: next.cursor, value: next.value }; + highlighted = null; + choices = await resolveSearchChoices(options.options, state.value); + } +}; + +export const search = async (options: SearchPromptOptions): Promise => { + return promptUntilValid(options, async (attempt) => { + const selected = await readSearchChoice(options, attempt); + + if (selected === undefined) { + throw new PromptValidationError('Please select a valid option.'); + } + + return selected; + }); +}; + +const readMultiSearchChoices = async (options: MultiSearchPromptOptions): Promise => { + const environment = promptEnvironment(); + + if (!environment.input.readKey) { + const query = await ask(options.message, options.hint); + const choices = await resolveSearchChoices(options.options, query); + + if (query.trim() === '' && options.default !== undefined) { + return options.default; + } + + const parts = query.split(',').map((part) => part.trim()).filter((part) => part.length > 0); + 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); + } + + let state = { cursor: 0, value: '' }; + let choices = await resolveSearchChoices(options.options, state.value); + let highlighted: number | null = null; + const selected = new Map(); + + environment.output.write(`${options.message}\n`); + + while (true) { + const key = await environment.input.readKey(); + + if (key === null || key === Key.enter) { + return [...selected.keys()]; + } + + if (key === Key.down || key === Key.downArrow || key === Key.ctrlN || key === Key.tab) { + choices = await resolveSearchChoices(options.options, state.value); + highlighted = choices.length === 0 ? null : ((highlighted ?? -1) + 1) % choices.length; + renderInteractiveChoices(options.message, choices, highlighted ?? 0); + continue; + } + + if (key === Key.up || key === Key.upArrow || key === Key.ctrlP || key === Key.shiftTab) { + choices = await resolveSearchChoices(options.options, state.value); + highlighted = choices.length === 0 ? null : ((highlighted ?? choices.length) - 1 + choices.length) % choices.length; + renderInteractiveChoices(options.message, choices, highlighted ?? 0); + continue; + } + + if (key === Key.space && highlighted !== null) { + const choice = choices[highlighted]; + + if (choice) { + if (selected.has(choice.value as T)) { + selected.delete(choice.value as T); + } else { + selected.set(choice.value as T, choice.label); + } + } + + continue; + } + + const next = applyTypedKey(state, key); + state = { cursor: next.cursor, value: next.value }; + choices = await resolveSearchChoices(options.options, state.value); + highlighted = null; + } +}; + +export const multisearch = async (options: MultiSearchPromptOptions): Promise => { + return promptUntilValid(options, async () => readMultiSearchChoices(options)); +}; diff --git a/packages/tui/src/prompts/select.ts b/packages/tui/src/prompts/select.ts new file mode 100644 index 00000000..7d21ffae --- /dev/null +++ b/packages/tui/src/prompts/select.ts @@ -0,0 +1,201 @@ +import { promptEnvironment } from '#tui/environment'; +import { Key } from '#tui/key'; +import { ask, promptUntilValid, PromptValidationError } from '#tui/prompt'; +import { renderChoices } from '#tui/theme'; +import { findChoice, firstEnabledIndex, nextEnabledIndex, normalizeChoices, renderInteractiveChoices } from '#tui/concerns/choices'; +import type { Choice, ConfirmPromptOptions, MultiSelectPromptOptions, SelectPromptOptions } from '#tui/types'; + +const parseChoiceIndex = (key: string): number => (/^\d+$/u.test(key) ? Number.parseInt(key, 10) : Number.NaN); + +const readSelectedChoice = async (message: string, choices: Array>, hint?: string): Promise => { + const environment = promptEnvironment(); + + if (!environment.input.readKey) { + const rendered = renderChoices(choices); + const answer = await ask(`${message}\n${rendered}\n`, hint); + const choice = findChoice(choices, answer); + + if (!choice || choice.disabled) { + throw new PromptValidationError('Please select a valid option.'); + } + + return choice.value; + } + + let selected = firstEnabledIndex(choices); + renderInteractiveChoices(message, choices, selected); + + while (true) { + const key = await environment.input.readKey(); + + if (key === null) { + throw new PromptValidationError('Please select a valid option.'); + } + + const numeric = parseChoiceIndex(key); + + if (!Number.isNaN(numeric) && choices[numeric - 1] && !choices[numeric - 1]?.disabled) { + return choices[numeric - 1].value; + } + + if (key === Key.down || key === Key.downArrow || key === Key.ctrlN) { + selected = nextEnabledIndex(choices, selected, 1); + renderInteractiveChoices(message, choices, selected); + continue; + } + + if (key === Key.up || key === Key.upArrow || key === Key.ctrlP) { + selected = nextEnabledIndex(choices, selected, -1); + renderInteractiveChoices(message, choices, selected); + continue; + } + + if (key === Key.enter) { + const choice = choices[selected]; + + if (!choice || choice.disabled) { + throw new PromptValidationError('Please select a valid option.'); + } + + return choice.value; + } + } +}; + +const choicesFromCommaSeparated = (choices: Array>, answer: string): T[] => { + const parts = answer.split(',').map((part) => part.trim()).filter((part) => part.length > 0); + const selected = parts + .map((part) => findChoice(choices, part)) + .filter((choice): choice is Choice => choice !== undefined && !choice.disabled); + + if (selected.length !== parts.length) { + throw new PromptValidationError('Please select valid options.'); + } + + return selected.map((choice) => choice.value); +}; + +const readMultipleChoices = async (message: string, choices: Array>, defaults: T[] = [], hint?: string): Promise => { + const environment = promptEnvironment(); + const selectedValues = new Set(defaults); + + if (!environment.input.readKey) { + const rendered = renderChoices(choices); + const answer = await ask(`${message}\n${rendered}\n`, hint); + + return answer.trim() === '' ? defaults : choicesFromCommaSeparated(choices, answer); + } + + let selected = firstEnabledIndex(choices); + const marked = new Set(choices.flatMap((choice, index) => selectedValues.has(choice.value) ? [index] : [])); + renderInteractiveChoices(message, choices, selected, marked); + + while (true) { + const key = await environment.input.readKey(); + + if (key === null) { + return [...marked].map((index) => choices[index]?.value).filter((value): value is T => value !== undefined); + } + + if (key.includes(',')) { + return choicesFromCommaSeparated(choices, key); + } + + const numeric = parseChoiceIndex(key); + + if (!Number.isNaN(numeric) && choices[numeric - 1] && !choices[numeric - 1]?.disabled) { + const index = numeric - 1; + + if (marked.has(index)) { + marked.delete(index); + } else { + marked.add(index); + } + + renderInteractiveChoices(message, choices, selected, marked); + continue; + } + + if (key === Key.down || key === Key.downArrow || key === Key.ctrlN) { + selected = nextEnabledIndex(choices, selected, 1); + renderInteractiveChoices(message, choices, selected, marked); + continue; + } + + if (key === Key.up || key === Key.upArrow || key === Key.ctrlP) { + selected = nextEnabledIndex(choices, selected, -1); + renderInteractiveChoices(message, choices, selected, marked); + continue; + } + + if (key === Key.space) { + if (marked.has(selected)) { + marked.delete(selected); + } else if (!choices[selected]?.disabled) { + marked.add(selected); + } + + renderInteractiveChoices(message, choices, selected, marked); + continue; + } + + if (key === Key.enter) { + return [...marked].map((index) => choices[index]?.value).filter((value): value is T => value !== undefined); + } + } +}; + +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 +): Promise; +export async function confirm( + message: string | ConfirmPromptOptions, + defaultValue = true, + yes = 'Yes', + no = 'No', + required: boolean | string = false, + validate: ConfirmPromptOptions['validate'] = undefined, + hint = '' +): Promise { + const options: ConfirmPromptOptions = typeof message === 'string' + ? { message, label: message, default: defaultValue, yes, no, required, validate, hint } + : { ...message, default: message.default ?? true }; + + return promptUntilValid(options, async () => { + const suffix = options.default === false ? ' [y/N]' : ' [Y/n]'; + const answer = (await ask(`${options.message}${suffix}`, options.hint)).trim().toLowerCase(); + + if (answer === '' && options.default !== undefined) { + return options.default; + } + + return ['y', 'yes', options.yes?.toLowerCase()].includes(answer); + }); +} + +export const select = async (options: SelectPromptOptions): Promise => { + const choices = normalizeChoices(options.options); + + return promptUntilValid(options, async () => { + return readSelectedChoice(options.message, choices, options.hint).catch((error: unknown) => { + if (options.default !== undefined && error instanceof PromptValidationError) { + return options.default; + } + + throw error; + }); + }); +}; + +export const multiselect = async (options: MultiSelectPromptOptions): Promise => { + const choices = normalizeChoices(options.options); + + return promptUntilValid(options, async () => readMultipleChoices(options.message, choices, options.default, options.hint)); +}; diff --git a/packages/tui/src/prompts/suggest.ts b/packages/tui/src/prompts/suggest.ts new file mode 100644 index 00000000..7b4ddfea --- /dev/null +++ b/packages/tui/src/prompts/suggest.ts @@ -0,0 +1,182 @@ +import { promptEnvironment } from '#tui/environment'; +import { Key, oneOf } from '#tui/key'; +import { ask, promptUntilValid } from '#tui/prompt'; +import { applyTypedKey } from '#tui/typed-value'; +import type { MaybePromise, TextPromptOptions } from '#tui/types'; + +export type SuggestOptions = TextPromptOptions & { + options: string[] | ((query: string) => MaybePromise); + scroll?: number; + info?: string | ((value: string) => string); +}; + +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 +): SuggestOptions => { + if (typeof message === 'string') { + return { message, label: message, options, placeholder, default: defaultValue, scroll, required, validate, hint, transform }; + } + + return { ...message, default: message.default ?? '' }; +}; + +const resolveSuggestions = async (source: SuggestOptions['options'], query: string): Promise => { + const options = typeof source === 'function' ? await source(query) : source; + + if (typeof source === 'function') { + return [...options]; + } + + return options.filter((option: string) => option.toLowerCase().startsWith(query.toLowerCase())); +}; + +const readSuggestionValue = async (options: SuggestOptions): Promise => { + const environment = promptEnvironment(); + + if (!environment.input.readKey) { + return ask(options.message, options.hint); + } + + let state = { + cursor: options.default?.length ?? 0, + value: options.default ?? '' + }; + let highlighted: number | null = null; + let matches: string[] = await resolveSuggestions(options.options, state.value); + + environment.output.write(`${options.message}\n`); + + while (true) { + const key = await environment.input.readKey(); + + if (key === null) { + return state.value; + } + + if (key === Key.tab) { + matches = await resolveSuggestions(options.options, state.value); + const match = matches[highlighted ?? 0]; + + if (match !== undefined) { + state = { cursor: match.length, value: match }; + } + + continue; + } + + if (key === Key.down || key === Key.downArrow || key === Key.ctrlN || key === Key.shiftTab) { + matches = await resolveSuggestions(options.options, state.value); + highlighted = matches.length === 0 ? null : ((highlighted ?? -1) + 1) % matches.length; + continue; + } + + if (key === Key.up || key === Key.upArrow || key === Key.ctrlP) { + matches = await resolveSuggestions(options.options, state.value); + highlighted = matches.length === 0 ? null : ((highlighted ?? matches.length) - 1 + matches.length) % matches.length; + continue; + } + + if (oneOf([Key.home, Key.ctrlA], key) && highlighted !== null) { + highlighted = 0; + continue; + } + + if (oneOf([Key.end, Key.ctrlE], key) && highlighted !== null) { + highlighted = Math.max(0, matches.length - 1); + continue; + } + + if (key === Key.enter) { + if (highlighted !== null && matches[highlighted] !== undefined) { + return matches[highlighted]; + } + + return state.value; + } + + const next = applyTypedKey(state, key); + + if (next.submitted) { + return state.value; + } + + if (next.cancelled) { + environment.error.write('Cancelled.\n'); + + return state.value; + } + + state = { cursor: next.cursor, value: next.value }; + highlighted = null; + matches = await resolveSuggestions(options.options, state.value); + } +}; + +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'] +): 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 +): Promise { + const options = suggestOptions(message, source, placeholder, defaultValue, scroll, required, validate, hint, transform); + + return promptUntilValid(options, async () => { + const answer = await readSuggestionValue(options); + const value = answer === '' && options.default !== undefined ? options.default : answer; + + return options.transform ? options.transform(value) : value; + }); +} + +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'] +): 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 +): Promise { + return suggest( + typeof message === 'string' + ? { message, label: message, options: source, placeholder, default: defaultValue, required, validate, hint, transform } + : message + ); +} From 14d07dff9ac76595e0f06dfd8662d753517efaf1 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 13:52:56 +0800 Subject: [PATCH 009/675] Split output domains --- UPSTREAM.md | 5 +- packages/tui/src/output.ts | 129 ++-------------------------- packages/tui/src/output/notes.ts | 39 +++++++++ packages/tui/src/output/notify.ts | 30 +++++++ packages/tui/src/output/table.ts | 49 +++++++++++ packages/tui/src/output/terminal.ts | 9 ++ 6 files changed, 136 insertions(+), 125 deletions(-) create mode 100644 packages/tui/src/output/notes.ts create mode 100644 packages/tui/src/output/notify.ts create mode 100644 packages/tui/src/output/table.ts create mode 100644 packages/tui/src/output/terminal.ts diff --git a/UPSTREAM.md b/UPSTREAM.md index 3bdaa7df..073caeb6 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -22,7 +22,10 @@ TypeScript and OpenTUI internals. | Pause | `packages/tui/src/prompts/pause.ts` | | Choice normalization, matching, navigation, and list rendering concerns | `packages/tui/src/concerns/choices.ts` | | Form builder | `packages/tui/src/form.ts` | -| Note, notify, table, grid, title, clear | `packages/tui/src/output.ts` | +| Notes and alert-style output | `packages/tui/src/output/notes.ts` | +| Native notifications | `packages/tui/src/output/notify.ts` | +| Tables and grids | `packages/tui/src/output/table.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` | | ANSI, wrapping, truncation, Unicode width | `packages/tui/src/strings.ts` | | OpenTUI adapter | `packages/tui/src/opentui.ts` | diff --git a/packages/tui/src/output.ts b/packages/tui/src/output.ts index a5c12ea9..9e25b49e 100644 --- a/packages/tui/src/output.ts +++ b/packages/tui/src/output.ts @@ -1,124 +1,5 @@ -import { spawn } from 'node:child_process'; -import { platform } from 'node:process'; -import { promptEnvironment } from '#tui/environment'; -import { clearTerminal, setTerminalTitle } from '#tui/terminal'; -import { renderTable, symbols } from '#tui/theme'; -import type { TableOptions } from '#tui/types'; - -const stringify = (value: string | number | boolean | null | undefined): string => { - return value === null || value === undefined ? '' : String(value); -}; - -export type NoteType = 'alert' | 'error' | 'info' | 'intro' | 'outro' | 'warning' | string; - -const notePrefix = (type?: NoteType | null): string => { - switch (type) { - case 'error': - return symbols.error; - case 'warning': - case 'alert': - return symbols.warning; - case 'info': - return symbols.info; - case 'intro': - case 'outro': - return symbols.success; - default: - return symbols.info; - } -}; - -export const note = (message: string, type?: NoteType | null): void => { - const prefix = notePrefix(type); - - promptEnvironment().output.write(`${prefix} ${message}\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'); - -export const notify = (title: string, body = '', subtitle = '', sound = '', icon = ''): void => { - if (platform === 'darwin') { - const script = [ - 'display notification', - JSON.stringify(body), - 'with title', - JSON.stringify(title), - subtitle ? `subtitle ${JSON.stringify(subtitle)}` : '', - sound ? `sound name ${JSON.stringify(sound)}` : '' - ].filter(Boolean).join(' '); - - spawn('osascript', ['-e', script], { detached: true, stdio: 'ignore' }).unref(); - - return; - } - - if (platform === 'linux') { - const args = icon ? ['--icon', icon, title, body] : [title, body]; - - spawn('notify-send', args, { detached: true, stdio: 'ignore' }).unref(); - - return; - } - - note(body ? `${title}: ${body}` : title, 'info'); -}; - -const normalizeTableOptions = (headersOrOptions: TableOptions | string[] = [], rows: TableOptions['rows'] | null = null): Required => { - if (!Array.isArray(headersOrOptions) && 'rows' in headersOrOptions) { - const headers = headersOrOptions.headers ?? Object.keys(headersOrOptions.rows[0] ?? {}); - - return { headers, rows: headersOrOptions.rows }; - } - - return { - headers: headersOrOptions, - rows: rows ?? [] - }; -}; - -export const table = (headersOrOptions: TableOptions | string[] = [], rows: TableOptions['rows'] | null = null): void => { - const options = normalizeTableOptions(headersOrOptions, rows); - const normalizedRows = options.rows.map((row) => { - if (Array.isArray(row)) { - return row.map(stringify); - } - - return options.headers.map((header) => stringify(row[header])); - }); - - promptEnvironment().output.write(`${renderTable(options.headers, normalizedRows)}\n`); -}; - -export const grid = (items: Array = [], maxWidth?: number): void => { - const width = maxWidth ?? 4; - const rows: string[][] = []; - - for (let index = 0; index < items.length; index += width) { - rows.push(items.slice(index, index + width).map(String)); - } - - promptEnvironment().output.write(rows.map((row) => row.join(' ')).join('\n')); - promptEnvironment().output.write(items.length > 0 ? '\n' : ''); -}; - -export const dataTable = table; - -export const datatable = table; - -export const title = (value: string): void => { - setTerminalTitle(value); -}; - -export const clear = (): void => { - clearTerminal(); -}; +export { alert, error, info, intro, note, outro, warning } from '#tui/output/notes'; +export type { NoteType } from '#tui/output/notes'; +export { notify } from '#tui/output/notify'; +export { dataTable, datatable, grid, table } from '#tui/output/table'; +export { clear, title } from '#tui/output/terminal'; diff --git a/packages/tui/src/output/notes.ts b/packages/tui/src/output/notes.ts new file mode 100644 index 00000000..5e855b78 --- /dev/null +++ b/packages/tui/src/output/notes.ts @@ -0,0 +1,39 @@ +import { promptEnvironment } from '#tui/environment'; +import { symbols } from '#tui/theme'; + +export type NoteType = 'alert' | 'error' | 'info' | 'intro' | 'outro' | 'warning' | string; + +const notePrefix = (type?: NoteType | null): string => { + switch (type) { + case 'error': + return symbols.error; + case 'warning': + case 'alert': + return symbols.warning; + case 'info': + return symbols.info; + case 'intro': + case 'outro': + return symbols.success; + default: + return symbols.info; + } +}; + +export const note = (message: string, type?: NoteType | null): void => { + const prefix = notePrefix(type); + + promptEnvironment().output.write(`${prefix} ${message}\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/notify.ts b/packages/tui/src/output/notify.ts new file mode 100644 index 00000000..2b3e3594 --- /dev/null +++ b/packages/tui/src/output/notify.ts @@ -0,0 +1,30 @@ +import { spawn } from 'node:child_process'; +import { platform } from 'node:process'; +import { note } from '#tui/output/notes'; + +export const notify = (title: string, body = '', subtitle = '', sound = '', icon = ''): void => { + if (platform === 'darwin') { + const script = [ + 'display notification', + JSON.stringify(body), + 'with title', + JSON.stringify(title), + subtitle ? `subtitle ${JSON.stringify(subtitle)}` : '', + sound ? `sound name ${JSON.stringify(sound)}` : '' + ].filter(Boolean).join(' '); + + spawn('osascript', ['-e', script], { detached: true, stdio: 'ignore' }).unref(); + + return; + } + + if (platform === 'linux') { + const args = icon ? ['--icon', icon, title, body] : [title, body]; + + spawn('notify-send', args, { detached: true, stdio: 'ignore' }).unref(); + + return; + } + + note(body ? `${title}: ${body}` : title, 'info'); +}; diff --git a/packages/tui/src/output/table.ts b/packages/tui/src/output/table.ts new file mode 100644 index 00000000..76a09ee2 --- /dev/null +++ b/packages/tui/src/output/table.ts @@ -0,0 +1,49 @@ +import { promptEnvironment } from '#tui/environment'; +import { renderTable } from '#tui/theme'; +import type { TableOptions } from '#tui/types'; + +const stringify = (value: string | number | boolean | null | undefined): string => { + return value === null || value === undefined ? '' : String(value); +}; + +const normalizeTableOptions = (headersOrOptions: TableOptions | string[] = [], rows: TableOptions['rows'] | null = null): Required => { + if (!Array.isArray(headersOrOptions) && 'rows' in headersOrOptions) { + const headers = headersOrOptions.headers ?? Object.keys(headersOrOptions.rows[0] ?? {}); + + return { headers, rows: headersOrOptions.rows }; + } + + return { + headers: headersOrOptions, + rows: rows ?? [] + }; +}; + +export const table = (headersOrOptions: TableOptions | string[] = [], rows: TableOptions['rows'] | null = null): void => { + const options = normalizeTableOptions(headersOrOptions, rows); + const normalizedRows = options.rows.map((row) => { + if (Array.isArray(row)) { + return row.map(stringify); + } + + return options.headers.map((header) => stringify(row[header])); + }); + + promptEnvironment().output.write(`${renderTable(options.headers, normalizedRows)}\n`); +}; + +export const grid = (items: Array = [], maxWidth?: number): void => { + const width = maxWidth ?? 4; + const rows: string[][] = []; + + for (let index = 0; index < items.length; index += width) { + rows.push(items.slice(index, index + width).map(String)); + } + + promptEnvironment().output.write(rows.map((row) => row.join(' ')).join('\n')); + promptEnvironment().output.write(items.length > 0 ? '\n' : ''); +}; + +export const dataTable = table; + +export const datatable = table; diff --git a/packages/tui/src/output/terminal.ts b/packages/tui/src/output/terminal.ts new file mode 100644 index 00000000..4a0efebd --- /dev/null +++ b/packages/tui/src/output/terminal.ts @@ -0,0 +1,9 @@ +import { clearTerminal, setTerminalTitle } from '#tui/terminal'; + +export const title = (value: string): void => { + setTerminalTitle(value); +}; + +export const clear = (): void => { + clearTerminal(); +}; From 780f8f41eb5a70948898e9eb0d0c92ca0aefe612 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 13:54:19 +0800 Subject: [PATCH 010/675] Split basic prompt domains --- UPSTREAM.md | 5 +- packages/tui/src/concerns/text-options.ts | 9 ++ packages/tui/src/prompts/basic.ts | 165 +--------------------- packages/tui/src/prompts/number.ts | 58 ++++++++ packages/tui/src/prompts/password.ts | 27 ++++ packages/tui/src/prompts/text.ts | 38 +++++ packages/tui/src/prompts/textarea.ts | 41 ++++++ 7 files changed, 181 insertions(+), 162 deletions(-) create mode 100644 packages/tui/src/concerns/text-options.ts create mode 100644 packages/tui/src/prompts/number.ts create mode 100644 packages/tui/src/prompts/password.ts create mode 100644 packages/tui/src/prompts/text.ts create mode 100644 packages/tui/src/prompts/textarea.ts diff --git a/UPSTREAM.md b/UPSTREAM.md index 073caeb6..445dd09a 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -15,7 +15,10 @@ TypeScript and OpenTUI internals. | Prompt lifecycle and fallback behavior | `packages/tui/src/prompt.ts`, `packages/tui/src/environment.ts` | | Key constants | `packages/tui/src/key.ts` | | Terminal helpers | `packages/tui/src/terminal.ts` | -| Text, textarea, number, password | `packages/tui/src/prompts/basic.ts`, `packages/tui/src/typed-value.ts` | +| Text input | `packages/tui/src/prompts/text.ts`, `packages/tui/src/typed-value.ts` | +| Textarea input | `packages/tui/src/prompts/textarea.ts`, `packages/tui/src/typed-value.ts` | +| Password input | `packages/tui/src/prompts/password.ts`, `packages/tui/src/typed-value.ts` | +| Number input | `packages/tui/src/prompts/number.ts`, `packages/tui/src/typed-value.ts` | | Confirm, select, multiselect | `packages/tui/src/prompts/select.ts` | | Suggest, autocomplete | `packages/tui/src/prompts/suggest.ts` | | Search, multisearch | `packages/tui/src/prompts/search.ts` | diff --git a/packages/tui/src/concerns/text-options.ts b/packages/tui/src/concerns/text-options.ts new file mode 100644 index 00000000..9c82d9d1 --- /dev/null +++ b/packages/tui/src/concerns/text-options.ts @@ -0,0 +1,9 @@ +import type { TextPromptOptions } from '#tui/types'; + +export const textOptions = (message: string | TextPromptOptions, fallback = ''): TextPromptOptions => { + if (typeof message === 'string') { + return { message, default: fallback }; + } + + return { ...message, default: message.default ?? fallback }; +}; diff --git a/packages/tui/src/prompts/basic.ts b/packages/tui/src/prompts/basic.ts index 7d6d563c..5bf4ecef 100644 --- a/packages/tui/src/prompts/basic.ts +++ b/packages/tui/src/prompts/basic.ts @@ -1,161 +1,4 @@ -import { promptUntilValid, PromptValidationError } from '#tui/prompt'; -import { readTypedValue } from '#tui/typed-value'; -import type { NumberPromptOptions, TextPromptOptions } from '#tui/types'; - -const textOptions = (message: string | TextPromptOptions, fallback = ''): TextPromptOptions => { - if (typeof message === 'string') { - return { message, default: fallback }; - } - - return { ...message, default: message.default ?? fallback }; -}; - -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 { - const options = typeof message === 'string' - ? textOptions({ message, label: message, placeholder, default: defaultValue, required, validate, hint, transform }) - : textOptions(message); - - return promptUntilValid(options, async () => { - const answer = await readTypedValue(options.message, { - default: options.default, - hint: options.hint - }); - const value = answer === '' && options.default !== undefined ? options.default : answer; - - return options.transform ? options.transform(value) : value; - }); -} - -export function textarea(options: TextPromptOptions & { rows?: number }): Promise; -export function textarea( - label: string, - placeholder?: string, - defaultValue?: string, - required?: boolean | string, - validate?: TextPromptOptions['validate'], - hint?: string, - rows?: number, - transform?: TextPromptOptions['transform'] -): Promise; -export async function textarea( - message: string | (TextPromptOptions & { rows?: number }), - placeholder = '', - defaultValue = '', - required: boolean | string = false, - validate: TextPromptOptions['validate'] = undefined, - hint = '', - _rows = 5, - transform: TextPromptOptions['transform'] = undefined -): Promise { - const options = typeof message === 'string' - ? textOptions({ message, label: message, placeholder, default: defaultValue, required, validate, hint, transform }) - : textOptions(message); - - return promptUntilValid(options, async () => { - const answer = await readTypedValue(options.message, { - default: options.default, - hint: options.hint, - allowNewLine: true - }); - const value = answer === '' && options.default !== undefined ? options.default : answer; - - return options.transform ? options.transform(value) : value; - }); -} - -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 { - const options = typeof message === 'string' - ? textOptions({ message, label: message, placeholder, required, validate, hint, transform }) - : textOptions(message); - - return text(options); -} - -export function number(options: NumberPromptOptions): Promise; -export function number( - label: string, - placeholder?: string, - defaultValue?: number, - required?: boolean | string, - validate?: NumberPromptOptions['validate'], - hint?: string, - min?: number, - max?: number, - step?: number -): Promise; -export async function number( - message: string | NumberPromptOptions, - _placeholder = '', - defaultValue = 0, - required: boolean | string = false, - validate: NumberPromptOptions['validate'] = undefined, - hint = '', - min: number | undefined = undefined, - max: number | undefined = undefined, - step: number | undefined = undefined -): Promise { - const options: NumberPromptOptions = typeof message === 'string' - ? { message, label: message, default: defaultValue, required, validate, hint, min, max, step } - : { ...message, default: message.default ?? defaultValue }; - - return promptUntilValid(options, async () => { - const answer = await readTypedValue(options.message, { - default: options.default === undefined ? undefined : String(options.default), - hint: options.hint - }); - - if (answer === '' && options.default !== undefined) { - return options.default; - } - - const parsed = options.integer ? Number.parseInt(answer, 10) : Number.parseFloat(answer); - - if (Number.isNaN(parsed)) { - throw new PromptValidationError('Please enter a valid number.'); - } - - if (options.min !== undefined && parsed < options.min) { - throw new PromptValidationError(`Please enter a value greater than or equal to ${options.min}.`); - } - - if (options.max !== undefined && parsed > options.max) { - throw new PromptValidationError(`Please enter a value less than or equal to ${options.max}.`); - } - - return options.transform ? options.transform(parsed) : parsed; - }); -} +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/number.ts b/packages/tui/src/prompts/number.ts new file mode 100644 index 00000000..afe3fc58 --- /dev/null +++ b/packages/tui/src/prompts/number.ts @@ -0,0 +1,58 @@ +import { promptUntilValid, PromptValidationError } from '#tui/prompt'; +import { readTypedValue } from '#tui/typed-value'; +import type { NumberPromptOptions } from '#tui/types'; + +export function number(options: NumberPromptOptions): Promise; +export function number( + label: string, + placeholder?: string, + defaultValue?: number, + required?: boolean | string, + validate?: NumberPromptOptions['validate'], + hint?: string, + min?: number, + max?: number, + step?: number +): Promise; +export async function number( + message: string | NumberPromptOptions, + _placeholder = '', + defaultValue = 0, + required: boolean | string = false, + validate: NumberPromptOptions['validate'] = undefined, + hint = '', + min: number | undefined = undefined, + max: number | undefined = undefined, + step: number | undefined = undefined +): Promise { + const options: NumberPromptOptions = typeof message === 'string' + ? { message, label: message, default: defaultValue, required, validate, hint, min, max, step } + : { ...message, default: message.default ?? defaultValue }; + + return promptUntilValid(options, async () => { + const answer = await readTypedValue(options.message, { + default: options.default === undefined ? undefined : String(options.default), + hint: options.hint + }); + + if (answer === '' && options.default !== undefined) { + return options.default; + } + + const parsed = options.integer ? Number.parseInt(answer, 10) : Number.parseFloat(answer); + + if (Number.isNaN(parsed)) { + throw new PromptValidationError('Please enter a valid number.'); + } + + if (options.min !== undefined && parsed < options.min) { + throw new PromptValidationError(`Please enter a value greater than or equal to ${options.min}.`); + } + + if (options.max !== undefined && parsed > options.max) { + throw new PromptValidationError(`Please enter a value less than or equal to ${options.max}.`); + } + + return options.transform ? options.transform(parsed) : parsed; + }); +} diff --git a/packages/tui/src/prompts/password.ts b/packages/tui/src/prompts/password.ts new file mode 100644 index 00000000..a0a42e37 --- /dev/null +++ b/packages/tui/src/prompts/password.ts @@ -0,0 +1,27 @@ +import { textOptions } from '#tui/concerns/text-options'; +import { text } from '#tui/prompts/text'; +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 { + const options = typeof message === 'string' + ? textOptions({ message, label: message, placeholder, required, validate, hint, transform }) + : textOptions(message); + + return text(options); +} diff --git a/packages/tui/src/prompts/text.ts b/packages/tui/src/prompts/text.ts new file mode 100644 index 00000000..1aab28fa --- /dev/null +++ b/packages/tui/src/prompts/text.ts @@ -0,0 +1,38 @@ +import { textOptions } from '#tui/concerns/text-options'; +import { promptUntilValid } from '#tui/prompt'; +import { readTypedValue } from '#tui/typed-value'; +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 { + const options = typeof message === 'string' + ? textOptions({ message, label: message, placeholder, default: defaultValue, required, validate, hint, transform }) + : textOptions(message); + + return promptUntilValid(options, async () => { + const answer = await readTypedValue(options.message, { + default: options.default, + hint: options.hint + }); + const value = answer === '' && options.default !== undefined ? options.default : answer; + + return options.transform ? options.transform(value) : value; + }); +} diff --git a/packages/tui/src/prompts/textarea.ts b/packages/tui/src/prompts/textarea.ts new file mode 100644 index 00000000..af0f12eb --- /dev/null +++ b/packages/tui/src/prompts/textarea.ts @@ -0,0 +1,41 @@ +import { textOptions } from '#tui/concerns/text-options'; +import { promptUntilValid } from '#tui/prompt'; +import { readTypedValue } from '#tui/typed-value'; +import type { TextPromptOptions } from '#tui/types'; + +export function textarea(options: TextPromptOptions & { rows?: number }): Promise; +export function textarea( + label: string, + placeholder?: string, + defaultValue?: string, + required?: boolean | string, + validate?: TextPromptOptions['validate'], + hint?: string, + rows?: number, + transform?: TextPromptOptions['transform'] +): Promise; +export async function textarea( + message: string | (TextPromptOptions & { rows?: number }), + placeholder = '', + defaultValue = '', + required: boolean | string = false, + validate: TextPromptOptions['validate'] = undefined, + hint = '', + _rows = 5, + transform: TextPromptOptions['transform'] = undefined +): Promise { + const options = typeof message === 'string' + ? textOptions({ message, label: message, placeholder, default: defaultValue, required, validate, hint, transform }) + : textOptions(message); + + return promptUntilValid(options, async () => { + const answer = await readTypedValue(options.message, { + default: options.default, + hint: options.hint, + allowNewLine: true + }); + const value = answer === '' && options.default !== undefined ? options.default : answer; + + return options.transform ? options.transform(value) : value; + }); +} From 955c2b3f8c78bc71034bdbbbc09dda5711594a76 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 13:56:27 +0800 Subject: [PATCH 011/675] Add chained form builder --- UPSTREAM.md | 2 +- packages/acceptance/tests/helpers.test.ts | 11 +- packages/tui/src/form.ts | 48 ++----- packages/tui/src/form/builder.ts | 146 ++++++++++++++++++++++ packages/tui/src/form/types.ts | 10 ++ packages/tui/tests/form.test.ts | 65 ++++++++++ 6 files changed, 237 insertions(+), 45 deletions(-) create mode 100644 packages/tui/src/form/builder.ts create mode 100644 packages/tui/src/form/types.ts create mode 100644 packages/tui/tests/form.test.ts diff --git a/UPSTREAM.md b/UPSTREAM.md index 445dd09a..0d3129ab 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -24,7 +24,7 @@ TypeScript and OpenTUI internals. | Search, multisearch | `packages/tui/src/prompts/search.ts` | | Pause | `packages/tui/src/prompts/pause.ts` | | Choice normalization, matching, navigation, and list rendering concerns | `packages/tui/src/concerns/choices.ts` | -| Form builder | `packages/tui/src/form.ts` | +| Form builder | `packages/tui/src/form/*`, `packages/tui/src/form.ts` | | Notes and alert-style output | `packages/tui/src/output/notes.ts` | | Native notifications | `packages/tui/src/output/notify.ts` | | Tables and grids | `packages/tui/src/output/table.ts` | diff --git a/packages/acceptance/tests/helpers.test.ts b/packages/acceptance/tests/helpers.test.ts index dfdcac9f..98188c8c 100644 --- a/packages/acceptance/tests/helpers.test.ts +++ b/packages/acceptance/tests/helpers.test.ts @@ -66,13 +66,14 @@ describe('prompt helpers', () => { it('runs form builders', async () => { const response = await scripted(['A', 'd', 'a', '\n', 'y'], () => - form(async (builder) => ({ - name: await builder.text('Name'), - active: await builder.confirm('Active') - })) + form() + .text('Name', '', '', false, undefined, '', 'name') + .confirm('Active', true, 'Yes', 'No', false, undefined, '', 'active') + .submit() ); - expect(response.result).toEqual({ name: 'Ada', active: true }); + expect(response.result.name).toBe('Ada'); + expect(response.result.active).toBe(true); }); it('writes output helpers', async () => { diff --git a/packages/tui/src/form.ts b/packages/tui/src/form.ts index a98c1c4b..a162828d 100644 --- a/packages/tui/src/form.ts +++ b/packages/tui/src/form.ts @@ -1,43 +1,13 @@ -import { confirm, multiselect, select } from '#tui/prompts/choices'; -import { number, password, text, textarea } from '#tui/prompts/basic'; -import type { - ConfirmPromptOptions, - MultiSelectPromptOptions, - NumberPromptOptions, - SelectPromptOptions, - TextPromptOptions -} from '#tui/types'; +export { FormBuilder } from '#tui/form/builder'; +export type { FormResponses, FormStep } from '#tui/form/types'; -export class FormBuilder { - async text(message: string | TextPromptOptions): Promise { - return typeof message === 'string' ? text(message) : text(message); - } +import { FormBuilder } from '#tui/form/builder'; +import type { MaybePromise } from '#tui/types'; - async textarea(message: string | TextPromptOptions): Promise { - return typeof message === 'string' ? textarea(message) : textarea(message); - } +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(); - async password(message: string | TextPromptOptions): Promise { - return typeof message === 'string' ? password(message) : password(message); - } - - async number(message: string | NumberPromptOptions): Promise { - return typeof message === 'string' ? number(message) : number(message); - } - - async confirm(message: string | ConfirmPromptOptions): Promise { - return typeof message === 'string' ? confirm(message) : confirm(message); - } - - async select(options: SelectPromptOptions): Promise { - return select(options); - } - - async multiselect(options: MultiSelectPromptOptions): Promise { - return multiselect(options); - } + return builder ? Promise.resolve(builder(instance)) : instance; } - -export const form = async (builder: (form: FormBuilder) => Promise | T): Promise => { - return builder(new FormBuilder()); -}; diff --git a/packages/tui/src/form/builder.ts b/packages/tui/src/form/builder.ts new file mode 100644 index 00000000..1c471eb9 --- /dev/null +++ b/packages/tui/src/form/builder.ts @@ -0,0 +1,146 @@ +import { alert, error, info, intro, note, outro, table, warning } from '#tui/output'; +import { confirm, multiselect, select } from '#tui/prompts/choices'; +import { number, password, text, textarea } from '#tui/prompts/basic'; +import { progress, spin } from '#tui/status'; +import type { FormResponses, FormStep } from '#tui/form/types'; +import type { ChoiceInput, MaybePromise, TableOptions, TextPromptOptions } from '#tui/types'; + +export class FormBuilder { + readonly #steps: FormStep[] = []; + readonly #responses: FormResponses = [] as unknown as FormResponses; + + add(step: (responses: FormResponses, previous: unknown) => MaybePromise, name?: string, ignoreWhenReverting = false): this { + this.#steps.push({ + condition: true, + ignoreWhenReverting, + name, + run: step + }); + + return this; + } + + addIf( + condition: boolean | ((responses: FormResponses) => boolean), + step: (responses: FormResponses, previous: unknown) => MaybePromise, + name?: string, + ignoreWhenReverting = false + ): this { + this.#steps.push({ + condition, + ignoreWhenReverting, + name, + run: step + }); + + return this; + } + + async submit(): Promise { + for (const [index, step] of this.#steps.entries()) { + const key = step.name ?? index; + const shouldRun = typeof step.condition === 'function' ? step.condition(this.#responses) : step.condition; + + if (!shouldRun) { + this.#responses[key] = null; + continue; + } + + this.#responses[key] = await step.run(this.#responses, this.#responses[key]); + } + + return this.#responses; + } + + text(label: string, placeholder = '', defaultValue = '', required: boolean | string = false, validate: TextPromptOptions['validate'] = undefined, hint = '', name?: string, transform?: TextPromptOptions['transform']): this { + return this.add((_, previous) => text(label, placeholder, previous === undefined || previous === null ? defaultValue : String(previous), required, validate, hint, transform), name); + } + + textarea(label: string, placeholder = '', defaultValue = '', required: boolean | string = false, validate: TextPromptOptions['validate'] = undefined, hint = '', rows = 5, name?: string, transform?: TextPromptOptions['transform']): this { + return this.add((_, previous) => textarea(label, placeholder, previous === undefined || previous === null ? defaultValue : String(previous), required, validate, hint, rows, transform), name); + } + + password(label: string, placeholder = '', required: boolean | string = false, validate: TextPromptOptions['validate'] = undefined, hint = '', name?: string, transform?: TextPromptOptions['transform']): this { + return this.add(() => password(label, placeholder, required, validate, hint, transform), name); + } + + number(label: string, placeholder = '', defaultValue = 0, required: boolean | string = false, validate: ((value: number) => MaybePromise) | undefined = undefined, hint = '', min?: number, max?: number, step?: number, name?: string): this { + return this.add((_, previous) => number(label, placeholder, typeof previous === 'number' ? previous : defaultValue, required, validate, hint, min, max, step), name); + } + + confirm(label: string, defaultValue = true, yes = 'Yes', no = 'No', required: boolean | string = false, validate: ((value: boolean) => MaybePromise) | undefined = undefined, hint = '', name?: string): this { + return this.add(() => confirm(label, defaultValue, yes, no, required, validate, hint), name); + } + + select(label: string, options: Array>, defaultValue?: T, scroll = 5, validate: ((value: T) => MaybePromise) | undefined = undefined, hint = '', required: boolean | string = true, name?: string): this { + return this.add((_, previous) => select({ message: label, options, default: previous === undefined ? defaultValue : previous as T, scroll, validate, hint, required }), name); + } + + multiselect(label: string, options: Array>, defaultValue: T[] = [], scroll = 5, required: boolean | string = false, validate: ((value: T[]) => MaybePromise) | undefined = undefined, hint = 'Use the space bar to select options.', name?: string): this { + return this.add((_, previous) => multiselect({ message: label, options, default: Array.isArray(previous) ? previous as T[] : defaultValue, scroll, required, validate, hint }), name); + } + + spin(callback: () => MaybePromise, message = '', name?: string): this { + return this.add(() => spin(callback, { message }), name, true); + } + + note(message: string, type: string | null = null, name?: string): this { + return this.add(() => { + note(message, type); + return null; + }, name, true); + } + + error(message: string, name?: string): this { + return this.add(() => { + error(message); + return null; + }, name, true); + } + + warning(message: string, name?: string): this { + return this.add(() => { + warning(message); + return null; + }, name, true); + } + + alert(message: string, name?: string): this { + return this.add(() => { + alert(message); + return null; + }, name, true); + } + + info(message: string, name?: string): this { + return this.add(() => { + info(message); + return null; + }, name, true); + } + + intro(message: string, name?: string): this { + return this.add(() => { + intro(message); + return null; + }, name, true); + } + + outro(message: string, name?: string): this { + return this.add(() => { + outro(message); + return null; + }, name, true); + } + + table(headersOrOptions: TableOptions | string[] = [], rows: TableOptions['rows'] | null = null, name?: string): this { + return this.add(() => { + table(headersOrOptions, rows); + return null; + }, name, true); + } + + progress(label: string, steps: Iterable | number, callback?: (step: T | number, bar: import('#tui/status').Progress) => MaybePromise, hint = '', name?: string): this { + return this.add(() => progress(label, steps, callback, hint), name, true); + } +} diff --git a/packages/tui/src/form/types.ts b/packages/tui/src/form/types.ts new file mode 100644 index 00000000..71836f35 --- /dev/null +++ b/packages/tui/src/form/types.ts @@ -0,0 +1,10 @@ +import type { MaybePromise } from '#tui/types'; + +export type FormResponses = unknown[] & Record; + +export type FormStep = { + condition: boolean | ((responses: FormResponses) => boolean); + ignoreWhenReverting: boolean; + name?: string; + run: (responses: FormResponses, previous: unknown) => MaybePromise; +}; diff --git a/packages/tui/tests/form.test.ts b/packages/tui/tests/form.test.ts new file mode 100644 index 00000000..2229b393 --- /dev/null +++ b/packages/tui/tests/form.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; +import { createMemoryOutput, createScriptedInput, form, Key, withPromptEnvironment } from '../src/index'; + +describe('form builder', () => { + it('runs chained steps and returns positional responses', async () => { + const output = createMemoryOutput(); + const responses = await withPromptEnvironment( + { + input: createScriptedInput(['A', 'd', 'a', Key.enter, Key.enter, Key.enter]), + output, + error: output, + interactive: true + }, + () => + form() + .text('Name') + .select('Runtime', ['TS', 'JS']) + .confirm('Active') + .submit() + ); + + expect(responses[0]).toBe('Ada'); + expect(responses[1]).toBe('TS'); + expect(responses[2]).toBe(true); + }); + + it('keys named responses and passes prior responses to custom steps', async () => { + const output = createMemoryOutput(); + const responses = await withPromptEnvironment( + { + input: createScriptedInput(['A', 'd', 'a', Key.enter]), + output, + error: output, + interactive: true + }, + () => + form() + .text('Name', '', '', false, undefined, '', 'name') + .add((values) => `Hello ${values.name}`, 'greeting') + .submit() + ); + + expect(responses.name).toBe('Ada'); + expect(responses.greeting).toBe('Hello Ada'); + }); + + it('stores null for skipped conditional steps', async () => { + const responses = await withPromptEnvironment( + { + input: createScriptedInput(['n']), + output: createMemoryOutput(), + error: createMemoryOutput(), + interactive: true + }, + () => + form() + .confirm('Include details?', false, 'Yes', 'No', false, undefined, '', 'include') + .addIf((values) => values.include === true, () => 'details', 'details') + .submit() + ); + + expect(responses.include).toBe(false); + expect(responses.details).toBeNull(); + }); +}); From 0788de7384ff24fc29bf0b57f54262cbb1613c48 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 14:04:02 +0800 Subject: [PATCH 012/675] Add selectable data table prompt --- LICENSE.md | 2 +- UPSTREAM.md | 3 +- packages/acceptance/tests/helpers.test.ts | 2 +- packages/tui/src/form/builder.ts | 8 +- packages/tui/src/index.ts | 4 + packages/tui/src/output.ts | 3 +- packages/tui/src/output/data-table.ts | 162 ++++++++++++++++++++++ packages/tui/src/output/table.ts | 2 - packages/tui/src/types.ts | 21 ++- packages/tui/tests/table.test.ts | 110 +++++++++++++++ 10 files changed, 307 insertions(+), 10 deletions(-) create mode 100644 packages/tui/src/output/data-table.ts create mode 100644 packages/tui/tests/table.test.ts diff --git a/LICENSE.md b/LICENSE.md index f7591733..c53de0be 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -2,7 +2,7 @@ MIT License Copyright (c) 2026 Oullin -Portions of this package are derived from upstream MIT-licensed prompt toolkit code, Copyright (c) Taylor Otwell. +Portions of this package are derived from upstream MIT-licensed prompt toolkit code. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/UPSTREAM.md b/UPSTREAM.md index 0d3129ab..a9d34f3d 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -27,7 +27,7 @@ TypeScript and OpenTUI internals. | Form builder | `packages/tui/src/form/*`, `packages/tui/src/form.ts` | | Notes and alert-style output | `packages/tui/src/output/notes.ts` | | Native notifications | `packages/tui/src/output/notify.ts` | -| Tables and grids | `packages/tui/src/output/table.ts` | +| Tables, data tables, and grids | `packages/tui/src/output/table.ts`, `packages/tui/src/output/data-table.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` | | ANSI, wrapping, truncation, Unicode width | `packages/tui/src/strings.ts` | @@ -39,7 +39,6 @@ The following upstream files still need full line-by-line behavioral mapping in later phases: - `src/AutoCompletePrompt.php` advanced ghost-text rendering -- `src/DataTablePrompt.php` - `src/FormBuilder.php` - `src/MultiSearchPrompt.php` full rendering and selected-result summary parity - `src/MultiSelectPrompt.php` diff --git a/packages/acceptance/tests/helpers.test.ts b/packages/acceptance/tests/helpers.test.ts index 98188c8c..96f6572d 100644 --- a/packages/acceptance/tests/helpers.test.ts +++ b/packages/acceptance/tests/helpers.test.ts @@ -38,7 +38,7 @@ const scripted = async (lines: string[], callback: () => Promise): Promise describe('prompt helpers', () => { it('reads text and number prompts', async () => { - await expect(scripted(['Tay', 'lor', '\n'], () => text('Name'))).resolves.toMatchObject({ result: 'Taylor' }); + 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 }); }); diff --git a/packages/tui/src/form/builder.ts b/packages/tui/src/form/builder.ts index 1c471eb9..5094d9e7 100644 --- a/packages/tui/src/form/builder.ts +++ b/packages/tui/src/form/builder.ts @@ -1,9 +1,9 @@ -import { alert, error, info, intro, note, outro, table, warning } from '#tui/output'; +import { alert, datatable, error, info, intro, note, outro, table, warning } from '#tui/output'; import { confirm, multiselect, select } from '#tui/prompts/choices'; import { number, password, text, textarea } from '#tui/prompts/basic'; import { progress, spin } from '#tui/status'; import type { FormResponses, FormStep } from '#tui/form/types'; -import type { ChoiceInput, MaybePromise, TableOptions, TextPromptOptions } from '#tui/types'; +import type { ChoiceInput, DataTablePromptOptions, MaybePromise, TableOptions, TextPromptOptions } from '#tui/types'; export class FormBuilder { readonly #steps: FormStep[] = []; @@ -140,6 +140,10 @@ export class FormBuilder { }, name, true); } + datatable(options: DataTablePromptOptions, name?: string): this { + return this.add(() => datatable(options), name); + } + progress(label: string, steps: Iterable | number, callback?: (step: T | number, bar: import('#tui/status').Progress) => MaybePromise, hint = '', name?: string): this { return this.add(() => progress(label, steps, callback, hint), name, true); } diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index 2b5155cc..5de07c04 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -15,6 +15,9 @@ export type { Choice, ChoiceInput, ConfirmPromptOptions, + DataTableObjectRow, + DataTablePromptOptions, + DataTableRow, MaybePromise, MultiSearchPromptOptions, MultiSelectPromptOptions, @@ -26,6 +29,7 @@ export type { SearchPromptOptions, SelectPromptOptions, StatusOptions, + TableCell, TableOptions, TextPromptOptions, ValidationResult, diff --git a/packages/tui/src/output.ts b/packages/tui/src/output.ts index 9e25b49e..410dc9cf 100644 --- a/packages/tui/src/output.ts +++ b/packages/tui/src/output.ts @@ -1,5 +1,6 @@ export { alert, error, info, intro, note, outro, warning } from '#tui/output/notes'; export type { NoteType } from '#tui/output/notes'; export { notify } from '#tui/output/notify'; -export { dataTable, datatable, grid, table } from '#tui/output/table'; +export { datatable } from '#tui/output/data-table'; +export { dataTable, grid, table } from '#tui/output/table'; export { clear, title } from '#tui/output/terminal'; diff --git a/packages/tui/src/output/data-table.ts b/packages/tui/src/output/data-table.ts new file mode 100644 index 00000000..aac41942 --- /dev/null +++ b/packages/tui/src/output/data-table.ts @@ -0,0 +1,162 @@ +import { promptEnvironment } from '#tui/environment'; +import { Key } from '#tui/key'; +import { promptUntilValid, PromptValidationError } from '#tui/prompt'; +import { renderTable } from '#tui/theme'; +import type { DataTableObjectRow, DataTablePromptOptions, DataTableRow, TableCell } from '#tui/types'; + +type VisibleRow = { + index: number; + row: DataTableRow; +}; + +const stringify = (value: TableCell): string => { + return value === null || value === undefined ? '' : String(value); +}; + +const isDataObjectRow = (row: DataTableRow): row is DataTableObjectRow => { + return !Array.isArray(row) && 'cells' in row && typeof row.cells === 'object' && row.cells !== null; +}; + +const rowFields = (row: DataTableRow): Record => { + if (Array.isArray(row)) { + return Object.fromEntries(row.map((value, index) => [String(index), value])); + } + + if (isDataObjectRow(row)) { + return row.cells; + } + + return row; +}; + +const derivedHeaders = (rows: Array>): string[] => { + const first = rows[0]; + + if (!first) { + return []; + } + + if (Array.isArray(first)) { + return first.map((_, index) => String(index + 1)); + } + + return Object.keys(rowFields(first)).filter((key) => key !== 'value'); +}; + +const rowCells = (headers: string[], row: DataTableRow): string[] => { + const fields = rowFields(row); + + return headers.map((header) => stringify(fields[header])); +}; + +const rowValue = (row: DataTableRow, index: number): T | number => { + if (isDataObjectRow(row) && row.value !== undefined) { + return row.value; + } + + if (!Array.isArray(row) && 'value' in row && row.value !== undefined) { + return row.value as T; + } + + return index; +}; + +const rowLabel = (headers: string[], row: DataTableRow): string => rowCells(headers, row).join(' '); + +const clampSelected = (selected: number, rows: Array>): number => { + if (rows.length === 0) { + return 0; + } + + return Math.min(selected, rows.length - 1); +}; + +const isPrintable = (key: string): boolean => { + return [...key].every((character) => (character.codePointAt(0) ?? 0) >= 32); +}; + +export const datatable = async (options: DataTablePromptOptions): Promise => { + const headers = options.headers ?? derivedHeaders(options.rows); + + return promptUntilValid(options, async () => { + const environment = promptEnvironment(); + let selected = 0; + let query = ''; + + const visibleRows = (): Array> => { + return options.rows + .map((row, index) => ({ index, row })) + .filter(({ row }) => options.filter?.(query, row) ?? rowLabel(headers, row).toLowerCase().includes(query.toLowerCase())); + }; + + const render = (): void => { + const rows = visibleRows(); + selected = clampSelected(selected, rows); + const renderedRows = rows.map(({ row }, index) => { + return [index === selected ? '›' : ' ', ...rowCells(headers, row)]; + }); + const querySuffix = query.length > 0 ? ` ${query}` : ''; + + environment.output.write(`${options.message}${querySuffix}\n`); + environment.output.write(`${renderTable(['', ...headers], renderedRows)}\n`); + }; + + if (!environment.input.readKey) { + const rows = visibleRows(); + const selectedRow = rows[0]; + + if (!selectedRow) { + throw new PromptValidationError('Please select a valid row.'); + } + + return rowValue(selectedRow.row, selectedRow.index); + } + + render(); + + while (true) { + const key = await environment.input.readKey(); + + if (key === null) { + throw new PromptValidationError('Please select a valid row.'); + } + + const rows = visibleRows(); + + if (key === Key.down || key === Key.downArrow || key === Key.ctrlN) { + selected = rows.length === 0 ? 0 : (selected + 1) % rows.length; + render(); + continue; + } + + if (key === Key.up || key === Key.upArrow || key === Key.ctrlP) { + selected = rows.length === 0 ? 0 : (selected - 1 + rows.length) % rows.length; + render(); + continue; + } + + if (key === Key.backspace || key === Key.ctrlH) { + query = query.slice(0, -1); + selected = 0; + render(); + continue; + } + + if (key === Key.enter) { + const selectedRow = rows[selected]; + + if (!selectedRow) { + throw new PromptValidationError('Please select a valid row.'); + } + + return rowValue(selectedRow.row, selectedRow.index); + } + + if (isPrintable(key)) { + query += key; + selected = 0; + render(); + } + } + }); +}; diff --git a/packages/tui/src/output/table.ts b/packages/tui/src/output/table.ts index 76a09ee2..c61517f6 100644 --- a/packages/tui/src/output/table.ts +++ b/packages/tui/src/output/table.ts @@ -45,5 +45,3 @@ export const grid = (items: Array = [], maxWidth?: nu }; export const dataTable = table; - -export const datatable = table; diff --git a/packages/tui/src/types.ts b/packages/tui/src/types.ts index 40aee145..8e4f515b 100644 --- a/packages/tui/src/types.ts +++ b/packages/tui/src/types.ts @@ -91,11 +91,30 @@ export type MultiSearchPromptOptions = BasePromptOptions & { info?: string | ((value: T[]) => string); }; +export type TableCell = string | number | boolean | null | undefined; + export type TableOptions = { headers?: string[]; - rows: Array | Record>; + rows: Array | Record>; +}; + +export type DataTablePromptOptions = BasePromptOptions & { + filter?: (query: string, row: DataTableRow) => boolean; + headers?: string[]; + rows: Array>; + scroll?: number; +}; + +export type DataTableObjectRow = { + cells: Record; + value?: T; }; +export type DataTableRow = + | Array + | Record + | DataTableObjectRow; + export type StatusOptions = { message: string; }; diff --git a/packages/tui/tests/table.test.ts b/packages/tui/tests/table.test.ts new file mode 100644 index 00000000..f5e9cfc7 --- /dev/null +++ b/packages/tui/tests/table.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from 'vitest'; +import { createMemoryOutput, createScriptedInput, datatable, form, Key, table, withPromptEnvironment } from '../src/index'; + +describe('table output', () => { + it('renders static table rows', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + table(['Name', 'Runtime'], [['Ollin', 'OpenTUI']]); + }); + + expect(output.text()).toContain('| Name | Runtime |'); + expect(output.text()).toContain('| Ollin | OpenTUI |'); + }); +}); + +describe('data table prompt', () => { + it('returns the selected explicit row value', async () => { + const output = createMemoryOutput(); + const selected = { id: 2 }; + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.enter]), + output, + error: output, + interactive: true + }, + () => + datatable({ + message: 'Pick project', + headers: ['Name', 'Runtime'], + rows: [ + { value: { id: 1 }, cells: { Name: 'Alpha', Runtime: 'Node' } }, + { value: selected, cells: { Name: 'Beta', Runtime: 'OpenTUI' } } + ] + }) + ); + + expect(result).toBe(selected); + expect(output.text()).toContain('Pick project'); + expect(output.text()).toContain('›'); + }); + + it('filters rows from typed input', async () => { + const output = createMemoryOutput(); + const result = await withPromptEnvironment( + { + input: createScriptedInput(['B', Key.enter]), + output, + error: output, + interactive: true + }, + () => + datatable({ + message: 'Pick project', + rows: [ + { Name: 'Alpha', value: 'alpha' }, + { Name: 'Beta', value: 'beta' } + ] + }) + ); + + expect(result).toBe('beta'); + expect(output.text()).toContain('Pick project B'); + }); + + it('falls back to row indexes when rows have no explicit value', async () => { + const output = createMemoryOutput(); + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.enter]), + output, + error: output, + interactive: true + }, + () => + datatable({ + message: 'Pick row', + headers: ['Name'], + rows: [['First'], ['Second']] + }) + ); + + expect(result).toBe(1); + }); + + it('works from form builders', async () => { + const output = createMemoryOutput(); + const responses = await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.enter]), + output, + error: output, + interactive: true + }, + () => + form() + .datatable({ + message: 'Pick project', + rows: [ + { Name: 'Alpha', value: 'alpha' }, + { Name: 'Beta', value: 'beta' } + ] + }, 'project') + .submit() + ); + + expect(responses.project).toBe('beta'); + }); +}); From 086ebd7855c50b2d83569f3bc2ad8ef6c18a95df Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 14:05:55 +0800 Subject: [PATCH 013/675] Render search query and selected summaries --- packages/tui/src/prompts/search.ts | 50 ++++++++++++++++++++++++++---- packages/tui/tests/search.test.ts | 19 ++++++++++++ 2 files changed, 63 insertions(+), 6 deletions(-) diff --git a/packages/tui/src/prompts/search.ts b/packages/tui/src/prompts/search.ts index 8dbb086c..c40008aa 100644 --- a/packages/tui/src/prompts/search.ts +++ b/packages/tui/src/prompts/search.ts @@ -11,6 +11,25 @@ const resolveSearchChoices = async (source: SearchPromptOptions['options'] return normalizeSearchChoices(options); }; +const searchMessage = (message: string, query: string): string => { + return query.length > 0 ? `${message} ${query}` : message; +}; + +const renderSearchChoices = ( + message: string, + query: string, + choices: Array>, + highlighted: number | null, + marked: Set = new Set(), + selectedLabels: string[] = [] +): void => { + renderInteractiveChoices(searchMessage(message, query), choices, highlighted ?? 0, marked); + + if (selectedLabels.length > 0) { + promptEnvironment().output.write(`Selected: ${selectedLabels.join(', ')}\n`); + } +}; + const readSearchChoice = async (options: SearchPromptOptions, attempt = 0): Promise => { const environment = promptEnvironment(); @@ -26,7 +45,7 @@ const readSearchChoice = async (options: SearchPromptOptions, attempt = 0) let choices = await resolveSearchChoices(options.options, state.value); let highlighted: number | null = null; - environment.output.write(`${options.message}\n`); + renderSearchChoices(options.message, state.value, choices, highlighted); while (true) { const key = await environment.input.readKey(); @@ -38,24 +57,26 @@ const readSearchChoice = async (options: SearchPromptOptions, attempt = 0) if (key === Key.down || key === Key.downArrow || key === Key.ctrlN || key === Key.tab) { choices = await resolveSearchChoices(options.options, state.value); highlighted = choices.length === 0 ? null : ((highlighted ?? (attempt > 0 ? 0 : -1)) + 1) % choices.length; - renderInteractiveChoices(options.message, choices, highlighted ?? 0); + renderSearchChoices(options.message, state.value, choices, highlighted); continue; } if (key === Key.up || key === Key.upArrow || key === Key.ctrlP || key === Key.shiftTab) { choices = await resolveSearchChoices(options.options, state.value); highlighted = choices.length === 0 ? null : ((highlighted ?? choices.length) - 1 + choices.length) % choices.length; - renderInteractiveChoices(options.message, choices, highlighted ?? 0); + renderSearchChoices(options.message, state.value, choices, highlighted); continue; } if (oneOf([Key.home, Key.ctrlA], key) && highlighted !== null) { highlighted = 0; + renderSearchChoices(options.message, state.value, choices, highlighted); continue; } if (oneOf([Key.end, Key.ctrlE], key) && highlighted !== null) { highlighted = Math.max(0, choices.length - 1); + renderSearchChoices(options.message, state.value, choices, highlighted); continue; } @@ -66,6 +87,7 @@ const readSearchChoice = async (options: SearchPromptOptions, attempt = 0) choices = await resolveSearchChoices(options.options, state.value); highlighted = choices.length > 0 ? 0 : null; + renderSearchChoices(options.message, state.value, choices, highlighted); continue; } @@ -80,6 +102,7 @@ const readSearchChoice = async (options: SearchPromptOptions, attempt = 0) state = { cursor: next.cursor, value: next.value }; highlighted = null; choices = await resolveSearchChoices(options.options, state.value); + renderSearchChoices(options.message, state.value, choices, highlighted); } }; @@ -123,7 +146,13 @@ const readMultiSearchChoices = async (options: MultiSearchPromptOptions): let highlighted: number | null = null; const selected = new Map(); - environment.output.write(`${options.message}\n`); + const render = (): void => { + const marked = new Set(choices.flatMap((choice, index) => selected.has(choice.value) ? [index] : [])); + + renderSearchChoices(options.message, state.value, choices, highlighted, marked, [...selected.values()]); + }; + + render(); while (true) { const key = await environment.input.readKey(); @@ -135,14 +164,14 @@ const readMultiSearchChoices = async (options: MultiSearchPromptOptions): if (key === Key.down || key === Key.downArrow || key === Key.ctrlN || key === Key.tab) { choices = await resolveSearchChoices(options.options, state.value); highlighted = choices.length === 0 ? null : ((highlighted ?? -1) + 1) % choices.length; - renderInteractiveChoices(options.message, choices, highlighted ?? 0); + render(); continue; } if (key === Key.up || key === Key.upArrow || key === Key.ctrlP || key === Key.shiftTab) { choices = await resolveSearchChoices(options.options, state.value); highlighted = choices.length === 0 ? null : ((highlighted ?? choices.length) - 1 + choices.length) % choices.length; - renderInteractiveChoices(options.message, choices, highlighted ?? 0); + render(); continue; } @@ -157,13 +186,22 @@ const readMultiSearchChoices = async (options: MultiSearchPromptOptions): } } + render(); continue; } const next = applyTypedKey(state, key); + + if (next.cancelled) { + environment.error.write('Cancelled.\n'); + + return options.default ?? []; + } + state = { cursor: next.cursor, value: next.value }; choices = await resolveSearchChoices(options.options, state.value); highlighted = null; + render(); } }; diff --git a/packages/tui/tests/search.test.ts b/packages/tui/tests/search.test.ts index fa7fc95a..d870bcb3 100644 --- a/packages/tui/tests/search.test.ts +++ b/packages/tui/tests/search.test.ts @@ -25,6 +25,7 @@ describe('search prompt', () => { ); expect(result).toBe('blue'); + expect(output.text()).toContain('Favorite color? ue'); }); it('validates selected values and allows another selection', async () => { @@ -47,6 +48,22 @@ describe('search prompt', () => { expect(result).toBe('green'); expect(output.text()).toContain('Please choose green.'); }); + + it('renders the typed query while navigating results', async () => { + const output = createMemoryOutput(); + await withPromptEnvironment( + { + input: createScriptedInput(['r', Key.down, Key.enter]), + output, + error: output, + interactive: true + }, + () => search({ message: 'Favorite color?', options: colors }) + ); + + expect(output.text()).toContain('Favorite color? r'); + expect(output.text()).toContain('›'); + }); }); describe('multisearch prompt', () => { @@ -71,5 +88,7 @@ describe('multisearch prompt', () => { ); expect(result).toEqual(['violet', 'green']); + expect(output.text()).toContain('Selected: Violet'); + expect(output.text()).toContain('Selected: Violet, Green'); }); }); From 35af958e6f568c65586548712b1589b3214774a7 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 14:07:28 +0800 Subject: [PATCH 014/675] Render suggest prompt choices --- packages/tui/src/prompts/suggest.ts | 19 ++++++++++- packages/tui/tests/suggest.test.ts | 49 ++++++++++++++++++++++++----- 2 files changed, 60 insertions(+), 8 deletions(-) diff --git a/packages/tui/src/prompts/suggest.ts b/packages/tui/src/prompts/suggest.ts index 7b4ddfea..2e78c4fc 100644 --- a/packages/tui/src/prompts/suggest.ts +++ b/packages/tui/src/prompts/suggest.ts @@ -2,6 +2,7 @@ import { promptEnvironment } from '#tui/environment'; import { Key, oneOf } from '#tui/key'; import { ask, promptUntilValid } from '#tui/prompt'; import { applyTypedKey } from '#tui/typed-value'; +import { renderInteractiveChoices } from '#tui/concerns/choices'; import type { MaybePromise, TextPromptOptions } from '#tui/types'; export type SuggestOptions = TextPromptOptions & { @@ -38,6 +39,14 @@ const resolveSuggestions = async (source: SuggestOptions['options'], query: stri return options.filter((option: string) => option.toLowerCase().startsWith(query.toLowerCase())); }; +const renderSuggestions = (message: string, value: string, matches: string[], highlighted: number | null): void => { + renderInteractiveChoices( + value.length > 0 ? `${message} ${value}` : message, + matches.map((match) => ({ label: match, value: match })), + highlighted ?? 0 + ); +}; + const readSuggestionValue = async (options: SuggestOptions): Promise => { const environment = promptEnvironment(); @@ -52,7 +61,7 @@ const readSuggestionValue = async (options: SuggestOptions): Promise => let highlighted: number | null = null; let matches: string[] = await resolveSuggestions(options.options, state.value); - environment.output.write(`${options.message}\n`); + renderSuggestions(options.message, state.value, matches, highlighted); while (true) { const key = await environment.input.readKey(); @@ -69,28 +78,35 @@ const readSuggestionValue = async (options: SuggestOptions): Promise => state = { cursor: match.length, value: match }; } + matches = await resolveSuggestions(options.options, state.value); + highlighted = null; + renderSuggestions(options.message, state.value, matches, highlighted); continue; } if (key === Key.down || key === Key.downArrow || key === Key.ctrlN || key === Key.shiftTab) { matches = await resolveSuggestions(options.options, state.value); highlighted = matches.length === 0 ? null : ((highlighted ?? -1) + 1) % matches.length; + renderSuggestions(options.message, state.value, matches, highlighted); continue; } if (key === Key.up || key === Key.upArrow || key === Key.ctrlP) { matches = await resolveSuggestions(options.options, state.value); highlighted = matches.length === 0 ? null : ((highlighted ?? matches.length) - 1 + matches.length) % matches.length; + renderSuggestions(options.message, state.value, matches, highlighted); continue; } if (oneOf([Key.home, Key.ctrlA], key) && highlighted !== null) { highlighted = 0; + renderSuggestions(options.message, state.value, matches, highlighted); continue; } if (oneOf([Key.end, Key.ctrlE], key) && highlighted !== null) { highlighted = Math.max(0, matches.length - 1); + renderSuggestions(options.message, state.value, matches, highlighted); continue; } @@ -117,6 +133,7 @@ const readSuggestionValue = async (options: SuggestOptions): Promise => state = { cursor: next.cursor, value: next.value }; highlighted = null; matches = await resolveSuggestions(options.options, state.value); + renderSuggestions(options.message, state.value, matches, highlighted); } }; diff --git a/packages/tui/tests/suggest.test.ts b/packages/tui/tests/suggest.test.ts index d2d1b62e..7426cb58 100644 --- a/packages/tui/tests/suggest.test.ts +++ b/packages/tui/tests/suggest.test.ts @@ -1,10 +1,29 @@ import { describe, expect, it } from 'vitest'; import { createMemoryOutput, createScriptedInput, Key, suggest, withPromptEnvironment } from '../src/index'; -const runSuggest = async (keys: string[], options: string[] | ((query: string) => string[])): Promise => { +const runSuggest = async (keys: string[], options: string[] | ((query: string) => string[])): Promise<{ output: string; result: string }> => { const output = createMemoryOutput(); + const result = await withPromptEnvironment( + { + input: createScriptedInput(keys), + output, + error: output, + interactive: true + }, + () => suggest('Favorite color?', options) + ); - return withPromptEnvironment( + return { output: output.text(), result }; +}; + +const expectSuggestion = async (keys: string[], options: string[] | ((query: string) => string[]), result: string): Promise => { + await expect(runSuggest(keys, options)).resolves.toMatchObject({ result }); +}; + +const outputFor = async (keys: string[], options: string[] | ((query: string) => string[])): Promise => { + const output = createMemoryOutput(); + + await withPromptEnvironment( { input: createScriptedInput(keys), output, @@ -13,23 +32,39 @@ const runSuggest = async (keys: string[], options: string[] | ((query: string) = }, () => suggest('Favorite color?', options) ); + + return output.text(); }; describe('suggest prompt', () => { it('accepts arbitrary typed input', async () => { - await expect(runSuggest(['B', 'l', 'a', 'c', 'k', Key.enter], ['Red', 'Green', 'Blue'])).resolves.toBe('Black'); + await expectSuggestion(['B', 'l', 'a', 'c', 'k', Key.enter], ['Red', 'Green', 'Blue'], 'Black'); }); it('completes input using tab', async () => { - await expect(runSuggest(['b', Key.tab, Key.enter], ['Red', 'Green', 'Blue'])).resolves.toBe('Blue'); + await expectSuggestion(['b', Key.tab, Key.enter], ['Red', 'Green', 'Blue'], 'Blue'); }); it('navigates matches using arrow and emacs keys', async () => { - await expect(runSuggest(['b', Key.down, Key.down, Key.down, Key.up, Key.enter], ['Red', 'Blue', 'Black', 'Blurple'])).resolves.toBe('Black'); - await expect(runSuggest(['b', Key.ctrlN, Key.ctrlN, Key.ctrlN, Key.ctrlP, Key.enter], ['Red', 'Blue', 'Black', 'Blurple'])).resolves.toBe('Black'); + await expectSuggestion(['b', Key.down, Key.down, Key.down, Key.up, Key.enter], ['Red', 'Blue', 'Black', 'Blurple'], 'Black'); + await expectSuggestion(['b', Key.ctrlN, Key.ctrlN, Key.ctrlN, Key.ctrlP, Key.enter], ['Red', 'Blue', 'Black', 'Blurple'], 'Black'); }); it('supports callback options', async () => { - await expect(runSuggest(['e', 'e', Key.down, Key.enter], (value) => ['Red', 'Green', 'Blue'].filter((option) => option.toLowerCase().includes(value.toLowerCase())))).resolves.toBe('Green'); + await expectSuggestion(['e', 'e', Key.down, Key.enter], (value) => ['Red', 'Green', 'Blue'].filter((option) => option.toLowerCase().includes(value.toLowerCase())), 'Green'); + }); + + it('renders typed queries and suggestion matches', async () => { + const output = await outputFor(['b', Key.down, Key.enter], ['Red', 'Green', 'Blue']); + + expect(output).toContain('Favorite color? b'); + expect(output).toContain('›'); + expect(output).toContain('Blue'); + }); + + it('renders completed values after tab completion', async () => { + const output = await outputFor(['b', Key.tab, Key.enter], ['Red', 'Green', 'Blue']); + + expect(output).toContain('Favorite color? Blue'); }); }); From 84381a48f79e92f4ef15c786cdc50561f081b2db Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 14:09:58 +0800 Subject: [PATCH 015/675] Expand form builder prompt helpers --- packages/tui/src/form/builder.ts | 44 ++++++++++++++++++++-- packages/tui/tests/form.test.ts | 63 ++++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 3 deletions(-) diff --git a/packages/tui/src/form/builder.ts b/packages/tui/src/form/builder.ts index 5094d9e7..3a223b1b 100644 --- a/packages/tui/src/form/builder.ts +++ b/packages/tui/src/form/builder.ts @@ -1,9 +1,11 @@ import { alert, datatable, error, info, intro, note, outro, table, warning } from '#tui/output'; -import { confirm, multiselect, select } from '#tui/prompts/choices'; +import { autocomplete, confirm, multiselect, multisearch, pause, search, select, suggest } from '#tui/prompts/choices'; import { number, password, text, textarea } from '#tui/prompts/basic'; -import { progress, spin } from '#tui/status'; +import { progress, spin, stream, task } from '#tui/status'; import type { FormResponses, FormStep } from '#tui/form/types'; -import type { ChoiceInput, DataTablePromptOptions, MaybePromise, TableOptions, TextPromptOptions } from '#tui/types'; +import type { ChoiceInput, DataTablePromptOptions, MaybePromise, MultiSearchPromptOptions, SearchPromptOptions, TableOptions, TextPromptOptions } from '#tui/types'; +import type { SuggestOptions } from '#tui/prompts/choices'; +import type { Logger } from '#tui/status'; export class FormBuilder { readonly #steps: FormStep[] = []; @@ -80,10 +82,46 @@ export class FormBuilder { return this.add((_, previous) => multiselect({ message: label, options, default: Array.isArray(previous) ? previous as T[] : defaultValue, scroll, required, validate, hint }), name); } + suggest(label: string, options: SuggestOptions['options'], defaultValue = '', scroll = 5, required: boolean | string = false, validate: TextPromptOptions['validate'] = undefined, hint = '', name?: string, transform?: TextPromptOptions['transform']): this { + return this.add((_, previous) => suggest({ message: label, label, options, default: previous === undefined || previous === null ? defaultValue : String(previous), scroll, required, validate, hint, transform }), name); + } + + autocomplete(label: string, options: SuggestOptions['options'], defaultValue = '', required: boolean | string = false, validate: TextPromptOptions['validate'] = undefined, hint = '', name?: string, transform?: TextPromptOptions['transform']): this { + return this.add((_, previous) => autocomplete({ message: label, label, options, default: previous === undefined || previous === null ? defaultValue : String(previous), required, validate, hint, transform }), name); + } + + search(options: SearchPromptOptions, name?: string): this { + return this.add(() => search(options), name); + } + + multisearch(options: MultiSearchPromptOptions, name?: string): this { + return this.add(() => multisearch(options), name); + } + spin(callback: () => MaybePromise, message = '', name?: string): this { return this.add(() => spin(callback, { message }), name, true); } + task(label: string, callback: (logger: Logger) => MaybePromise, limit = 10, keepSummary = false, subLabel = '', name?: string): this { + return this.add(() => task(label, callback, limit, keepSummary, subLabel), name); + } + + pause(message = 'Press enter to continue', name?: string): this { + return this.add(async () => { + await pause(message); + + return null; + }, name, true); + } + + stream(source: AsyncIterable | Iterable, name?: string): this { + return this.add(async () => { + await stream(source); + + return null; + }, name, true); + } + note(message: string, type: string | null = null, name?: string): this { return this.add(() => { note(message, type); diff --git a/packages/tui/tests/form.test.ts b/packages/tui/tests/form.test.ts index 2229b393..428aa78b 100644 --- a/packages/tui/tests/form.test.ts +++ b/packages/tui/tests/form.test.ts @@ -62,4 +62,67 @@ describe('form builder', () => { expect(responses.include).toBe(false); expect(responses.details).toBeNull(); }); + + it('runs suggest, search, and multisearch form steps', async () => { + const output = createMemoryOutput(); + const responses = await withPromptEnvironment( + { + input: createScriptedInput(['b', Key.tab, Key.enter, 'g', Key.down, Key.enter, 'r', Key.down, Key.space, Key.enter]), + output, + error: output, + interactive: true + }, + () => + form() + .suggest('Suggested color', ['Red', 'Green', 'Blue'], '', 5, false, undefined, '', 'suggested') + .search({ + message: 'Searched color', + options: (value) => { + const options = { green: 'Green', blue: 'Blue', red: 'Red' }; + + return Object.fromEntries(Object.entries(options).filter(([, label]) => label.toLowerCase().includes(value.toLowerCase()))); + } + }, 'searched') + .multisearch({ + message: 'Many colors', + options: (value) => { + const options = { red: 'Red', green: 'Green', blue: 'Blue' }; + + return Object.fromEntries(Object.entries(options).filter(([, label]) => label.toLowerCase().includes(value.toLowerCase()))); + } + }, 'many') + .submit() + ); + + expect(responses.suggested).toBe('Blue'); + expect(responses.searched).toBe('green'); + expect(responses.many).toEqual(['red']); + }); + + it('runs task, pause, and stream form steps', async () => { + const output = createMemoryOutput(); + const responses = await withPromptEnvironment( + { + input: createScriptedInput([Key.enter]), + output, + error: output, + interactive: true + }, + () => + form() + .task('Build', (logger) => { + logger.log('done'); + + return 1; + }, 10, false, '', 'task') + .pause('Continue') + .stream(['line one\n'], 'streamed') + .submit() + ); + + expect(responses.task).toBe(1); + expect(responses.streamed).toBeNull(); + expect(output.text()).toContain('Build'); + expect(output.text()).toContain('line one'); + }); }); From 6fb266dffe079d440f96a7b50b5335454de1721f Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 14:11:19 +0800 Subject: [PATCH 016/675] Add textarea submit key handling --- packages/tui/src/typed-value.ts | 4 ++++ packages/tui/tests/textarea.test.ts | 20 ++++++++++++++++++++ packages/tui/tests/typed-value.test.ts | 20 ++++++++++++++++++++ 3 files changed, 44 insertions(+) create mode 100644 packages/tui/tests/textarea.test.ts diff --git a/packages/tui/src/typed-value.ts b/packages/tui/src/typed-value.ts index e49948f3..5ab1b718 100644 --- a/packages/tui/src/typed-value.ts +++ b/packages/tui/src/typed-value.ts @@ -28,6 +28,10 @@ export const applyTypedKey = (state: TypedValueState, key: string, allowNewLine return { cursor, value: fromCharacters(value), submitted: false, cancelled: true }; } + if (key === Key.ctrlD && allowNewLine) { + return { cursor, value: fromCharacters(value), submitted: true, cancelled: false }; + } + if (key === Key.enter) { if (!allowNewLine) { return { cursor, value: fromCharacters(value), submitted: true, cancelled: false }; diff --git a/packages/tui/tests/textarea.test.ts b/packages/tui/tests/textarea.test.ts new file mode 100644 index 00000000..c0613bd4 --- /dev/null +++ b/packages/tui/tests/textarea.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; +import { createMemoryOutput, createScriptedInput, Key, textarea, withPromptEnvironment } from '../src/index'; + +describe('textarea prompt', () => { + it('accepts multiline input and submits with ctrl-d', async () => { + const output = createMemoryOutput(); + const result = await withPromptEnvironment( + { + input: createScriptedInput(['A', Key.enter, 'B', Key.ctrlD]), + output, + error: output, + interactive: true + }, + () => textarea('Description') + ); + + expect(result).toBe('A\nB'); + expect(output.text()).toContain('Description'); + }); +}); diff --git a/packages/tui/tests/typed-value.test.ts b/packages/tui/tests/typed-value.test.ts index 4ea09e57..0c98498f 100644 --- a/packages/tui/tests/typed-value.test.ts +++ b/packages/tui/tests/typed-value.test.ts @@ -12,6 +12,19 @@ const apply = (keys: string[]): string => { return state.value; }; +const applyMultiline = (keys: string[]): { submitted: boolean; value: string } => { + let state = { cursor: 0, value: '' }; + let submitted = false; + + for (const key of keys) { + const next = applyTypedKey(state, key, true); + state = { cursor: next.cursor, value: next.value }; + submitted = next.submitted; + } + + return { submitted, value: state.value }; +}; + describe('typed value editing', () => { it('inserts buffered characters', () => { expect(apply(['Je', 'ss'])).toBe('Jess'); @@ -29,4 +42,11 @@ describe('typed value editing', () => { it('moves to the start and end of a line', () => { expect(apply(['A', 'r', Key.home[0], 'J', Key.end[0], 'c', 'h', 'e', 'r'])).toBe('JArcher'); }); + + it('submits multiline input with ctrl-d', () => { + expect(applyMultiline(['A', Key.enter, 'B', Key.ctrlD])).toEqual({ + submitted: true, + value: 'A\nB' + }); + }); }); From 4575cf55f53c1387b22e6ad63a4c3046e83a20b3 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 14:12:22 +0800 Subject: [PATCH 017/675] Validate full numeric prompt input --- packages/tui/src/prompts/number.ts | 10 +++++++-- packages/tui/tests/number.test.ts | 36 ++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) create mode 100644 packages/tui/tests/number.test.ts diff --git a/packages/tui/src/prompts/number.ts b/packages/tui/src/prompts/number.ts index afe3fc58..e508550d 100644 --- a/packages/tui/src/prompts/number.ts +++ b/packages/tui/src/prompts/number.ts @@ -2,6 +2,9 @@ import { promptUntilValid, PromptValidationError } from '#tui/prompt'; import { readTypedValue } from '#tui/typed-value'; import type { NumberPromptOptions } from '#tui/types'; +const integerPattern = /^[+-]?\d+$/u; +const numberPattern = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?$/iu; + export function number(options: NumberPromptOptions): Promise; export function number( label: string, @@ -39,12 +42,15 @@ export async function number( return options.default; } - const parsed = options.integer ? Number.parseInt(answer, 10) : Number.parseFloat(answer); + const normalized = answer.trim(); + const valid = options.integer ? integerPattern.test(normalized) : numberPattern.test(normalized); - if (Number.isNaN(parsed)) { + if (!valid) { throw new PromptValidationError('Please enter a valid number.'); } + const parsed = options.integer ? Number.parseInt(normalized, 10) : Number(normalized); + if (options.min !== undefined && parsed < options.min) { throw new PromptValidationError(`Please enter a value greater than or equal to ${options.min}.`); } diff --git a/packages/tui/tests/number.test.ts b/packages/tui/tests/number.test.ts new file mode 100644 index 00000000..c23d1170 --- /dev/null +++ b/packages/tui/tests/number.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { createMemoryOutput, createScriptedInput, Key, number, withPromptEnvironment } from '../src/index'; + +describe('number prompt', () => { + it('rejects partial decimal input and accepts a retry', async () => { + const output = createMemoryOutput(); + const result = await withPromptEnvironment( + { + input: createScriptedInput(['1', '2', 'a', Key.enter, '4', '2', Key.enter]), + output, + error: output, + interactive: true + }, + () => number({ message: 'Amount' }) + ); + + expect(result).toBe(42); + expect(output.text()).toContain('Please enter a valid number.'); + }); + + it('rejects decimal values for integer prompts', async () => { + const output = createMemoryOutput(); + const result = await withPromptEnvironment( + { + input: createScriptedInput(['1', '.', '5', Key.enter, '2', Key.enter]), + output, + error: output, + interactive: true + }, + () => number({ message: 'Count', integer: true }) + ); + + expect(result).toBe(2); + expect(output.text()).toContain('Please enter a valid number.'); + }); +}); From 2dc99318e71983b46e8c7911a6d59c5496ecbdb4 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 14:15:06 +0800 Subject: [PATCH 018/675] Respect prompt scroll windows --- packages/tui/src/concerns/choices.ts | 17 +++++++++++++++-- packages/tui/src/prompts/search.ts | 21 +++++++++++---------- packages/tui/src/prompts/select.ts | 24 ++++++++++++------------ packages/tui/src/prompts/suggest.ts | 20 +++++++++++--------- packages/tui/tests/choices.test.ts | 20 ++++++++++++++++++++ packages/tui/tests/search.test.ts | 25 +++++++++++++++++++++++++ 6 files changed, 94 insertions(+), 33 deletions(-) diff --git a/packages/tui/src/concerns/choices.ts b/packages/tui/src/concerns/choices.ts index 26b5ac87..e0e36285 100644 --- a/packages/tui/src/concerns/choices.ts +++ b/packages/tui/src/concerns/choices.ts @@ -59,12 +59,25 @@ export const nextEnabledIndex = (choices: Array>, current: number, return current; }; -export const renderInteractiveChoices = (message: string, choices: Array>, selected: number, marked: Set = new Set()): void => { +const choiceWindow = (total: number, selected: number, scroll?: number): { end: number; start: number } => { + if (scroll === undefined || scroll <= 0 || scroll >= total) { + return { end: total, start: 0 }; + } + + const before = Math.floor((scroll - 1) / 2); + const start = Math.max(0, Math.min(selected - before, total - scroll)); + + return { end: start + scroll, start }; +}; + +export const renderInteractiveChoices = (message: string, choices: Array>, selected: number, marked: Set = new Set(), scroll?: number): void => { const environment = promptEnvironment(); + const window = choiceWindow(choices.length, selected, scroll); environment.output.write(`${message}\n`); - for (const [index, choice] of choices.entries()) { + for (const [offset, choice] of choices.slice(window.start, window.end).entries()) { + const index = window.start + offset; const pointer = index === selected ? '›' : ' '; const checked = marked.size > 0 ? (marked.has(index) ? '[x]' : '[ ]') : ' '; const disabled = choice.disabled ? ` (${typeof choice.disabled === 'string' ? choice.disabled : 'disabled'})` : ''; diff --git a/packages/tui/src/prompts/search.ts b/packages/tui/src/prompts/search.ts index c40008aa..9612259d 100644 --- a/packages/tui/src/prompts/search.ts +++ b/packages/tui/src/prompts/search.ts @@ -21,9 +21,10 @@ const renderSearchChoices = ( choices: Array>, highlighted: number | null, marked: Set = new Set(), - selectedLabels: string[] = [] + selectedLabels: string[] = [], + scroll?: number ): void => { - renderInteractiveChoices(searchMessage(message, query), choices, highlighted ?? 0, marked); + renderInteractiveChoices(searchMessage(message, query), choices, highlighted ?? 0, marked, scroll); if (selectedLabels.length > 0) { promptEnvironment().output.write(`Selected: ${selectedLabels.join(', ')}\n`); @@ -45,7 +46,7 @@ const readSearchChoice = async (options: SearchPromptOptions, attempt = 0) let choices = await resolveSearchChoices(options.options, state.value); let highlighted: number | null = null; - renderSearchChoices(options.message, state.value, choices, highlighted); + renderSearchChoices(options.message, state.value, choices, highlighted, new Set(), [], options.scroll); while (true) { const key = await environment.input.readKey(); @@ -57,26 +58,26 @@ const readSearchChoice = async (options: SearchPromptOptions, attempt = 0) if (key === Key.down || key === Key.downArrow || key === Key.ctrlN || key === Key.tab) { choices = await resolveSearchChoices(options.options, state.value); highlighted = choices.length === 0 ? null : ((highlighted ?? (attempt > 0 ? 0 : -1)) + 1) % choices.length; - renderSearchChoices(options.message, state.value, choices, highlighted); + renderSearchChoices(options.message, state.value, choices, highlighted, new Set(), [], options.scroll); continue; } if (key === Key.up || key === Key.upArrow || key === Key.ctrlP || key === Key.shiftTab) { choices = await resolveSearchChoices(options.options, state.value); highlighted = choices.length === 0 ? null : ((highlighted ?? choices.length) - 1 + choices.length) % choices.length; - renderSearchChoices(options.message, state.value, choices, highlighted); + renderSearchChoices(options.message, state.value, choices, highlighted, new Set(), [], options.scroll); continue; } if (oneOf([Key.home, Key.ctrlA], key) && highlighted !== null) { highlighted = 0; - renderSearchChoices(options.message, state.value, choices, highlighted); + renderSearchChoices(options.message, state.value, choices, highlighted, new Set(), [], options.scroll); continue; } if (oneOf([Key.end, Key.ctrlE], key) && highlighted !== null) { highlighted = Math.max(0, choices.length - 1); - renderSearchChoices(options.message, state.value, choices, highlighted); + renderSearchChoices(options.message, state.value, choices, highlighted, new Set(), [], options.scroll); continue; } @@ -87,7 +88,7 @@ const readSearchChoice = async (options: SearchPromptOptions, attempt = 0) choices = await resolveSearchChoices(options.options, state.value); highlighted = choices.length > 0 ? 0 : null; - renderSearchChoices(options.message, state.value, choices, highlighted); + renderSearchChoices(options.message, state.value, choices, highlighted, new Set(), [], options.scroll); continue; } @@ -102,7 +103,7 @@ const readSearchChoice = async (options: SearchPromptOptions, attempt = 0) state = { cursor: next.cursor, value: next.value }; highlighted = null; choices = await resolveSearchChoices(options.options, state.value); - renderSearchChoices(options.message, state.value, choices, highlighted); + renderSearchChoices(options.message, state.value, choices, highlighted, new Set(), [], options.scroll); } }; @@ -149,7 +150,7 @@ const readMultiSearchChoices = async (options: MultiSearchPromptOptions): const render = (): void => { const marked = new Set(choices.flatMap((choice, index) => selected.has(choice.value) ? [index] : [])); - renderSearchChoices(options.message, state.value, choices, highlighted, marked, [...selected.values()]); + renderSearchChoices(options.message, state.value, choices, highlighted, marked, [...selected.values()], options.scroll); }; render(); diff --git a/packages/tui/src/prompts/select.ts b/packages/tui/src/prompts/select.ts index 7d21ffae..980570c0 100644 --- a/packages/tui/src/prompts/select.ts +++ b/packages/tui/src/prompts/select.ts @@ -7,7 +7,7 @@ import type { Choice, ConfirmPromptOptions, MultiSelectPromptOptions, SelectProm const parseChoiceIndex = (key: string): number => (/^\d+$/u.test(key) ? Number.parseInt(key, 10) : Number.NaN); -const readSelectedChoice = async (message: string, choices: Array>, hint?: string): Promise => { +const readSelectedChoice = async (message: string, choices: Array>, hint?: string, scroll?: number): Promise => { const environment = promptEnvironment(); if (!environment.input.readKey) { @@ -23,7 +23,7 @@ const readSelectedChoice = async (message: string, choices: Array>, } let selected = firstEnabledIndex(choices); - renderInteractiveChoices(message, choices, selected); + renderInteractiveChoices(message, choices, selected, new Set(), scroll); while (true) { const key = await environment.input.readKey(); @@ -40,13 +40,13 @@ const readSelectedChoice = async (message: string, choices: Array>, if (key === Key.down || key === Key.downArrow || key === Key.ctrlN) { selected = nextEnabledIndex(choices, selected, 1); - renderInteractiveChoices(message, choices, selected); + renderInteractiveChoices(message, choices, selected, new Set(), scroll); continue; } if (key === Key.up || key === Key.upArrow || key === Key.ctrlP) { selected = nextEnabledIndex(choices, selected, -1); - renderInteractiveChoices(message, choices, selected); + renderInteractiveChoices(message, choices, selected, new Set(), scroll); continue; } @@ -75,7 +75,7 @@ const choicesFromCommaSeparated = (choices: Array>, answer: string) return selected.map((choice) => choice.value); }; -const readMultipleChoices = async (message: string, choices: Array>, defaults: T[] = [], hint?: string): Promise => { +const readMultipleChoices = async (message: string, choices: Array>, defaults: T[] = [], hint?: string, scroll?: number): Promise => { const environment = promptEnvironment(); const selectedValues = new Set(defaults); @@ -88,7 +88,7 @@ const readMultipleChoices = async (message: string, choices: Array> let selected = firstEnabledIndex(choices); const marked = new Set(choices.flatMap((choice, index) => selectedValues.has(choice.value) ? [index] : [])); - renderInteractiveChoices(message, choices, selected, marked); + renderInteractiveChoices(message, choices, selected, marked, scroll); while (true) { const key = await environment.input.readKey(); @@ -112,19 +112,19 @@ const readMultipleChoices = async (message: string, choices: Array> marked.add(index); } - renderInteractiveChoices(message, choices, selected, marked); + renderInteractiveChoices(message, choices, selected, marked, scroll); continue; } if (key === Key.down || key === Key.downArrow || key === Key.ctrlN) { selected = nextEnabledIndex(choices, selected, 1); - renderInteractiveChoices(message, choices, selected, marked); + renderInteractiveChoices(message, choices, selected, marked, scroll); continue; } if (key === Key.up || key === Key.upArrow || key === Key.ctrlP) { selected = nextEnabledIndex(choices, selected, -1); - renderInteractiveChoices(message, choices, selected, marked); + renderInteractiveChoices(message, choices, selected, marked, scroll); continue; } @@ -135,7 +135,7 @@ const readMultipleChoices = async (message: string, choices: Array> marked.add(selected); } - renderInteractiveChoices(message, choices, selected, marked); + renderInteractiveChoices(message, choices, selected, marked, scroll); continue; } @@ -184,7 +184,7 @@ export const select = async (options: SelectPromptOptions): Promise => const choices = normalizeChoices(options.options); return promptUntilValid(options, async () => { - return readSelectedChoice(options.message, choices, options.hint).catch((error: unknown) => { + return readSelectedChoice(options.message, choices, options.hint, options.scroll).catch((error: unknown) => { if (options.default !== undefined && error instanceof PromptValidationError) { return options.default; } @@ -197,5 +197,5 @@ export const select = async (options: SelectPromptOptions): Promise => export const multiselect = async (options: MultiSelectPromptOptions): Promise => { const choices = normalizeChoices(options.options); - return promptUntilValid(options, async () => readMultipleChoices(options.message, choices, options.default, options.hint)); + return promptUntilValid(options, async () => readMultipleChoices(options.message, choices, options.default, options.hint, options.scroll)); }; diff --git a/packages/tui/src/prompts/suggest.ts b/packages/tui/src/prompts/suggest.ts index 2e78c4fc..f6e957df 100644 --- a/packages/tui/src/prompts/suggest.ts +++ b/packages/tui/src/prompts/suggest.ts @@ -39,11 +39,13 @@ const resolveSuggestions = async (source: SuggestOptions['options'], query: stri return options.filter((option: string) => option.toLowerCase().startsWith(query.toLowerCase())); }; -const renderSuggestions = (message: string, value: string, matches: string[], highlighted: number | null): void => { +const renderSuggestions = (message: string, value: string, matches: string[], highlighted: number | null, scroll?: number): void => { renderInteractiveChoices( value.length > 0 ? `${message} ${value}` : message, matches.map((match) => ({ label: match, value: match })), - highlighted ?? 0 + highlighted ?? 0, + new Set(), + scroll ); }; @@ -61,7 +63,7 @@ const readSuggestionValue = async (options: SuggestOptions): Promise => let highlighted: number | null = null; let matches: string[] = await resolveSuggestions(options.options, state.value); - renderSuggestions(options.message, state.value, matches, highlighted); + renderSuggestions(options.message, state.value, matches, highlighted, options.scroll); while (true) { const key = await environment.input.readKey(); @@ -80,33 +82,33 @@ const readSuggestionValue = async (options: SuggestOptions): Promise => matches = await resolveSuggestions(options.options, state.value); highlighted = null; - renderSuggestions(options.message, state.value, matches, highlighted); + renderSuggestions(options.message, state.value, matches, highlighted, options.scroll); continue; } if (key === Key.down || key === Key.downArrow || key === Key.ctrlN || key === Key.shiftTab) { matches = await resolveSuggestions(options.options, state.value); highlighted = matches.length === 0 ? null : ((highlighted ?? -1) + 1) % matches.length; - renderSuggestions(options.message, state.value, matches, highlighted); + renderSuggestions(options.message, state.value, matches, highlighted, options.scroll); continue; } if (key === Key.up || key === Key.upArrow || key === Key.ctrlP) { matches = await resolveSuggestions(options.options, state.value); highlighted = matches.length === 0 ? null : ((highlighted ?? matches.length) - 1 + matches.length) % matches.length; - renderSuggestions(options.message, state.value, matches, highlighted); + renderSuggestions(options.message, state.value, matches, highlighted, options.scroll); continue; } if (oneOf([Key.home, Key.ctrlA], key) && highlighted !== null) { highlighted = 0; - renderSuggestions(options.message, state.value, matches, highlighted); + renderSuggestions(options.message, state.value, matches, highlighted, options.scroll); continue; } if (oneOf([Key.end, Key.ctrlE], key) && highlighted !== null) { highlighted = Math.max(0, matches.length - 1); - renderSuggestions(options.message, state.value, matches, highlighted); + renderSuggestions(options.message, state.value, matches, highlighted, options.scroll); continue; } @@ -133,7 +135,7 @@ const readSuggestionValue = async (options: SuggestOptions): Promise => state = { cursor: next.cursor, value: next.value }; highlighted = null; matches = await resolveSuggestions(options.options, state.value); - renderSuggestions(options.message, state.value, matches, highlighted); + renderSuggestions(options.message, state.value, matches, highlighted, options.scroll); } }; diff --git a/packages/tui/tests/choices.test.ts b/packages/tui/tests/choices.test.ts index f38809c3..fdb2f34a 100644 --- a/packages/tui/tests/choices.test.ts +++ b/packages/tui/tests/choices.test.ts @@ -18,6 +18,26 @@ describe('choice prompts', () => { expect(output.text()).toContain('Pick one'); }); + it('renders a scrolling select window around the highlighted choice', async () => { + const output = createMemoryOutput(); + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.down, Key.enter]), + output, + error: output, + interactive: true + }, + () => select({ message: 'Pick one', options: ['first', 'second', 'third', 'fourth'], scroll: 3 }) + ); + const latestFrame = output.text().split('Pick one\n').at(-1) ?? ''; + + expect(result).toBe('third'); + expect(latestFrame).not.toContain('first'); + expect(latestFrame).toContain('second'); + expect(latestFrame).toContain('third'); + expect(latestFrame).toContain('fourth'); + }); + it('toggles multiselect choices with the space bar', async () => { const output = createMemoryOutput(); const result = await withPromptEnvironment( diff --git a/packages/tui/tests/search.test.ts b/packages/tui/tests/search.test.ts index d870bcb3..2b630281 100644 --- a/packages/tui/tests/search.test.ts +++ b/packages/tui/tests/search.test.ts @@ -64,6 +64,31 @@ describe('search prompt', () => { expect(output.text()).toContain('Favorite color? r'); expect(output.text()).toContain('›'); }); + + it('respects scroll windows when rendering results', async () => { + const output = createMemoryOutput(); + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.down, Key.down, Key.enter]), + output, + error: output, + interactive: true + }, + () => + search({ + message: 'Pick number', + options: ['one', 'two', 'three', 'four'], + scroll: 3 + }) + ); + const latestFrame = output.text().split('Pick number\n').at(-1) ?? ''; + + expect(result).toBe('three'); + expect(latestFrame).not.toContain('one'); + expect(latestFrame).toContain('two'); + expect(latestFrame).toContain('three'); + expect(latestFrame).toContain('four'); + }); }); describe('multisearch prompt', () => { From 5162e973c6057ff6d355a2f6fa8900d6562ec029 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 14:17:05 +0800 Subject: [PATCH 019/675] Render multiselect selected summaries --- packages/tui/src/prompts/select.ts | 23 ++++++++++++++++++----- packages/tui/tests/choices.test.ts | 18 ++++++++++++++++++ 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/packages/tui/src/prompts/select.ts b/packages/tui/src/prompts/select.ts index 980570c0..217be121 100644 --- a/packages/tui/src/prompts/select.ts +++ b/packages/tui/src/prompts/select.ts @@ -75,6 +75,19 @@ const choicesFromCommaSeparated = (choices: Array>, answer: string) return selected.map((choice) => choice.value); }; +const renderMultipleChoices = (message: string, choices: Array>, selected: number, marked: Set, scroll?: number): void => { + renderInteractiveChoices(message, choices, selected, marked, scroll); + + const labels = [...marked] + .sort((left, right) => left - right) + .map((index) => choices[index]?.label) + .filter((label): label is string => label !== undefined); + + if (labels.length > 0) { + promptEnvironment().output.write(`Selected: ${labels.join(', ')}\n`); + } +}; + const readMultipleChoices = async (message: string, choices: Array>, defaults: T[] = [], hint?: string, scroll?: number): Promise => { const environment = promptEnvironment(); const selectedValues = new Set(defaults); @@ -88,7 +101,7 @@ const readMultipleChoices = async (message: string, choices: Array> let selected = firstEnabledIndex(choices); const marked = new Set(choices.flatMap((choice, index) => selectedValues.has(choice.value) ? [index] : [])); - renderInteractiveChoices(message, choices, selected, marked, scroll); + renderMultipleChoices(message, choices, selected, marked, scroll); while (true) { const key = await environment.input.readKey(); @@ -112,19 +125,19 @@ const readMultipleChoices = async (message: string, choices: Array> marked.add(index); } - renderInteractiveChoices(message, choices, selected, marked, scroll); + renderMultipleChoices(message, choices, selected, marked, scroll); continue; } if (key === Key.down || key === Key.downArrow || key === Key.ctrlN) { selected = nextEnabledIndex(choices, selected, 1); - renderInteractiveChoices(message, choices, selected, marked, scroll); + renderMultipleChoices(message, choices, selected, marked, scroll); continue; } if (key === Key.up || key === Key.upArrow || key === Key.ctrlP) { selected = nextEnabledIndex(choices, selected, -1); - renderInteractiveChoices(message, choices, selected, marked, scroll); + renderMultipleChoices(message, choices, selected, marked, scroll); continue; } @@ -135,7 +148,7 @@ const readMultipleChoices = async (message: string, choices: Array> marked.add(selected); } - renderInteractiveChoices(message, choices, selected, marked, scroll); + renderMultipleChoices(message, choices, selected, marked, scroll); continue; } diff --git a/packages/tui/tests/choices.test.ts b/packages/tui/tests/choices.test.ts index fdb2f34a..2997bdb2 100644 --- a/packages/tui/tests/choices.test.ts +++ b/packages/tui/tests/choices.test.ts @@ -51,5 +51,23 @@ describe('choice prompts', () => { ); expect(result).toEqual(['first', 'second']); + expect(output.text()).toContain('Selected: first'); + expect(output.text()).toContain('Selected: first, second'); + }); + + it('requires multiselect choices when configured', async () => { + const output = createMemoryOutput(); + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.enter, Key.space, Key.enter]), + output, + error: output, + interactive: true + }, + () => multiselect({ message: 'Pick many', options: ['first', 'second'], required: true }) + ); + + expect(result).toEqual(['first']); + expect(output.text()).toContain('A value is required.'); }); }); From bf3c6b499233ee5a28f84e2ac1e74bdd558d1f0a Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 14:18:59 +0800 Subject: [PATCH 020/675] Support choice prompt navigation keys --- packages/tui/src/prompts/select.ts | 85 ++++++++++++++++++++++++++++-- packages/tui/tests/choices.test.ts | 46 ++++++++++++++++ 2 files changed, 126 insertions(+), 5 deletions(-) diff --git a/packages/tui/src/prompts/select.ts b/packages/tui/src/prompts/select.ts index 217be121..98a976ea 100644 --- a/packages/tui/src/prompts/select.ts +++ b/packages/tui/src/prompts/select.ts @@ -1,5 +1,5 @@ import { promptEnvironment } from '#tui/environment'; -import { Key } from '#tui/key'; +import { Key, oneOf } from '#tui/key'; import { ask, promptUntilValid, PromptValidationError } from '#tui/prompt'; import { renderChoices } from '#tui/theme'; import { findChoice, firstEnabledIndex, nextEnabledIndex, normalizeChoices, renderInteractiveChoices } from '#tui/concerns/choices'; @@ -7,6 +7,30 @@ import type { Choice, ConfirmPromptOptions, MultiSelectPromptOptions, SelectProm const parseChoiceIndex = (key: string): number => (/^\d+$/u.test(key) ? Number.parseInt(key, 10) : Number.NaN); +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'; +}; + +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'; +}; + const readSelectedChoice = async (message: string, choices: Array>, hint?: string, scroll?: number): Promise => { const environment = promptEnvironment(); @@ -38,18 +62,35 @@ const readSelectedChoice = async (message: string, choices: Array>, return choices[numeric - 1].value; } - if (key === Key.down || key === Key.downArrow || key === Key.ctrlN) { + if (nextChoiceKeys(key)) { selected = nextEnabledIndex(choices, selected, 1); renderInteractiveChoices(message, choices, selected, new Set(), scroll); continue; } - if (key === Key.up || key === Key.upArrow || key === Key.ctrlP) { + if (previousChoiceKeys(key)) { selected = nextEnabledIndex(choices, selected, -1); renderInteractiveChoices(message, choices, selected, new Set(), scroll); continue; } + if (oneOf([Key.home, Key.ctrlA], key)) { + selected = firstEnabledIndex(choices); + renderInteractiveChoices(message, choices, selected, new Set(), scroll); + continue; + } + + if (oneOf([Key.end, Key.ctrlE], key)) { + selected = choices.length - 1; + + while (choices[selected]?.disabled && selected > 0) { + selected -= 1; + } + + renderInteractiveChoices(message, choices, selected, new Set(), scroll); + continue; + } + if (key === Key.enter) { const choice = choices[selected]; @@ -129,18 +170,52 @@ const readMultipleChoices = async (message: string, choices: Array> continue; } - if (key === Key.down || key === Key.downArrow || key === Key.ctrlN) { + if (nextChoiceKeys(key)) { selected = nextEnabledIndex(choices, selected, 1); renderMultipleChoices(message, choices, selected, marked, scroll); continue; } - if (key === Key.up || key === Key.upArrow || key === Key.ctrlP) { + if (previousChoiceKeys(key)) { selected = nextEnabledIndex(choices, selected, -1); renderMultipleChoices(message, choices, selected, marked, scroll); continue; } + if (oneOf([Key.home], key)) { + selected = firstEnabledIndex(choices); + renderMultipleChoices(message, choices, selected, marked, scroll); + continue; + } + + if (oneOf([Key.end], key)) { + selected = choices.length - 1; + + while (choices[selected]?.disabled && selected > 0) { + selected -= 1; + } + + renderMultipleChoices(message, choices, selected, marked, scroll); + continue; + } + + if (key === Key.ctrlA) { + if (marked.size === choices.filter((choice) => !choice.disabled).length) { + marked.clear(); + } else { + marked.clear(); + + for (const [index, choice] of choices.entries()) { + if (!choice.disabled) { + marked.add(index); + } + } + } + + renderMultipleChoices(message, choices, selected, marked, scroll); + continue; + } + if (key === Key.space) { if (marked.has(selected)) { marked.delete(selected); diff --git a/packages/tui/tests/choices.test.ts b/packages/tui/tests/choices.test.ts index 2997bdb2..f435b3a8 100644 --- a/packages/tui/tests/choices.test.ts +++ b/packages/tui/tests/choices.test.ts @@ -38,6 +38,36 @@ describe('choice prompts', () => { expect(latestFrame).toContain('fourth'); }); + it('supports alternate select navigation keys', async () => { + const output = createMemoryOutput(); + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.tab, 'l', Key.right, 'h', Key.enter]), + output, + error: output, + interactive: true + }, + () => select({ message: 'Pick one', options: ['first', 'second', 'third'] }) + ); + + expect(result).toBe('third'); + }); + + it('supports home and end select navigation keys', async () => { + const output = createMemoryOutput(); + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.end[0], Key.home[0], Key.enter]), + output, + error: output, + interactive: true + }, + () => select({ message: 'Pick one', options: ['first', 'second', 'third'] }) + ); + + expect(result).toBe('first'); + }); + it('toggles multiselect choices with the space bar', async () => { const output = createMemoryOutput(); const result = await withPromptEnvironment( @@ -70,4 +100,20 @@ describe('choice prompts', () => { expect(result).toEqual(['first']); expect(output.text()).toContain('A value is required.'); }); + + it('toggles all multiselect choices with ctrl-a', async () => { + const output = createMemoryOutput(); + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.ctrlA, Key.enter]), + output, + error: output, + interactive: true + }, + () => multiselect({ message: 'Pick many', options: ['first', 'second', 'third'] }) + ); + + expect(result).toEqual(['first', 'second', 'third']); + expect(output.text()).toContain('Selected: first, second, third'); + }); }); From bbd1c6ae92b7aeb9d1ee24509cc877117704ebd2 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 14:21:55 +0800 Subject: [PATCH 021/675] Render prompt info text --- packages/tui/src/concerns/info.ts | 9 +++++ packages/tui/src/index.ts | 1 + packages/tui/src/prompts/search.ts | 26 +++++++++----- packages/tui/src/prompts/select.ts | 53 +++++++++++++++++++---------- packages/tui/src/prompts/suggest.ts | 25 +++++++++----- packages/tui/src/types.ts | 10 +++--- packages/tui/tests/choices.test.ts | 32 +++++++++++++++++ packages/tui/tests/search.test.ts | 30 ++++++++++++++++ packages/tui/tests/suggest.test.ts | 16 +++++++++ 9 files changed, 162 insertions(+), 40 deletions(-) create mode 100644 packages/tui/src/concerns/info.ts diff --git a/packages/tui/src/concerns/info.ts b/packages/tui/src/concerns/info.ts new file mode 100644 index 00000000..c5c9209f --- /dev/null +++ b/packages/tui/src/concerns/info.ts @@ -0,0 +1,9 @@ +export type InfoResolver = string | ((value: T | null) => string | null | undefined) | undefined; + +export const resolveInfo = (info: InfoResolver, value: T | null): string => { + if (typeof info === 'function') { + return info(value) ?? ''; + } + + return info ?? ''; +}; diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index 5de07c04..cd1e5872 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -23,6 +23,7 @@ export type { MultiSelectPromptOptions, NumberPromptOptions, PromptEnvironment, + PromptInfo, PromptInput, PromptOutput, PromptValue, diff --git a/packages/tui/src/prompts/search.ts b/packages/tui/src/prompts/search.ts index 9612259d..b20b67db 100644 --- a/packages/tui/src/prompts/search.ts +++ b/packages/tui/src/prompts/search.ts @@ -3,6 +3,7 @@ import { Key, oneOf } from '#tui/key'; import { ask, promptUntilValid, PromptValidationError } from '#tui/prompt'; import { applyTypedKey } from '#tui/typed-value'; import { findChoice, normalizeSearchChoices, renderInteractiveChoices } from '#tui/concerns/choices'; +import { resolveInfo } from '#tui/concerns/info'; import type { Choice, MultiSearchPromptOptions, SearchPromptOptions } from '#tui/types'; const resolveSearchChoices = async (source: SearchPromptOptions['options'], query: string): Promise>> => { @@ -22,10 +23,17 @@ const renderSearchChoices = ( highlighted: number | null, marked: Set = new Set(), selectedLabels: string[] = [], - scroll?: number + scroll?: number, + info?: SearchPromptOptions['info'] | MultiSearchPromptOptions['info'] ): void => { renderInteractiveChoices(searchMessage(message, query), choices, highlighted ?? 0, marked, scroll); + const text = resolveInfo(info, highlighted === null ? null : choices[highlighted]?.value ?? null); + + if (text.length > 0) { + promptEnvironment().output.write(`${text}\n`); + } + if (selectedLabels.length > 0) { promptEnvironment().output.write(`Selected: ${selectedLabels.join(', ')}\n`); } @@ -46,7 +54,7 @@ const readSearchChoice = async (options: SearchPromptOptions, attempt = 0) let choices = await resolveSearchChoices(options.options, state.value); let highlighted: number | null = null; - renderSearchChoices(options.message, state.value, choices, highlighted, new Set(), [], options.scroll); + renderSearchChoices(options.message, state.value, choices, highlighted, new Set(), [], options.scroll, options.info); while (true) { const key = await environment.input.readKey(); @@ -58,26 +66,26 @@ const readSearchChoice = async (options: SearchPromptOptions, attempt = 0) if (key === Key.down || key === Key.downArrow || key === Key.ctrlN || key === Key.tab) { choices = await resolveSearchChoices(options.options, state.value); highlighted = choices.length === 0 ? null : ((highlighted ?? (attempt > 0 ? 0 : -1)) + 1) % choices.length; - renderSearchChoices(options.message, state.value, choices, highlighted, new Set(), [], options.scroll); + renderSearchChoices(options.message, state.value, choices, highlighted, new Set(), [], options.scroll, options.info); continue; } if (key === Key.up || key === Key.upArrow || key === Key.ctrlP || key === Key.shiftTab) { choices = await resolveSearchChoices(options.options, state.value); highlighted = choices.length === 0 ? null : ((highlighted ?? choices.length) - 1 + choices.length) % choices.length; - renderSearchChoices(options.message, state.value, choices, highlighted, new Set(), [], options.scroll); + renderSearchChoices(options.message, state.value, choices, highlighted, new Set(), [], options.scroll, options.info); continue; } if (oneOf([Key.home, Key.ctrlA], key) && highlighted !== null) { highlighted = 0; - renderSearchChoices(options.message, state.value, choices, highlighted, new Set(), [], options.scroll); + renderSearchChoices(options.message, state.value, choices, highlighted, new Set(), [], options.scroll, options.info); continue; } if (oneOf([Key.end, Key.ctrlE], key) && highlighted !== null) { highlighted = Math.max(0, choices.length - 1); - renderSearchChoices(options.message, state.value, choices, highlighted, new Set(), [], options.scroll); + renderSearchChoices(options.message, state.value, choices, highlighted, new Set(), [], options.scroll, options.info); continue; } @@ -88,7 +96,7 @@ const readSearchChoice = async (options: SearchPromptOptions, attempt = 0) choices = await resolveSearchChoices(options.options, state.value); highlighted = choices.length > 0 ? 0 : null; - renderSearchChoices(options.message, state.value, choices, highlighted, new Set(), [], options.scroll); + renderSearchChoices(options.message, state.value, choices, highlighted, new Set(), [], options.scroll, options.info); continue; } @@ -103,7 +111,7 @@ const readSearchChoice = async (options: SearchPromptOptions, attempt = 0) state = { cursor: next.cursor, value: next.value }; highlighted = null; choices = await resolveSearchChoices(options.options, state.value); - renderSearchChoices(options.message, state.value, choices, highlighted, new Set(), [], options.scroll); + renderSearchChoices(options.message, state.value, choices, highlighted, new Set(), [], options.scroll, options.info); } }; @@ -150,7 +158,7 @@ const readMultiSearchChoices = async (options: MultiSearchPromptOptions): const render = (): void => { const marked = new Set(choices.flatMap((choice, index) => selected.has(choice.value) ? [index] : [])); - renderSearchChoices(options.message, state.value, choices, highlighted, marked, [...selected.values()], options.scroll); + renderSearchChoices(options.message, state.value, choices, highlighted, marked, [...selected.values()], options.scroll, options.info); }; render(); diff --git a/packages/tui/src/prompts/select.ts b/packages/tui/src/prompts/select.ts index 98a976ea..f9e820e5 100644 --- a/packages/tui/src/prompts/select.ts +++ b/packages/tui/src/prompts/select.ts @@ -3,6 +3,7 @@ import { Key, oneOf } from '#tui/key'; import { ask, promptUntilValid, PromptValidationError } from '#tui/prompt'; import { renderChoices } from '#tui/theme'; import { findChoice, firstEnabledIndex, nextEnabledIndex, normalizeChoices, renderInteractiveChoices } from '#tui/concerns/choices'; +import { resolveInfo } from '#tui/concerns/info'; import type { Choice, ConfirmPromptOptions, MultiSelectPromptOptions, SelectPromptOptions } from '#tui/types'; const parseChoiceIndex = (key: string): number => (/^\d+$/u.test(key) ? Number.parseInt(key, 10) : Number.NaN); @@ -31,7 +32,17 @@ const nextChoiceKeys = (key: string): boolean => { || key === 'l'; }; -const readSelectedChoice = async (message: string, choices: Array>, hint?: string, scroll?: number): Promise => { +const renderSelectedChoice = (message: string, choices: Array>, selected: number, scroll: number | undefined, info: SelectPromptOptions['info']): void => { + renderInteractiveChoices(message, choices, selected, new Set(), scroll); + + const text = resolveInfo(info, choices[selected]?.value ?? null); + + if (text.length > 0) { + promptEnvironment().output.write(`${text}\n`); + } +}; + +const readSelectedChoice = async (message: string, choices: Array>, hint?: string, scroll?: number, info?: SelectPromptOptions['info']): Promise => { const environment = promptEnvironment(); if (!environment.input.readKey) { @@ -47,7 +58,7 @@ const readSelectedChoice = async (message: string, choices: Array>, } let selected = firstEnabledIndex(choices); - renderInteractiveChoices(message, choices, selected, new Set(), scroll); + renderSelectedChoice(message, choices, selected, scroll, info); while (true) { const key = await environment.input.readKey(); @@ -64,19 +75,19 @@ const readSelectedChoice = async (message: string, choices: Array>, if (nextChoiceKeys(key)) { selected = nextEnabledIndex(choices, selected, 1); - renderInteractiveChoices(message, choices, selected, new Set(), scroll); + renderSelectedChoice(message, choices, selected, scroll, info); continue; } if (previousChoiceKeys(key)) { selected = nextEnabledIndex(choices, selected, -1); - renderInteractiveChoices(message, choices, selected, new Set(), scroll); + renderSelectedChoice(message, choices, selected, scroll, info); continue; } if (oneOf([Key.home, Key.ctrlA], key)) { selected = firstEnabledIndex(choices); - renderInteractiveChoices(message, choices, selected, new Set(), scroll); + renderSelectedChoice(message, choices, selected, scroll, info); continue; } @@ -87,7 +98,7 @@ const readSelectedChoice = async (message: string, choices: Array>, selected -= 1; } - renderInteractiveChoices(message, choices, selected, new Set(), scroll); + renderSelectedChoice(message, choices, selected, scroll, info); continue; } @@ -116,9 +127,15 @@ const choicesFromCommaSeparated = (choices: Array>, answer: string) return selected.map((choice) => choice.value); }; -const renderMultipleChoices = (message: string, choices: Array>, selected: number, marked: Set, scroll?: number): void => { +const renderMultipleChoices = (message: string, choices: Array>, selected: number, marked: Set, scroll?: number, info?: MultiSelectPromptOptions['info']): void => { renderInteractiveChoices(message, choices, selected, marked, scroll); + const text = resolveInfo(info, choices[selected]?.value ?? null); + + if (text.length > 0) { + promptEnvironment().output.write(`${text}\n`); + } + const labels = [...marked] .sort((left, right) => left - right) .map((index) => choices[index]?.label) @@ -129,7 +146,7 @@ const renderMultipleChoices = (message: string, choices: Array>, se } }; -const readMultipleChoices = async (message: string, choices: Array>, defaults: T[] = [], hint?: string, scroll?: number): Promise => { +const readMultipleChoices = async (message: string, choices: Array>, defaults: T[] = [], hint?: string, scroll?: number, info?: MultiSelectPromptOptions['info']): Promise => { const environment = promptEnvironment(); const selectedValues = new Set(defaults); @@ -142,7 +159,7 @@ const readMultipleChoices = async (message: string, choices: Array> let selected = firstEnabledIndex(choices); const marked = new Set(choices.flatMap((choice, index) => selectedValues.has(choice.value) ? [index] : [])); - renderMultipleChoices(message, choices, selected, marked, scroll); + renderMultipleChoices(message, choices, selected, marked, scroll, info); while (true) { const key = await environment.input.readKey(); @@ -166,25 +183,25 @@ const readMultipleChoices = async (message: string, choices: Array> marked.add(index); } - renderMultipleChoices(message, choices, selected, marked, scroll); + renderMultipleChoices(message, choices, selected, marked, scroll, info); continue; } if (nextChoiceKeys(key)) { selected = nextEnabledIndex(choices, selected, 1); - renderMultipleChoices(message, choices, selected, marked, scroll); + renderMultipleChoices(message, choices, selected, marked, scroll, info); continue; } if (previousChoiceKeys(key)) { selected = nextEnabledIndex(choices, selected, -1); - renderMultipleChoices(message, choices, selected, marked, scroll); + renderMultipleChoices(message, choices, selected, marked, scroll, info); continue; } if (oneOf([Key.home], key)) { selected = firstEnabledIndex(choices); - renderMultipleChoices(message, choices, selected, marked, scroll); + renderMultipleChoices(message, choices, selected, marked, scroll, info); continue; } @@ -195,7 +212,7 @@ const readMultipleChoices = async (message: string, choices: Array> selected -= 1; } - renderMultipleChoices(message, choices, selected, marked, scroll); + renderMultipleChoices(message, choices, selected, marked, scroll, info); continue; } @@ -212,7 +229,7 @@ const readMultipleChoices = async (message: string, choices: Array> } } - renderMultipleChoices(message, choices, selected, marked, scroll); + renderMultipleChoices(message, choices, selected, marked, scroll, info); continue; } @@ -223,7 +240,7 @@ const readMultipleChoices = async (message: string, choices: Array> marked.add(selected); } - renderMultipleChoices(message, choices, selected, marked, scroll); + renderMultipleChoices(message, choices, selected, marked, scroll, info); continue; } @@ -272,7 +289,7 @@ export const select = async (options: SelectPromptOptions): Promise => const choices = normalizeChoices(options.options); return promptUntilValid(options, async () => { - return readSelectedChoice(options.message, choices, options.hint, options.scroll).catch((error: unknown) => { + return readSelectedChoice(options.message, choices, options.hint, options.scroll, options.info).catch((error: unknown) => { if (options.default !== undefined && error instanceof PromptValidationError) { return options.default; } @@ -285,5 +302,5 @@ export const select = async (options: SelectPromptOptions): Promise => export const multiselect = async (options: MultiSelectPromptOptions): Promise => { const choices = normalizeChoices(options.options); - return promptUntilValid(options, async () => readMultipleChoices(options.message, choices, options.default, options.hint, options.scroll)); + return promptUntilValid(options, async () => readMultipleChoices(options.message, choices, options.default, options.hint, options.scroll, options.info)); }; diff --git a/packages/tui/src/prompts/suggest.ts b/packages/tui/src/prompts/suggest.ts index f6e957df..a8915607 100644 --- a/packages/tui/src/prompts/suggest.ts +++ b/packages/tui/src/prompts/suggest.ts @@ -3,12 +3,13 @@ import { Key, oneOf } from '#tui/key'; import { ask, promptUntilValid } from '#tui/prompt'; import { applyTypedKey } from '#tui/typed-value'; import { renderInteractiveChoices } from '#tui/concerns/choices'; +import { resolveInfo } from '#tui/concerns/info'; import type { MaybePromise, TextPromptOptions } from '#tui/types'; export type SuggestOptions = TextPromptOptions & { options: string[] | ((query: string) => MaybePromise); scroll?: number; - info?: string | ((value: string) => string); + info?: string | ((value: string | null) => string | null | undefined); }; const suggestOptions = ( @@ -39,7 +40,7 @@ const resolveSuggestions = async (source: SuggestOptions['options'], query: stri return options.filter((option: string) => option.toLowerCase().startsWith(query.toLowerCase())); }; -const renderSuggestions = (message: string, value: string, matches: string[], highlighted: number | null, scroll?: number): void => { +const renderSuggestions = (message: string, value: string, matches: string[], highlighted: number | null, scroll?: number, info?: SuggestOptions['info']): void => { renderInteractiveChoices( value.length > 0 ? `${message} ${value}` : message, matches.map((match) => ({ label: match, value: match })), @@ -47,6 +48,12 @@ const renderSuggestions = (message: string, value: string, matches: string[], hi new Set(), scroll ); + + const text = resolveInfo(info, highlighted === null ? null : matches[highlighted] ?? null); + + if (text.length > 0) { + promptEnvironment().output.write(`${text}\n`); + } }; const readSuggestionValue = async (options: SuggestOptions): Promise => { @@ -63,7 +70,7 @@ const readSuggestionValue = async (options: SuggestOptions): Promise => let highlighted: number | null = null; let matches: string[] = await resolveSuggestions(options.options, state.value); - renderSuggestions(options.message, state.value, matches, highlighted, options.scroll); + renderSuggestions(options.message, state.value, matches, highlighted, options.scroll, options.info); while (true) { const key = await environment.input.readKey(); @@ -82,33 +89,33 @@ const readSuggestionValue = async (options: SuggestOptions): Promise => matches = await resolveSuggestions(options.options, state.value); highlighted = null; - renderSuggestions(options.message, state.value, matches, highlighted, options.scroll); + renderSuggestions(options.message, state.value, matches, highlighted, options.scroll, options.info); continue; } if (key === Key.down || key === Key.downArrow || key === Key.ctrlN || key === Key.shiftTab) { matches = await resolveSuggestions(options.options, state.value); highlighted = matches.length === 0 ? null : ((highlighted ?? -1) + 1) % matches.length; - renderSuggestions(options.message, state.value, matches, highlighted, options.scroll); + renderSuggestions(options.message, state.value, matches, highlighted, options.scroll, options.info); continue; } if (key === Key.up || key === Key.upArrow || key === Key.ctrlP) { matches = await resolveSuggestions(options.options, state.value); highlighted = matches.length === 0 ? null : ((highlighted ?? matches.length) - 1 + matches.length) % matches.length; - renderSuggestions(options.message, state.value, matches, highlighted, options.scroll); + renderSuggestions(options.message, state.value, matches, highlighted, options.scroll, options.info); continue; } if (oneOf([Key.home, Key.ctrlA], key) && highlighted !== null) { highlighted = 0; - renderSuggestions(options.message, state.value, matches, highlighted, options.scroll); + renderSuggestions(options.message, state.value, matches, highlighted, options.scroll, options.info); continue; } if (oneOf([Key.end, Key.ctrlE], key) && highlighted !== null) { highlighted = Math.max(0, matches.length - 1); - renderSuggestions(options.message, state.value, matches, highlighted, options.scroll); + renderSuggestions(options.message, state.value, matches, highlighted, options.scroll, options.info); continue; } @@ -135,7 +142,7 @@ const readSuggestionValue = async (options: SuggestOptions): Promise => state = { cursor: next.cursor, value: next.value }; highlighted = null; matches = await resolveSuggestions(options.options, state.value); - renderSuggestions(options.message, state.value, matches, highlighted, options.scroll); + renderSuggestions(options.message, state.value, matches, highlighted, options.scroll, options.info); } }; diff --git a/packages/tui/src/types.ts b/packages/tui/src/types.ts index 8e4f515b..7373bc5e 100644 --- a/packages/tui/src/types.ts +++ b/packages/tui/src/types.ts @@ -39,6 +39,8 @@ export type Choice = { export type ChoiceInput = Choice | T; +export type PromptInfo = string | ((value: T | null) => string | null | undefined); + export type TextPromptOptions = BasePromptOptions & { label?: string; options?: string[] | ((query: string) => MaybePromise); @@ -65,14 +67,14 @@ export type SelectPromptOptions = BasePromptOptions & { label?: string; options: Array>; scroll?: number; - info?: string | ((value: T) => string); + info?: PromptInfo; }; export type MultiSelectPromptOptions = BasePromptOptions & { label?: string; options: Array>; scroll?: number; - info?: string | ((value: T[]) => string); + info?: PromptInfo; }; export type SearchPromptOptions = BasePromptOptions & { @@ -80,7 +82,7 @@ export type SearchPromptOptions = BasePromptOptions & { options: Array> | Record | ((query: string) => MaybePromise> | Record>); placeholder?: string; scroll?: number; - info?: string | ((value: T) => string); + info?: PromptInfo; }; export type MultiSearchPromptOptions = BasePromptOptions & { @@ -88,7 +90,7 @@ export type MultiSearchPromptOptions = BasePromptOptions & { options: Array> | Record | ((query: string) => MaybePromise> | Record>); placeholder?: string; scroll?: number; - info?: string | ((value: T[]) => string); + info?: PromptInfo; }; export type TableCell = string | number | boolean | null | undefined; diff --git a/packages/tui/tests/choices.test.ts b/packages/tui/tests/choices.test.ts index f435b3a8..7a0794fb 100644 --- a/packages/tui/tests/choices.test.ts +++ b/packages/tui/tests/choices.test.ts @@ -68,6 +68,22 @@ describe('choice prompts', () => { expect(result).toBe('first'); }); + it('renders select info for the highlighted option', async () => { + const output = createMemoryOutput(); + await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.enter]), + output, + error: output, + interactive: true + }, + () => select({ message: 'Pick one', options: ['first', 'second'], info: (value) => `About ${value ?? 'none'}` }) + ); + + expect(output.text()).toContain('About first'); + expect(output.text()).toContain('About second'); + }); + it('toggles multiselect choices with the space bar', async () => { const output = createMemoryOutput(); const result = await withPromptEnvironment( @@ -116,4 +132,20 @@ describe('choice prompts', () => { expect(result).toEqual(['first', 'second', 'third']); expect(output.text()).toContain('Selected: first, second, third'); }); + + it('renders multiselect info for the highlighted option', async () => { + const output = createMemoryOutput(); + await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.enter]), + output, + error: output, + interactive: true + }, + () => multiselect({ message: 'Pick many', options: ['first', 'second'], info: (value) => `About ${value ?? 'none'}` }) + ); + + expect(output.text()).toContain('About first'); + expect(output.text()).toContain('About second'); + }); }); diff --git a/packages/tui/tests/search.test.ts b/packages/tui/tests/search.test.ts index 2b630281..38c94ece 100644 --- a/packages/tui/tests/search.test.ts +++ b/packages/tui/tests/search.test.ts @@ -89,6 +89,21 @@ describe('search prompt', () => { expect(latestFrame).toContain('three'); expect(latestFrame).toContain('four'); }); + + it('renders search info for the highlighted result', async () => { + const output = createMemoryOutput(); + await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.enter]), + output, + error: output, + interactive: true + }, + () => search({ message: 'Favorite color?', options: colors, info: (value) => `About ${value ?? 'none'}` }) + ); + + expect(output.text()).toContain('About red'); + }); }); describe('multisearch prompt', () => { @@ -116,4 +131,19 @@ describe('multisearch prompt', () => { expect(output.text()).toContain('Selected: Violet'); expect(output.text()).toContain('Selected: Violet, Green'); }); + + it('renders multisearch info for the highlighted result', async () => { + const output = createMemoryOutput(); + await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.enter]), + output, + error: output, + interactive: true + }, + () => multisearch({ message: 'Favorite colors?', options: colors, info: (value) => `About ${value ?? 'none'}` }) + ); + + expect(output.text()).toContain('About red'); + }); }); diff --git a/packages/tui/tests/suggest.test.ts b/packages/tui/tests/suggest.test.ts index 7426cb58..2e52aaf3 100644 --- a/packages/tui/tests/suggest.test.ts +++ b/packages/tui/tests/suggest.test.ts @@ -67,4 +67,20 @@ describe('suggest prompt', () => { expect(output).toContain('Favorite color? Blue'); }); + + it('renders suggest info for the highlighted result', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment( + { + input: createScriptedInput(['b', Key.down, Key.enter]), + output, + error: output, + interactive: true + }, + () => suggest({ message: 'Favorite color?', options: ['Red', 'Green', 'Blue'], info: (value) => `About ${value ?? 'none'}` }) + ); + + expect(output.text()).toContain('About Blue'); + }); }); From 5f013982068f70ce879169ac779e738c32af9d67 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 14:27:56 +0800 Subject: [PATCH 022/675] Add project maintenance Makefile targets --- Makefile | 49 +++ packages/acceptance/tests/helpers.test.ts | 219 +++++----- packages/acceptance/tests/opentui.test.ts | 69 +-- packages/acceptance/vitest.config.ts | 8 +- packages/tui/src/concerns/choices.ts | 106 ++--- packages/tui/src/concerns/info.ts | 8 +- packages/tui/src/concerns/text-options.ts | 8 +- packages/tui/src/environment.ts | 162 ++++--- packages/tui/src/form.ts | 6 +- packages/tui/src/form/builder.ts | 496 ++++++++++++++-------- packages/tui/src/form/types.ts | 8 +- packages/tui/src/index.ts | 48 +-- packages/tui/src/key.ts | 118 ++--- packages/tui/src/opentui.ts | 25 +- packages/tui/src/output/data-table.ts | 234 +++++----- packages/tui/src/output/notes.ts | 38 +- packages/tui/src/output/notify.ts | 38 +- packages/tui/src/output/table.ts | 49 +-- packages/tui/src/output/terminal.ts | 4 +- packages/tui/src/prompt.ts | 125 +++--- packages/tui/src/prompts/number.ts | 87 ++-- packages/tui/src/prompts/password.ts | 32 +- packages/tui/src/prompts/pause.ts | 2 +- packages/tui/src/prompts/search.ts | 387 +++++++++-------- packages/tui/src/prompts/select.ts | 454 ++++++++++---------- packages/tui/src/prompts/suggest.ts | 340 +++++++-------- packages/tui/src/prompts/text.ts | 51 +-- packages/tui/src/prompts/textarea.ts | 57 +-- packages/tui/src/status/progress.ts | 174 ++++---- packages/tui/src/status/spinner.ts | 19 +- packages/tui/src/status/stream.ts | 24 +- packages/tui/src/status/task.ts | 90 ++-- packages/tui/src/strings.ts | 98 ++--- packages/tui/src/terminal.ts | 16 +- packages/tui/src/theme.ts | 54 +-- packages/tui/src/typed-value.ts | 184 ++++---- packages/tui/src/types.ts | 117 +++-- packages/tui/tests/choices.test.ts | 302 ++++++------- packages/tui/tests/form.test.ts | 262 ++++++------ packages/tui/tests/key.test.ts | 14 +- packages/tui/tests/number.test.ts | 58 +-- packages/tui/tests/progress.test.ts | 45 +- packages/tui/tests/search.test.ts | 282 ++++++------ packages/tui/tests/strings.test.ts | 24 +- packages/tui/tests/suggest.test.ts | 146 +++---- packages/tui/tests/table.test.ts | 193 +++++---- packages/tui/tests/task.test.ts | 55 +-- packages/tui/tests/textarea.test.ts | 29 +- packages/tui/tests/typed-value.test.ts | 76 ++-- packages/tui/vitest.config.ts | 8 +- 50 files changed, 2888 insertions(+), 2610 deletions(-) create mode 100644 Makefile diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..56e266f0 --- /dev/null +++ b/Makefile @@ -0,0 +1,49 @@ +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' '*.mts' '*.cts' + +.DEFAULT_GOAL := help + +define step + @printf "\n\033[1;36m==>\033[0m \033[1m%s\033[0m\n" "$(1)" +endef + +define run_oxfmt +@cd "$(ROOT_PATH)" && tmp=$$(mktemp); git ls-files -z $(1) --exclude-standard -- $(TS_GLOBS) | while IFS= read -r -d '' f; do [ -f "$$f" ] && printf '%s\0' "$$f"; done > "$$tmp"; if [ ! -s "$$tmp" ]; then echo "No TS files to format."; rm -f "$$tmp"; else xargs -0 $(FMT_RUN) format < "$$tmp"; rc=$$?; rm -f "$$tmp"; exit $$rc; fi +endef + +.PHONY: help format format-all fresh + +help: + @awk 'BEGIN {FS = ":.*?## "; printf "\nUsage: \033[36mmake \033[0m\n"} \ + /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5); next } \ + /^[a-zA-Z0-9_.-]+:.*?##/ { printf " \033[36m%-28s\033[0m %s\n", $$1, $$2 }' $(MAKEFILE_LIST) + @echo "" + +##@ Formatting +format: ## Format untracked + modified TS files via oxfmt/oxlint + $(call run_oxfmt,--others --modified) + +format-all: ## Format every non-ignored TS 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 files to format."; rm -f "$$tmp"; else xargs -0 $(FMT_RUN) format < "$$tmp"; rc=$$?; rm -f "$$tmp"; exit $$rc; fi + +##@ Maintenance +fresh: ## Clean generated state, reinstall dependencies, then run local gates + $(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)/.turbo" "$(ROOT_PATH)/packages/artefacts/.cache" "$(ROOT_PATH)/packages/artefacts/.logs" "$(ROOT_PATH)/packages/artefacts/dist" + $(call step,Removing TypeScript build info files) + find "$(ROOT_PATH)" -name '*.tsbuildinfo' -type f -delete + $(call step,Installing dependencies) + cd "$(ROOT_PATH)" && pnpm install + $(call step,Running local gates) + cd "$(ROOT_PATH)" && pnpm typecheck && pnpm test diff --git a/packages/acceptance/tests/helpers.test.ts b/packages/acceptance/tests/helpers.test.ts index 96f6572d..445d6d83 100644 --- a/packages/acceptance/tests/helpers.test.ts +++ b/packages/acceptance/tests/helpers.test.ts @@ -1,117 +1,120 @@ 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 + 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 }; + 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.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'); - }); + 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.finish(); + + await stream(['a', 'b']); + }); + + expect(output.text()).toContain('Working'); + expect(output.text()).toContain('Files:'); + expect(output.text()).toContain('2 / 2'); + expect(output.text()).toContain('ab'); + }); }); diff --git a/packages/acceptance/tests/opentui.test.ts b/packages/acceptance/tests/opentui.test.ts index 7f6aefcb..8dd293f1 100644 --- a/packages/acceptance/tests/opentui.test.ts +++ b/packages/acceptance/tests/opentui.test.ts @@ -4,38 +4,39 @@ import { describe, expect, it } from 'vitest'; import { renderOpenTuiTextFrame } from '@ollin/tui'; describe('OpenTUI memory renderer', () => { - it('captures rendered text without writing to the real terminal', async () => { - let testRenderer: Awaited>; - - try { - testRenderer = await createTestRenderer({ width: 20, height: 4 }); - } catch (error) { - expect(error).toBeInstanceOf(Error); - expect((error as Error).message).toContain('native FFI is not available'); - - return; - } - - const { renderer, renderOnce, captureCharFrame } = testRenderer; - - try { - renderer.root.add(Text({ content: 'Prompt' })); - await renderOnce(); - - expect(captureCharFrame()).toContain('Prompt'); - } finally { - renderer.destroy(); - } - }); - - it('renders through the package OpenTUI adapter', async () => { - try { - const frame = await renderOpenTuiTextFrame('Prompt', { width: 20, height: 4 }); - - expect(frame.text).toContain('Prompt'); - } catch (error) { - expect(error).toBeInstanceOf(Error); - expect((error as Error).message).toContain('native FFI is not available'); - } - }); + it('captures rendered text without writing to the real terminal', async () => { + let testRenderer: Awaited>; + + try { + testRenderer = await createTestRenderer({ width: 20, height: 4 }); + } catch (error) { + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain('native FFI is not available'); + + return; + } + + const { renderer, renderOnce, captureCharFrame } = testRenderer; + + try { + renderer.root.add(Text({ content: 'Prompt' })); + + await renderOnce(); + + expect(captureCharFrame()).toContain('Prompt'); + } finally { + renderer.destroy(); + } + }); + + it('renders through the package OpenTUI adapter', async () => { + try { + const frame = await renderOpenTuiTextFrame('Prompt', { width: 20, height: 4 }); + + expect(frame.text).toContain('Prompt'); + } catch (error) { + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain('native FFI is not available'); + } + }); }); diff --git a/packages/acceptance/vitest.config.ts b/packages/acceptance/vitest.config.ts index 262b8b24..dfeeb7cb 100644 --- a/packages/acceptance/vitest.config.ts +++ b/packages/acceptance/vitest.config.ts @@ -1,8 +1,8 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ - test: { - environment: 'node', - globals: false - } + test: { + environment: 'node', + globals: false, + }, }); diff --git a/packages/tui/src/concerns/choices.ts b/packages/tui/src/concerns/choices.ts index e0e36285..9fe13875 100644 --- a/packages/tui/src/concerns/choices.ts +++ b/packages/tui/src/concerns/choices.ts @@ -2,87 +2,87 @@ import { promptEnvironment } from '#tui/environment'; import type { Choice, ChoiceInput } from '#tui/types'; export const normalizeChoices = (options: Array>): Array> => { - return options.map((choice) => { - if (typeof choice === 'object' && choice !== null && 'value' in choice && 'label' in choice) { - return choice; - } - - return { - label: String(choice), - value: choice as T - }; - }); + return options.map((choice) => { + if (typeof choice === 'object' && choice !== null && 'value' in choice && 'label' in choice) { + return choice; + } + + return { + label: String(choice), + value: choice as T, + }; + }); }; export const normalizeSearchChoices = (options: Array> | Record): Array> => { - if (Array.isArray(options)) { - return normalizeChoices(options); - } - - return Object.entries(options).map(([value, label]) => ({ - label, - value: value as T - })); + if (Array.isArray(options)) { + return normalizeChoices(options); + } + + return Object.entries(options).map(([value, label]) => ({ + label, + value: value as T, + })); }; export const findChoice = (choices: Array>, answer: string): Choice | undefined => { - if (/^\d+$/u.test(answer)) { - const index = Number.parseInt(answer, 10); + if (/^\d+$/u.test(answer)) { + const index = Number.parseInt(answer, 10); - return choices[index - 1]; - } + return choices[index - 1]; + } - return choices.find((choice) => choice.label === answer || String(choice.value) === answer); + return choices.find((choice) => choice.label === answer || String(choice.value) === answer); }; export const firstEnabledIndex = (choices: Array>): number => { - const index = choices.findIndex((choice) => !choice.disabled); + const index = choices.findIndex((choice) => !choice.disabled); - return index === -1 ? 0 : index; + return index === -1 ? 0 : index; }; export const nextEnabledIndex = (choices: Array>, current: number, direction: 1 | -1): number => { - if (choices.length === 0) { - return 0; - } + if (choices.length === 0) { + return 0; + } - let index = current; + let index = current; - for (let attempts = 0; attempts < choices.length; attempts += 1) { - index = (index + direction + choices.length) % choices.length; + for (let attempts = 0; attempts < choices.length; attempts += 1) { + index = (index + direction + choices.length) % choices.length; - if (!choices[index]?.disabled) { - return index; - } - } + if (!choices[index]?.disabled) { + return index; + } + } - return current; + return current; }; const choiceWindow = (total: number, selected: number, scroll?: number): { end: number; start: number } => { - if (scroll === undefined || scroll <= 0 || scroll >= total) { - return { end: total, start: 0 }; - } + if (scroll === undefined || scroll <= 0 || scroll >= total) { + return { end: total, start: 0 }; + } - const before = Math.floor((scroll - 1) / 2); - const start = Math.max(0, Math.min(selected - before, total - scroll)); + const before = Math.floor((scroll - 1) / 2); + const start = Math.max(0, Math.min(selected - before, total - scroll)); - return { end: start + scroll, start }; + return { end: start + scroll, start }; }; export const renderInteractiveChoices = (message: string, choices: Array>, selected: number, marked: Set = new Set(), scroll?: number): void => { - const environment = promptEnvironment(); - const window = choiceWindow(choices.length, selected, scroll); + const environment = promptEnvironment(); + const window = choiceWindow(choices.length, selected, scroll); - environment.output.write(`${message}\n`); + environment.output.write(`${message}\n`); - for (const [offset, choice] of choices.slice(window.start, window.end).entries()) { - const index = window.start + offset; - const pointer = index === selected ? '›' : ' '; - const checked = marked.size > 0 ? (marked.has(index) ? '[x]' : '[ ]') : ' '; - const disabled = choice.disabled ? ` (${typeof choice.disabled === 'string' ? choice.disabled : 'disabled'})` : ''; - const hint = choice.hint ? ` ${choice.hint}` : ''; + for (const [offset, choice] of choices.slice(window.start, window.end).entries()) { + const index = window.start + offset; + const pointer = index === selected ? '›' : ' '; + const checked = marked.size > 0 ? (marked.has(index) ? '[x]' : '[ ]') : ' '; + const disabled = choice.disabled ? ` (${typeof choice.disabled === 'string' ? choice.disabled : 'disabled'})` : ''; + const hint = choice.hint ? ` ${choice.hint}` : ''; - environment.output.write(`${pointer} ${checked} ${choice.label}${hint}${disabled}\n`); - } + environment.output.write(`${pointer} ${checked} ${choice.label}${hint}${disabled}\n`); + } }; diff --git a/packages/tui/src/concerns/info.ts b/packages/tui/src/concerns/info.ts index c5c9209f..3005cd53 100644 --- a/packages/tui/src/concerns/info.ts +++ b/packages/tui/src/concerns/info.ts @@ -1,9 +1,9 @@ export type InfoResolver = string | ((value: T | null) => string | null | undefined) | undefined; export const resolveInfo = (info: InfoResolver, value: T | null): string => { - if (typeof info === 'function') { - return info(value) ?? ''; - } + if (typeof info === 'function') { + return info(value) ?? ''; + } - return info ?? ''; + return info ?? ''; }; diff --git a/packages/tui/src/concerns/text-options.ts b/packages/tui/src/concerns/text-options.ts index 9c82d9d1..8883c275 100644 --- a/packages/tui/src/concerns/text-options.ts +++ b/packages/tui/src/concerns/text-options.ts @@ -1,9 +1,9 @@ import type { TextPromptOptions } from '#tui/types'; export const textOptions = (message: string | TextPromptOptions, fallback = ''): TextPromptOptions => { - if (typeof message === 'string') { - return { message, default: fallback }; - } + if (typeof message === 'string') { + return { message, default: fallback }; + } - return { ...message, default: message.default ?? fallback }; + return { ...message, default: message.default ?? fallback }; }; diff --git a/packages/tui/src/environment.ts b/packages/tui/src/environment.ts index 423ae7a3..35148206 100644 --- a/packages/tui/src/environment.ts +++ b/packages/tui/src/environment.ts @@ -3,105 +3,103 @@ import { stdin as defaultStdin, stdout as defaultStdout, stderr as defaultStderr import type { PromptEnvironment, PromptInput, PromptOutput } from '#tui/types'; const outputFromStream = (stream: NodeJS.WritableStream): PromptOutput => ({ - write(content: string): void { - stream.write(content); - } + write(content: string): void { + stream.write(content); + }, }); const defaultInput: PromptInput = { - async readKey(): Promise { - return new Promise((resolve) => { - const input = defaultStdin; - const wasRaw = input.isRaw; - - const cleanup = (): void => { - input.off('data', onData); - - if (input.isTTY) { - input.setRawMode(wasRaw); - input.pause(); - } - }; - - const onData = (chunk: Buffer): void => { - cleanup(); - resolve(chunk.toString('utf8')); - }; - - input.once('data', onData); - - if (input.isTTY) { - input.setRawMode(true); - input.resume(); - } - }); - }, - async readLine(message: string): Promise { - const readline = createInterface({ - input: defaultStdin, - output: defaultStdout - }); - - try { - return await readline.question(message); - } finally { - readline.close(); - } - } + async readKey(): Promise { + return new Promise((resolve) => { + const input = defaultStdin; + const wasRaw = input.isRaw; + + const cleanup = (): void => { + input.off('data', onData); + + if (input.isTTY) { + input.setRawMode(wasRaw); + input.pause(); + } + }; + + const onData = (chunk: Buffer): void => { + cleanup(); + resolve(chunk.toString('utf8')); + }; + + input.once('data', onData); + + if (input.isTTY) { + input.setRawMode(true); + input.resume(); + } + }); + }, + async readLine(message: string): Promise { + const readline = createInterface({ + input: defaultStdin, + output: defaultStdout, + }); + + try { + return await readline.question(message); + } finally { + readline.close(); + } + }, }; let currentEnvironment: PromptEnvironment = { - input: defaultInput, - output: outputFromStream(defaultStdout), - error: outputFromStream(defaultStderr), - interactive: Boolean(defaultStdin.isTTY) + input: defaultInput, + output: outputFromStream(defaultStdout), + error: outputFromStream(defaultStderr), + interactive: Boolean(defaultStdin.isTTY), }; export const promptEnvironment = (): PromptEnvironment => currentEnvironment; export const configurePrompts = (environment: Partial): void => { - currentEnvironment = { - ...currentEnvironment, - ...environment - }; + currentEnvironment = { + ...currentEnvironment, + ...environment, + }; }; -export const withPromptEnvironment = async ( - environment: Partial, - callback: () => Promise -): Promise => { - const previous = currentEnvironment; - configurePrompts(environment); - - try { - return await callback(); - } finally { - currentEnvironment = previous; - } +export const withPromptEnvironment = async (environment: Partial, callback: () => Promise): Promise => { + const previous = currentEnvironment; + + configurePrompts(environment); + + try { + return await callback(); + } finally { + currentEnvironment = previous; + } }; export const createMemoryOutput = (): PromptOutput & { text(): string } => { - let buffer = ''; - - return { - write(content: string): void { - buffer += content; - }, - text(): string { - return buffer; - } - }; + let buffer = ''; + + return { + write(content: string): void { + buffer += content; + }, + text(): string { + return buffer; + }, + }; }; export const createScriptedInput = (lines: string[]): PromptInput => { - const queued = [...lines]; - - return { - async readKey(): Promise { - return queued.shift() ?? null; - }, - async readLine(): Promise { - return queued.shift() ?? ''; - } - }; + const queued = [...lines]; + + return { + async readKey(): Promise { + return queued.shift() ?? null; + }, + async readLine(): Promise { + return queued.shift() ?? ''; + }, + }; }; diff --git a/packages/tui/src/form.ts b/packages/tui/src/form.ts index a162828d..888f61e7 100644 --- a/packages/tui/src/form.ts +++ b/packages/tui/src/form.ts @@ -5,9 +5,11 @@ import { FormBuilder } from '#tui/form/builder'; 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(); + const instance = new FormBuilder(); - return builder ? Promise.resolve(builder(instance)) : instance; + return builder ? Promise.resolve(builder(instance)) : instance; } diff --git a/packages/tui/src/form/builder.ts b/packages/tui/src/form/builder.ts index 3a223b1b..02a99980 100644 --- a/packages/tui/src/form/builder.ts +++ b/packages/tui/src/form/builder.ts @@ -5,184 +5,324 @@ import { progress, spin, stream, task } from '#tui/status'; import type { FormResponses, FormStep } from '#tui/form/types'; import type { ChoiceInput, DataTablePromptOptions, MaybePromise, MultiSearchPromptOptions, SearchPromptOptions, TableOptions, TextPromptOptions } from '#tui/types'; import type { SuggestOptions } from '#tui/prompts/choices'; -import type { Logger } from '#tui/status'; +import type { Logger, Progress } from '#tui/status'; export class FormBuilder { - readonly #steps: FormStep[] = []; - readonly #responses: FormResponses = [] as unknown as FormResponses; - - add(step: (responses: FormResponses, previous: unknown) => MaybePromise, name?: string, ignoreWhenReverting = false): this { - this.#steps.push({ - condition: true, - ignoreWhenReverting, - name, - run: step - }); - - return this; - } - - addIf( - condition: boolean | ((responses: FormResponses) => boolean), - step: (responses: FormResponses, previous: unknown) => MaybePromise, - name?: string, - ignoreWhenReverting = false - ): this { - this.#steps.push({ - condition, - ignoreWhenReverting, - name, - run: step - }); - - return this; - } - - async submit(): Promise { - for (const [index, step] of this.#steps.entries()) { - const key = step.name ?? index; - const shouldRun = typeof step.condition === 'function' ? step.condition(this.#responses) : step.condition; - - if (!shouldRun) { - this.#responses[key] = null; - continue; - } - - this.#responses[key] = await step.run(this.#responses, this.#responses[key]); - } - - return this.#responses; - } - - text(label: string, placeholder = '', defaultValue = '', required: boolean | string = false, validate: TextPromptOptions['validate'] = undefined, hint = '', name?: string, transform?: TextPromptOptions['transform']): this { - return this.add((_, previous) => text(label, placeholder, previous === undefined || previous === null ? defaultValue : String(previous), required, validate, hint, transform), name); - } - - textarea(label: string, placeholder = '', defaultValue = '', required: boolean | string = false, validate: TextPromptOptions['validate'] = undefined, hint = '', rows = 5, name?: string, transform?: TextPromptOptions['transform']): this { - return this.add((_, previous) => textarea(label, placeholder, previous === undefined || previous === null ? defaultValue : String(previous), required, validate, hint, rows, transform), name); - } - - password(label: string, placeholder = '', required: boolean | string = false, validate: TextPromptOptions['validate'] = undefined, hint = '', name?: string, transform?: TextPromptOptions['transform']): this { - return this.add(() => password(label, placeholder, required, validate, hint, transform), name); - } - - number(label: string, placeholder = '', defaultValue = 0, required: boolean | string = false, validate: ((value: number) => MaybePromise) | undefined = undefined, hint = '', min?: number, max?: number, step?: number, name?: string): this { - return this.add((_, previous) => number(label, placeholder, typeof previous === 'number' ? previous : defaultValue, required, validate, hint, min, max, step), name); - } - - confirm(label: string, defaultValue = true, yes = 'Yes', no = 'No', required: boolean | string = false, validate: ((value: boolean) => MaybePromise) | undefined = undefined, hint = '', name?: string): this { - return this.add(() => confirm(label, defaultValue, yes, no, required, validate, hint), name); - } - - select(label: string, options: Array>, defaultValue?: T, scroll = 5, validate: ((value: T) => MaybePromise) | undefined = undefined, hint = '', required: boolean | string = true, name?: string): this { - return this.add((_, previous) => select({ message: label, options, default: previous === undefined ? defaultValue : previous as T, scroll, validate, hint, required }), name); - } - - multiselect(label: string, options: Array>, defaultValue: T[] = [], scroll = 5, required: boolean | string = false, validate: ((value: T[]) => MaybePromise) | undefined = undefined, hint = 'Use the space bar to select options.', name?: string): this { - return this.add((_, previous) => multiselect({ message: label, options, default: Array.isArray(previous) ? previous as T[] : defaultValue, scroll, required, validate, hint }), name); - } - - suggest(label: string, options: SuggestOptions['options'], defaultValue = '', scroll = 5, required: boolean | string = false, validate: TextPromptOptions['validate'] = undefined, hint = '', name?: string, transform?: TextPromptOptions['transform']): this { - return this.add((_, previous) => suggest({ message: label, label, options, default: previous === undefined || previous === null ? defaultValue : String(previous), scroll, required, validate, hint, transform }), name); - } - - autocomplete(label: string, options: SuggestOptions['options'], defaultValue = '', required: boolean | string = false, validate: TextPromptOptions['validate'] = undefined, hint = '', name?: string, transform?: TextPromptOptions['transform']): this { - return this.add((_, previous) => autocomplete({ message: label, label, options, default: previous === undefined || previous === null ? defaultValue : String(previous), required, validate, hint, transform }), name); - } - - search(options: SearchPromptOptions, name?: string): this { - return this.add(() => search(options), name); - } - - multisearch(options: MultiSearchPromptOptions, name?: string): this { - return this.add(() => multisearch(options), name); - } - - spin(callback: () => MaybePromise, message = '', name?: string): this { - return this.add(() => spin(callback, { message }), name, true); - } - - task(label: string, callback: (logger: Logger) => MaybePromise, limit = 10, keepSummary = false, subLabel = '', name?: string): this { - return this.add(() => task(label, callback, limit, keepSummary, subLabel), name); - } - - pause(message = 'Press enter to continue', name?: string): this { - return this.add(async () => { - await pause(message); - - return null; - }, name, true); - } - - stream(source: AsyncIterable | Iterable, name?: string): this { - return this.add(async () => { - await stream(source); - - return null; - }, name, true); - } - - note(message: string, type: string | null = null, name?: string): this { - return this.add(() => { - note(message, type); - return null; - }, name, true); - } - - error(message: string, name?: string): this { - return this.add(() => { - error(message); - return null; - }, name, true); - } - - warning(message: string, name?: string): this { - return this.add(() => { - warning(message); - return null; - }, name, true); - } - - alert(message: string, name?: string): this { - return this.add(() => { - alert(message); - return null; - }, name, true); - } - - info(message: string, name?: string): this { - return this.add(() => { - info(message); - return null; - }, name, true); - } - - intro(message: string, name?: string): this { - return this.add(() => { - intro(message); - return null; - }, name, true); - } - - outro(message: string, name?: string): this { - return this.add(() => { - outro(message); - return null; - }, name, true); - } - - table(headersOrOptions: TableOptions | string[] = [], rows: TableOptions['rows'] | null = null, name?: string): this { - return this.add(() => { - table(headersOrOptions, rows); - return null; - }, name, true); - } - - datatable(options: DataTablePromptOptions, name?: string): this { - return this.add(() => datatable(options), name); - } - - progress(label: string, steps: Iterable | number, callback?: (step: T | number, bar: import('#tui/status').Progress) => MaybePromise, hint = '', name?: string): this { - return this.add(() => progress(label, steps, callback, hint), name, true); - } + readonly #steps: FormStep[] = []; + readonly #responses: FormResponses = [] as unknown as FormResponses; + + add(step: (responses: FormResponses, previous: unknown) => MaybePromise, name?: string, ignoreWhenReverting = false): this { + this.#steps.push({ + condition: true, + ignoreWhenReverting, + name, + run: step, + }); + + return this; + } + + addIf( + condition: boolean | ((responses: FormResponses) => boolean), + step: (responses: FormResponses, previous: unknown) => MaybePromise, + name?: string, + ignoreWhenReverting = false, + ): this { + this.#steps.push({ + condition, + ignoreWhenReverting, + name, + run: step, + }); + + return this; + } + + async submit(): Promise { + for (const [index, step] of this.#steps.entries()) { + const key = step.name ?? index; + const shouldRun = typeof step.condition === 'function' ? step.condition(this.#responses) : step.condition; + + if (!shouldRun) { + this.#responses[key] = null; + continue; + } + + this.#responses[key] = await step.run(this.#responses, this.#responses[key]); + } + + return this.#responses; + } + + text( + label: string, + placeholder = '', + defaultValue = '', + required: boolean | string = false, + validate: TextPromptOptions['validate'] = undefined, + hint = '', + name?: string, + transform?: TextPromptOptions['transform'], + ): this { + return this.add((_, previous) => text(label, placeholder, previous === undefined || previous === null ? defaultValue : String(previous), required, validate, hint, transform), name); + } + + textarea( + label: string, + placeholder = '', + defaultValue = '', + required: boolean | string = false, + validate: TextPromptOptions['validate'] = undefined, + hint = '', + rows = 5, + name?: string, + transform?: TextPromptOptions['transform'], + ): this { + return this.add((_, previous) => textarea(label, placeholder, previous === undefined || previous === null ? defaultValue : String(previous), required, validate, hint, rows, transform), name); + } + + password( + label: string, + placeholder = '', + required: boolean | string = false, + validate: TextPromptOptions['validate'] = undefined, + hint = '', + name?: string, + transform?: TextPromptOptions['transform'], + ): this { + return this.add(() => password(label, placeholder, required, validate, hint, transform), name); + } + + number( + label: string, + placeholder = '', + defaultValue = 0, + required: boolean | string = false, + validate: ((value: number) => MaybePromise) | undefined = undefined, + hint = '', + min?: number, + max?: number, + step?: number, + name?: string, + ): this { + return this.add((_, previous) => number(label, placeholder, typeof previous === 'number' ? previous : defaultValue, required, validate, hint, min, max, step), name); + } + + confirm( + label: string, + defaultValue = true, + yes = 'Yes', + no = 'No', + required: boolean | string = false, + validate: ((value: boolean) => MaybePromise) | undefined = undefined, + hint = '', + name?: string, + ): this { + return this.add(() => confirm(label, defaultValue, yes, no, required, validate, hint), name); + } + + select( + label: string, + options: Array>, + defaultValue?: T, + scroll = 5, + validate: ((value: T) => MaybePromise) | undefined = undefined, + hint = '', + required: boolean | string = true, + name?: string, + ): this { + return this.add((_, previous) => select({ message: label, options, default: previous === undefined ? defaultValue : (previous as T), scroll, validate, hint, required }), name); + } + + multiselect( + label: string, + options: Array>, + defaultValue: T[] = [], + scroll = 5, + required: boolean | string = false, + validate: ((value: T[]) => MaybePromise) | undefined = undefined, + hint = 'Use the space bar to select options.', + name?: string, + ): this { + return this.add((_, previous) => multiselect({ message: label, options, default: Array.isArray(previous) ? (previous as T[]) : defaultValue, scroll, required, validate, hint }), name); + } + + suggest( + label: string, + options: SuggestOptions['options'], + defaultValue = '', + scroll = 5, + required: boolean | string = false, + validate: TextPromptOptions['validate'] = undefined, + hint = '', + name?: string, + transform?: TextPromptOptions['transform'], + ): this { + return this.add( + (_, previous) => + suggest({ message: label, label, options, default: previous === undefined || previous === null ? defaultValue : String(previous), scroll, required, validate, hint, transform }), + name, + ); + } + + autocomplete( + label: string, + options: SuggestOptions['options'], + defaultValue = '', + required: boolean | string = false, + validate: TextPromptOptions['validate'] = undefined, + hint = '', + name?: string, + transform?: TextPromptOptions['transform'], + ): this { + return this.add( + (_, previous) => + autocomplete({ message: label, label, options, default: previous === undefined || previous === null ? defaultValue : String(previous), required, validate, hint, transform }), + name, + ); + } + + search(options: SearchPromptOptions, name?: string): this { + return this.add(() => search(options), name); + } + + multisearch(options: MultiSearchPromptOptions, name?: string): this { + return this.add(() => multisearch(options), name); + } + + spin(callback: () => MaybePromise, message = '', name?: string): this { + return this.add(() => spin(callback, { message }), name, true); + } + + task(label: string, callback: (logger: Logger) => MaybePromise, limit = 10, keepSummary = false, subLabel = '', name?: string): this { + return this.add(() => task(label, callback, limit, keepSummary, subLabel), name); + } + + pause(message = 'Press enter to continue', name?: string): this { + return this.add( + async () => { + await pause(message); + + return null; + }, + name, + true, + ); + } + + stream(source: AsyncIterable | Iterable, name?: string): this { + return this.add( + async () => { + await stream(source); + + return null; + }, + name, + true, + ); + } + + note(message: string, type: string | null = null, name?: string): this { + return this.add( + () => { + note(message, type); + + return null; + }, + name, + true, + ); + } + + error(message: string, name?: string): this { + return this.add( + () => { + error(message); + + return null; + }, + name, + true, + ); + } + + warning(message: string, name?: string): this { + return this.add( + () => { + warning(message); + + return null; + }, + name, + true, + ); + } + + alert(message: string, name?: string): this { + return this.add( + () => { + alert(message); + + return null; + }, + name, + true, + ); + } + + info(message: string, name?: string): this { + return this.add( + () => { + info(message); + + return null; + }, + name, + true, + ); + } + + intro(message: string, name?: string): this { + return this.add( + () => { + intro(message); + + return null; + }, + name, + true, + ); + } + + outro(message: string, name?: string): this { + return this.add( + () => { + outro(message); + + return null; + }, + name, + true, + ); + } + + table(headersOrOptions: TableOptions | string[] = [], rows: TableOptions['rows'] | null = null, name?: string): this { + return this.add( + () => { + table(headersOrOptions, rows); + + return null; + }, + name, + true, + ); + } + + datatable(options: DataTablePromptOptions, name?: string): this { + return this.add(() => datatable(options), name); + } + + progress(label: string, steps: Iterable | number, callback?: (step: T | number, bar: Progress) => MaybePromise, hint = '', name?: string): this { + return this.add(() => progress(label, steps, callback, hint), name, true); + } } diff --git a/packages/tui/src/form/types.ts b/packages/tui/src/form/types.ts index 71836f35..01e04ddb 100644 --- a/packages/tui/src/form/types.ts +++ b/packages/tui/src/form/types.ts @@ -3,8 +3,8 @@ import type { MaybePromise } from '#tui/types'; export type FormResponses = unknown[] & Record; export type FormStep = { - condition: boolean | ((responses: FormResponses) => boolean); - ignoreWhenReverting: boolean; - name?: string; - run: (responses: FormResponses, previous: unknown) => MaybePromise; + condition: boolean | ((responses: FormResponses) => boolean); + ignoreWhenReverting: boolean; + name?: string; + run: (responses: FormResponses, previous: unknown) => MaybePromise; }; diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index cd1e5872..8e3dc331 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -11,28 +11,28 @@ export { clearTerminal, cursorToStart, eraseLine, setTerminalTitle, terminalSize export { applyTypedKey, readTypedValue } from '#tui/typed-value'; export { parseAnsiText, truncate, visibleWidth, wrap } from '#tui/strings'; export type { - BasePromptOptions, - Choice, - ChoiceInput, - ConfirmPromptOptions, - DataTableObjectRow, - DataTablePromptOptions, - DataTableRow, - MaybePromise, - MultiSearchPromptOptions, - MultiSelectPromptOptions, - NumberPromptOptions, - PromptEnvironment, - PromptInfo, - PromptInput, - PromptOutput, - PromptValue, - SearchPromptOptions, - SelectPromptOptions, - StatusOptions, - TableCell, - TableOptions, - TextPromptOptions, - ValidationResult, - Validator + BasePromptOptions, + Choice, + ChoiceInput, + ConfirmPromptOptions, + DataTableObjectRow, + DataTablePromptOptions, + DataTableRow, + MaybePromise, + MultiSearchPromptOptions, + MultiSelectPromptOptions, + NumberPromptOptions, + PromptEnvironment, + PromptInfo, + PromptInput, + PromptOutput, + PromptValue, + SearchPromptOptions, + SelectPromptOptions, + StatusOptions, + TableCell, + TableOptions, + TextPromptOptions, + ValidationResult, + Validator, } from '#tui/types'; diff --git a/packages/tui/src/key.ts b/packages/tui/src/key.ts index 6897a6c8..caec13b2 100644 --- a/packages/tui/src/key.ts +++ b/packages/tui/src/key.ts @@ -1,36 +1,36 @@ export const Key = { - up: '\u001B[A', - shiftUp: '\u001B[1;2A', - pageUp: '\u001B[5~', - down: '\u001B[B', - shiftDown: '\u001B[1;2B', - pageDown: '\u001B[6~', - right: '\u001B[C', - left: '\u001B[D', - upArrow: '\u001BOA', - downArrow: '\u001BOB', - rightArrow: '\u001BOC', - leftArrow: '\u001BOD', - escape: '\u001B', - delete: '\u001B[3~', - backspace: '\u007F', - enter: '\n', - space: ' ', - tab: '\t', - shiftTab: '\u001B[Z', - home: ['\u001B[1~', '\u001BOH', '\u001B[H', '\u001B[7~'], - end: ['\u001B[4~', '\u001BOF', '\u001B[F', '\u001B[8~'], - ctrlC: '\u0003', - ctrlP: '\u0010', - ctrlN: '\u000E', - ctrlF: '\u0006', - ctrlB: '\u0002', - ctrlH: '\u0008', - ctrlA: '\u0001', - ctrlD: '\u0004', - ctrlE: '\u0005', - ctrlU: '\u0015', - optionBackspace: '\u001B\u007F' + up: '\u001B[A', + shiftUp: '\u001B[1;2A', + pageUp: '\u001B[5~', + down: '\u001B[B', + shiftDown: '\u001B[1;2B', + pageDown: '\u001B[6~', + right: '\u001B[C', + left: '\u001B[D', + upArrow: '\u001BOA', + downArrow: '\u001BOB', + rightArrow: '\u001BOC', + leftArrow: '\u001BOD', + escape: '\u001B', + delete: '\u001B[3~', + backspace: '\u007F', + enter: '\n', + space: ' ', + tab: '\t', + shiftTab: '\u001B[Z', + home: ['\u001B[1~', '\u001BOH', '\u001B[H', '\u001B[7~'], + end: ['\u001B[4~', '\u001BOF', '\u001B[F', '\u001B[8~'], + ctrlC: '\u0003', + ctrlP: '\u0010', + ctrlN: '\u000E', + ctrlF: '\u0006', + ctrlB: '\u0002', + ctrlH: '\u0008', + ctrlA: '\u0001', + ctrlD: '\u0004', + ctrlE: '\u0005', + ctrlU: '\u0015', + optionBackspace: '\u001B\u007F', } as const; export type KeyValue = string | readonly string[]; @@ -38,43 +38,43 @@ 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; + name?: string; + sequence?: string; + ctrl?: boolean; + meta?: boolean; + shift?: boolean; }; export const keyFromEvent = (event: KeyboardEventLike): KeyName | string => { - if (event.name && event.name in Key) { - const key = Key[event.name as keyof typeof Key]; + if (event.name && event.name in Key) { + const key = Key[event.name as keyof typeof Key]; - return typeof key === 'string' ? key : key[0]; - } + return typeof key === 'string' ? key : key[0]; + } - if (event.sequence === ' ') { - return Key.space; - } + if (event.sequence === ' ') { + return Key.space; + } - if (event.sequence === '\r' || event.sequence === '\n') { - return Key.enter; - } + if (event.sequence === '\r' || event.sequence === '\n') { + return Key.enter; + } - return event.name ?? event.sequence ?? ''; + return event.name ?? event.sequence ?? ''; }; export const oneOf = (keys: KeyValue[], match: string): string | undefined => { - for (const key of keys) { - if (Array.isArray(key)) { - const nested = oneOf([...key], match); + for (const key of keys) { + if (Array.isArray(key)) { + const nested = oneOf([...key], match); - if (nested !== undefined) { - return nested; - } - } else if (key === match) { - return match; - } - } + if (nested !== undefined) { + return nested; + } + } else if (key === match) { + return match; + } + } - return undefined; + return undefined; }; diff --git a/packages/tui/src/opentui.ts b/packages/tui/src/opentui.ts index a1541a7a..1aee21b9 100644 --- a/packages/tui/src/opentui.ts +++ b/packages/tui/src/opentui.ts @@ -2,21 +2,22 @@ import { Text } from '@opentui/core'; import { createTestRenderer } from '@opentui/core/testing'; export type OpenTuiFrame = { - text: string; + text: string; }; export const renderOpenTuiTextFrame = async (text: string, size: { width?: number; height?: number } = {}): Promise => { - const { renderer, renderOnce, captureCharFrame } = await createTestRenderer({ - width: size.width ?? 80, - height: size.height ?? 24 - }); + const { renderer, renderOnce, captureCharFrame } = await createTestRenderer({ + width: size.width ?? 80, + height: size.height ?? 24, + }); - try { - renderer.root.add(Text({ content: text })); - await renderOnce(); + try { + renderer.root.add(Text({ content: text })); - return { text: captureCharFrame() }; - } finally { - renderer.destroy(); - } + await renderOnce(); + + return { text: captureCharFrame() }; + } finally { + renderer.destroy(); + } }; diff --git a/packages/tui/src/output/data-table.ts b/packages/tui/src/output/data-table.ts index aac41942..8e204874 100644 --- a/packages/tui/src/output/data-table.ts +++ b/packages/tui/src/output/data-table.ts @@ -5,158 +5,160 @@ import { renderTable } from '#tui/theme'; import type { DataTableObjectRow, DataTablePromptOptions, DataTableRow, TableCell } from '#tui/types'; type VisibleRow = { - index: number; - row: DataTableRow; + index: number; + row: DataTableRow; }; const stringify = (value: TableCell): string => { - return value === null || value === undefined ? '' : String(value); + return value === null || value === undefined ? '' : String(value); }; const isDataObjectRow = (row: DataTableRow): row is DataTableObjectRow => { - return !Array.isArray(row) && 'cells' in row && typeof row.cells === 'object' && row.cells !== null; + return !Array.isArray(row) && 'cells' in row && typeof row.cells === 'object' && row.cells !== null; }; const rowFields = (row: DataTableRow): Record => { - if (Array.isArray(row)) { - return Object.fromEntries(row.map((value, index) => [String(index), value])); - } + if (Array.isArray(row)) { + return Object.fromEntries(row.map((value, index) => [String(index), value])); + } - if (isDataObjectRow(row)) { - return row.cells; - } + if (isDataObjectRow(row)) { + return row.cells; + } - return row; + return row; }; const derivedHeaders = (rows: Array>): string[] => { - const first = rows[0]; + const first = rows[0]; - if (!first) { - return []; - } + if (!first) { + return []; + } - if (Array.isArray(first)) { - return first.map((_, index) => String(index + 1)); - } + if (Array.isArray(first)) { + return first.map((_, index) => String(index + 1)); + } - return Object.keys(rowFields(first)).filter((key) => key !== 'value'); + return Object.keys(rowFields(first)).filter((key) => key !== 'value'); }; const rowCells = (headers: string[], row: DataTableRow): string[] => { - const fields = rowFields(row); + const fields = rowFields(row); - return headers.map((header) => stringify(fields[header])); + return headers.map((header) => stringify(fields[header])); }; const rowValue = (row: DataTableRow, index: number): T | number => { - if (isDataObjectRow(row) && row.value !== undefined) { - return row.value; - } + if (isDataObjectRow(row) && row.value !== undefined) { + return row.value; + } - if (!Array.isArray(row) && 'value' in row && row.value !== undefined) { - return row.value as T; - } + if (!Array.isArray(row) && 'value' in row && row.value !== undefined) { + return row.value as T; + } - return index; + return index; }; const rowLabel = (headers: string[], row: DataTableRow): string => rowCells(headers, row).join(' '); const clampSelected = (selected: number, rows: Array>): number => { - if (rows.length === 0) { - return 0; - } + if (rows.length === 0) { + return 0; + } - return Math.min(selected, rows.length - 1); + return Math.min(selected, rows.length - 1); }; const isPrintable = (key: string): boolean => { - return [...key].every((character) => (character.codePointAt(0) ?? 0) >= 32); + return [...key].every((character) => (character.codePointAt(0) ?? 0) >= 32); }; export const datatable = async (options: DataTablePromptOptions): Promise => { - const headers = options.headers ?? derivedHeaders(options.rows); - - return promptUntilValid(options, async () => { - const environment = promptEnvironment(); - let selected = 0; - let query = ''; - - const visibleRows = (): Array> => { - return options.rows - .map((row, index) => ({ index, row })) - .filter(({ row }) => options.filter?.(query, row) ?? rowLabel(headers, row).toLowerCase().includes(query.toLowerCase())); - }; - - const render = (): void => { - const rows = visibleRows(); - selected = clampSelected(selected, rows); - const renderedRows = rows.map(({ row }, index) => { - return [index === selected ? '›' : ' ', ...rowCells(headers, row)]; - }); - const querySuffix = query.length > 0 ? ` ${query}` : ''; - - environment.output.write(`${options.message}${querySuffix}\n`); - environment.output.write(`${renderTable(['', ...headers], renderedRows)}\n`); - }; - - if (!environment.input.readKey) { - const rows = visibleRows(); - const selectedRow = rows[0]; - - if (!selectedRow) { - throw new PromptValidationError('Please select a valid row.'); - } - - return rowValue(selectedRow.row, selectedRow.index); - } - - render(); - - while (true) { - const key = await environment.input.readKey(); - - if (key === null) { - throw new PromptValidationError('Please select a valid row.'); - } - - const rows = visibleRows(); - - if (key === Key.down || key === Key.downArrow || key === Key.ctrlN) { - selected = rows.length === 0 ? 0 : (selected + 1) % rows.length; - render(); - continue; - } - - if (key === Key.up || key === Key.upArrow || key === Key.ctrlP) { - selected = rows.length === 0 ? 0 : (selected - 1 + rows.length) % rows.length; - render(); - continue; - } - - if (key === Key.backspace || key === Key.ctrlH) { - query = query.slice(0, -1); - selected = 0; - render(); - continue; - } - - if (key === Key.enter) { - const selectedRow = rows[selected]; - - if (!selectedRow) { - throw new PromptValidationError('Please select a valid row.'); - } - - return rowValue(selectedRow.row, selectedRow.index); - } - - if (isPrintable(key)) { - query += key; - selected = 0; - render(); - } - } - }); + const headers = options.headers ?? derivedHeaders(options.rows); + + return promptUntilValid(options, async () => { + const environment = promptEnvironment(); + + let selected = 0; + let query = ''; + + const visibleRows = (): Array> => { + return options.rows.map((row, index) => ({ index, row })).filter(({ row }) => options.filter?.(query, row) ?? rowLabel(headers, row).toLowerCase().includes(query.toLowerCase())); + }; + + const render = (): void => { + const rows = visibleRows(); + + selected = clampSelected(selected, rows); + + const renderedRows = rows.map(({ row }, index) => { + return [index === selected ? '›' : ' ', ...rowCells(headers, row)]; + }); + + const querySuffix = query.length > 0 ? ` ${query}` : ''; + + environment.output.write(`${options.message}${querySuffix}\n`); + environment.output.write(`${renderTable(['', ...headers], renderedRows)}\n`); + }; + + if (!environment.input.readKey) { + const rows = visibleRows(); + const selectedRow = rows[0]; + + if (!selectedRow) { + throw new PromptValidationError('Please select a valid row.'); + } + + return rowValue(selectedRow.row, selectedRow.index); + } + + render(); + + while (true) { + const key = await environment.input.readKey(); + + if (key === null) { + throw new PromptValidationError('Please select a valid row.'); + } + + const rows = visibleRows(); + + if (key === Key.down || key === Key.downArrow || key === Key.ctrlN) { + selected = rows.length === 0 ? 0 : (selected + 1) % rows.length; + render(); + continue; + } + + if (key === Key.up || key === Key.upArrow || key === Key.ctrlP) { + selected = rows.length === 0 ? 0 : (selected - 1 + rows.length) % rows.length; + render(); + continue; + } + + if (key === Key.backspace || key === Key.ctrlH) { + query = query.slice(0, -1); + selected = 0; + render(); + continue; + } + + if (key === Key.enter) { + const selectedRow = rows[selected]; + + if (!selectedRow) { + throw new PromptValidationError('Please select a valid row.'); + } + + return rowValue(selectedRow.row, selectedRow.index); + } + + if (isPrintable(key)) { + query += key; + selected = 0; + render(); + } + } + }); }; diff --git a/packages/tui/src/output/notes.ts b/packages/tui/src/output/notes.ts index 5e855b78..82bda5e0 100644 --- a/packages/tui/src/output/notes.ts +++ b/packages/tui/src/output/notes.ts @@ -4,26 +4,32 @@ import { symbols } from '#tui/theme'; export type NoteType = 'alert' | 'error' | 'info' | 'intro' | 'outro' | 'warning' | string; const notePrefix = (type?: NoteType | null): string => { - switch (type) { - case 'error': - return symbols.error; - case 'warning': - case 'alert': - return symbols.warning; - case 'info': - return symbols.info; - case 'intro': - case 'outro': - return symbols.success; - default: - return symbols.info; - } + switch (type) { + case 'error': + return symbols.error; + + case 'warning': + + case 'alert': + return symbols.warning; + + case 'info': + return symbols.info; + + case 'intro': + + case 'outro': + return symbols.success; + + default: + return symbols.info; + } }; export const note = (message: string, type?: NoteType | null): void => { - const prefix = notePrefix(type); + const prefix = notePrefix(type); - promptEnvironment().output.write(`${prefix} ${message}\n`); + promptEnvironment().output.write(`${prefix} ${message}\n`); }; export const error = (message: string): void => note(message, 'error'); diff --git a/packages/tui/src/output/notify.ts b/packages/tui/src/output/notify.ts index 2b3e3594..64068ba5 100644 --- a/packages/tui/src/output/notify.ts +++ b/packages/tui/src/output/notify.ts @@ -3,28 +3,30 @@ import { platform } from 'node:process'; import { note } from '#tui/output/notes'; export const notify = (title: string, body = '', subtitle = '', sound = '', icon = ''): void => { - if (platform === 'darwin') { - const script = [ - 'display notification', - JSON.stringify(body), - 'with title', - JSON.stringify(title), - subtitle ? `subtitle ${JSON.stringify(subtitle)}` : '', - sound ? `sound name ${JSON.stringify(sound)}` : '' - ].filter(Boolean).join(' '); + if (platform === 'darwin') { + const script = [ + 'display notification', + JSON.stringify(body), + 'with title', + JSON.stringify(title), + subtitle ? `subtitle ${JSON.stringify(subtitle)}` : '', + sound ? `sound name ${JSON.stringify(sound)}` : '', + ] + .filter(Boolean) + .join(' '); - spawn('osascript', ['-e', script], { detached: true, stdio: 'ignore' }).unref(); + spawn('osascript', ['-e', script], { detached: true, stdio: 'ignore' }).unref(); - return; - } + return; + } - if (platform === 'linux') { - const args = icon ? ['--icon', icon, title, body] : [title, body]; + if (platform === 'linux') { + const args = icon ? ['--icon', icon, title, body] : [title, body]; - spawn('notify-send', args, { detached: true, stdio: 'ignore' }).unref(); + spawn('notify-send', args, { detached: true, stdio: 'ignore' }).unref(); - return; - } + return; + } - note(body ? `${title}: ${body}` : title, 'info'); + note(body ? `${title}: ${body}` : title, 'info'); }; diff --git a/packages/tui/src/output/table.ts b/packages/tui/src/output/table.ts index c61517f6..97f4a9a1 100644 --- a/packages/tui/src/output/table.ts +++ b/packages/tui/src/output/table.ts @@ -3,45 +3,46 @@ import { renderTable } from '#tui/theme'; import type { TableOptions } from '#tui/types'; const stringify = (value: string | number | boolean | null | undefined): string => { - return value === null || value === undefined ? '' : String(value); + return value === null || value === undefined ? '' : String(value); }; const normalizeTableOptions = (headersOrOptions: TableOptions | string[] = [], rows: TableOptions['rows'] | null = null): Required => { - if (!Array.isArray(headersOrOptions) && 'rows' in headersOrOptions) { - const headers = headersOrOptions.headers ?? Object.keys(headersOrOptions.rows[0] ?? {}); + if (!Array.isArray(headersOrOptions) && 'rows' in headersOrOptions) { + const headers = headersOrOptions.headers ?? Object.keys(headersOrOptions.rows[0] ?? {}); - return { headers, rows: headersOrOptions.rows }; - } + return { headers, rows: headersOrOptions.rows }; + } - return { - headers: headersOrOptions, - rows: rows ?? [] - }; + return { + headers: headersOrOptions, + rows: rows ?? [], + }; }; export const table = (headersOrOptions: TableOptions | string[] = [], rows: TableOptions['rows'] | null = null): void => { - const options = normalizeTableOptions(headersOrOptions, rows); - const normalizedRows = options.rows.map((row) => { - if (Array.isArray(row)) { - return row.map(stringify); - } + const options = normalizeTableOptions(headersOrOptions, rows); - return options.headers.map((header) => stringify(row[header])); - }); + const normalizedRows = options.rows.map((row) => { + if (Array.isArray(row)) { + return row.map(stringify); + } - promptEnvironment().output.write(`${renderTable(options.headers, normalizedRows)}\n`); + return options.headers.map((header) => stringify(row[header])); + }); + + promptEnvironment().output.write(`${renderTable(options.headers, normalizedRows)}\n`); }; export const grid = (items: Array = [], maxWidth?: number): void => { - const width = maxWidth ?? 4; - const rows: string[][] = []; + const width = maxWidth ?? 4; + const rows: string[][] = []; - for (let index = 0; index < items.length; index += width) { - rows.push(items.slice(index, index + width).map(String)); - } + for (let index = 0; index < items.length; index += width) { + rows.push(items.slice(index, index + width).map(String)); + } - promptEnvironment().output.write(rows.map((row) => row.join(' ')).join('\n')); - promptEnvironment().output.write(items.length > 0 ? '\n' : ''); + promptEnvironment().output.write(rows.map((row) => row.join(' ')).join('\n')); + promptEnvironment().output.write(items.length > 0 ? '\n' : ''); }; export const dataTable = table; diff --git a/packages/tui/src/output/terminal.ts b/packages/tui/src/output/terminal.ts index 4a0efebd..4c1444d5 100644 --- a/packages/tui/src/output/terminal.ts +++ b/packages/tui/src/output/terminal.ts @@ -1,9 +1,9 @@ import { clearTerminal, setTerminalTitle } from '#tui/terminal'; export const title = (value: string): void => { - setTerminalTitle(value); + setTerminalTitle(value); }; export const clear = (): void => { - clearTerminal(); + clearTerminal(); }; diff --git a/packages/tui/src/prompt.ts b/packages/tui/src/prompt.ts index a2261ddd..0a9d259f 100644 --- a/packages/tui/src/prompt.ts +++ b/packages/tui/src/prompt.ts @@ -3,97 +3,96 @@ import { renderError, renderQuestion } from '#tui/theme'; import type { BasePromptOptions, Validator } from '#tui/types'; export class PromptValidationError extends Error { - constructor(message: string) { - super(message); - this.name = 'PromptValidationError'; - } + constructor(message: string) { + super(message); + this.name = 'PromptValidationError'; + } } export const validationMessage = async (value: T, validator?: Validator): Promise => { - const result = await validator?.(value); + const result = await validator?.(value); - if (typeof result === 'string') { - return result; - } + if (typeof result === 'string') { + return result; + } - if (result === false) { - return 'The given value is invalid.'; - } + if (result === false) { + return 'The given value is invalid.'; + } - return undefined; + return undefined; }; export const ensureRequired = (value: T, required?: boolean | string): string | undefined => { - const empty = value === undefined || value === null || value === '' || (Array.isArray(value) && value.length === 0); + const empty = value === undefined || value === null || value === '' || (Array.isArray(value) && value.length === 0); - if (!empty || !required) { - return undefined; - } + if (!empty || !required) { + return undefined; + } - return typeof required === 'string' ? required : 'A value is required.'; + return typeof required === 'string' ? required : 'A value is required.'; }; -export const promptUntilValid = async ( - options: BasePromptOptions, - read: (attempt: number) => Promise -): Promise => { - const environment = promptEnvironment(); +export const promptUntilValid = async (options: BasePromptOptions, read: (attempt: number) => Promise): Promise => { + const environment = promptEnvironment(); - if (!environment.interactive) { - const value = options.default as T; - const required = ensureRequired(value, options.required); - const validation = required ?? (await validationMessage(value, options.validate)); + if (!environment.interactive) { + const value = options.default as T; + const required = ensureRequired(value, options.required); - if (validation) { - throw new PromptValidationError(validation); - } + const validation = required ?? (await validationMessage(value, options.validate)); - return value; - } + if (validation) { + throw new PromptValidationError(validation); + } - let attempt = 0; + return value; + } - while (true) { - let value: T; + let attempt = 0; - try { - value = await read(attempt); - } catch (error) { - if (error instanceof PromptValidationError) { - if (!environment.interactive) { - throw error; - } + while (true) { + let value: T; - environment.error.write(renderError(error.message)); - attempt += 1; - continue; - } + try { + value = await read(attempt); + } catch (error) { + if (error instanceof PromptValidationError) { + if (!environment.interactive) { + throw error; + } - throw error; - } + environment.error.write(renderError(error.message)); + attempt += 1; + continue; + } - const required = ensureRequired(value, options.required); - const validation = required ?? (await validationMessage(value, options.validate)); + throw error; + } - if (!validation) { - return value; - } + const required = ensureRequired(value, options.required); - if (!environment.interactive) { - throw new PromptValidationError(validation); - } + const validation = required ?? (await validationMessage(value, options.validate)); - environment.error.write(renderError(validation)); - attempt += 1; - } + if (!validation) { + return value; + } + + if (!environment.interactive) { + throw new PromptValidationError(validation); + } + + environment.error.write(renderError(validation)); + attempt += 1; + } }; export const ask = async (message: string, hint?: string): Promise => { - const environment = promptEnvironment(); + const environment = promptEnvironment(); - if (!environment.input.readLine) { - throw new PromptValidationError('The configured prompt input cannot read lines.'); - } + if (!environment.input.readLine) { + throw new PromptValidationError('The configured prompt input cannot read lines.'); + } - return environment.input.readLine(renderQuestion(message, hint)); + return environment.input.readLine(renderQuestion(message, hint)); }; diff --git a/packages/tui/src/prompts/number.ts b/packages/tui/src/prompts/number.ts index e508550d..d4c6519c 100644 --- a/packages/tui/src/prompts/number.ts +++ b/packages/tui/src/prompts/number.ts @@ -6,59 +6,60 @@ const integerPattern = /^[+-]?\d+$/u; const numberPattern = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?$/iu; export function number(options: NumberPromptOptions): Promise; + export function number( - label: string, - placeholder?: string, - defaultValue?: number, - required?: boolean | string, - validate?: NumberPromptOptions['validate'], - hint?: string, - min?: number, - max?: number, - step?: number + label: string, + placeholder?: string, + defaultValue?: number, + required?: boolean | string, + validate?: NumberPromptOptions['validate'], + hint?: string, + min?: number, + max?: number, + step?: number, ): Promise; + export async function number( - message: string | NumberPromptOptions, - _placeholder = '', - defaultValue = 0, - required: boolean | string = false, - validate: NumberPromptOptions['validate'] = undefined, - hint = '', - min: number | undefined = undefined, - max: number | undefined = undefined, - step: number | undefined = undefined + message: string | NumberPromptOptions, + _placeholder = '', + defaultValue = 0, + required: boolean | string = false, + validate: NumberPromptOptions['validate'] = undefined, + hint = '', + min: number | undefined = undefined, + max: number | undefined = undefined, + step: number | undefined = undefined, ): Promise { - const options: NumberPromptOptions = typeof message === 'string' - ? { message, label: message, default: defaultValue, required, validate, hint, min, max, step } - : { ...message, default: message.default ?? defaultValue }; + const options: NumberPromptOptions = + typeof message === 'string' ? { message, label: message, default: defaultValue, required, validate, hint, min, max, step } : { ...message, default: message.default ?? defaultValue }; - return promptUntilValid(options, async () => { - const answer = await readTypedValue(options.message, { - default: options.default === undefined ? undefined : String(options.default), - hint: options.hint - }); + return promptUntilValid(options, async () => { + const answer = await readTypedValue(options.message, { + default: options.default === undefined ? undefined : String(options.default), + hint: options.hint, + }); - if (answer === '' && options.default !== undefined) { - return options.default; - } + if (answer === '' && options.default !== undefined) { + return options.default; + } - const normalized = answer.trim(); - const valid = options.integer ? integerPattern.test(normalized) : numberPattern.test(normalized); + const normalized = answer.trim(); + const valid = options.integer ? integerPattern.test(normalized) : numberPattern.test(normalized); - if (!valid) { - throw new PromptValidationError('Please enter a valid number.'); - } + if (!valid) { + throw new PromptValidationError('Please enter a valid number.'); + } - const parsed = options.integer ? Number.parseInt(normalized, 10) : Number(normalized); + const parsed = options.integer ? Number.parseInt(normalized, 10) : Number(normalized); - if (options.min !== undefined && parsed < options.min) { - throw new PromptValidationError(`Please enter a value greater than or equal to ${options.min}.`); - } + if (options.min !== undefined && parsed < options.min) { + throw new PromptValidationError(`Please enter a value greater than or equal to ${options.min}.`); + } - if (options.max !== undefined && parsed > options.max) { - throw new PromptValidationError(`Please enter a value less than or equal to ${options.max}.`); - } + if (options.max !== undefined && parsed > options.max) { + throw new PromptValidationError(`Please enter a value less than or equal to ${options.max}.`); + } - return options.transform ? options.transform(parsed) : parsed; - }); + return options.transform ? options.transform(parsed) : parsed; + }); } diff --git a/packages/tui/src/prompts/password.ts b/packages/tui/src/prompts/password.ts index a0a42e37..8f139466 100644 --- a/packages/tui/src/prompts/password.ts +++ b/packages/tui/src/prompts/password.ts @@ -3,25 +3,25 @@ import { text } from '#tui/prompts/text'; 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'] + 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 + message: string | TextPromptOptions, + placeholder = '', + required: boolean | string = false, + validate: TextPromptOptions['validate'] = undefined, + hint = '', + transform: TextPromptOptions['transform'] = undefined, ): Promise { - const options = typeof message === 'string' - ? textOptions({ message, label: message, placeholder, required, validate, hint, transform }) - : textOptions(message); + const options = typeof message === 'string' ? textOptions({ message, label: message, placeholder, required, validate, hint, transform }) : textOptions(message); - return text(options); + return text(options); } diff --git a/packages/tui/src/prompts/pause.ts b/packages/tui/src/prompts/pause.ts index 59beb76d..bc3aa9b1 100644 --- a/packages/tui/src/prompts/pause.ts +++ b/packages/tui/src/prompts/pause.ts @@ -1,5 +1,5 @@ import { ask } from '#tui/prompt'; export const pause = async (message = 'Press enter to continue'): Promise => { - await ask(message); + await ask(message); }; diff --git a/packages/tui/src/prompts/search.ts b/packages/tui/src/prompts/search.ts index b20b67db..721f8b25 100644 --- a/packages/tui/src/prompts/search.ts +++ b/packages/tui/src/prompts/search.ts @@ -7,213 +7,232 @@ import { resolveInfo } from '#tui/concerns/info'; import type { Choice, MultiSearchPromptOptions, SearchPromptOptions } from '#tui/types'; const resolveSearchChoices = async (source: SearchPromptOptions['options'], query: string): Promise>> => { - const options = typeof source === 'function' ? await source(query) : source; + const options = typeof source === 'function' ? await source(query) : source; - return normalizeSearchChoices(options); + return normalizeSearchChoices(options); }; const searchMessage = (message: string, query: string): string => { - return query.length > 0 ? `${message} ${query}` : message; + return query.length > 0 ? `${message} ${query}` : message; }; const renderSearchChoices = ( - message: string, - query: string, - choices: Array>, - highlighted: number | null, - marked: Set = new Set(), - selectedLabels: string[] = [], - scroll?: number, - info?: SearchPromptOptions['info'] | MultiSearchPromptOptions['info'] + message: string, + query: string, + choices: Array>, + highlighted: number | null, + marked: Set = new Set(), + selectedLabels: string[] = [], + scroll?: number, + info?: SearchPromptOptions['info'] | MultiSearchPromptOptions['info'], ): void => { - renderInteractiveChoices(searchMessage(message, query), choices, highlighted ?? 0, marked, scroll); + renderInteractiveChoices(searchMessage(message, query), choices, highlighted ?? 0, marked, scroll); - const text = resolveInfo(info, highlighted === null ? null : choices[highlighted]?.value ?? null); + const text = resolveInfo(info, highlighted === null ? null : (choices[highlighted]?.value ?? null)); - if (text.length > 0) { - promptEnvironment().output.write(`${text}\n`); - } + if (text.length > 0) { + promptEnvironment().output.write(`${text}\n`); + } - if (selectedLabels.length > 0) { - promptEnvironment().output.write(`Selected: ${selectedLabels.join(', ')}\n`); - } + if (selectedLabels.length > 0) { + promptEnvironment().output.write(`Selected: ${selectedLabels.join(', ')}\n`); + } }; const readSearchChoice = async (options: SearchPromptOptions, attempt = 0): Promise => { - const environment = promptEnvironment(); - - if (!environment.input.readKey) { - const query = await ask(options.message, options.hint); - const choices = await resolveSearchChoices(options.options, query); - const choice = findChoice(choices, query) ?? choices.find((candidate) => !candidate.disabled); - - return choice?.value ?? options.default ?? choices[0]?.value; - } - - let state = { cursor: 0, value: '' }; - let choices = await resolveSearchChoices(options.options, state.value); - let highlighted: number | null = null; - - renderSearchChoices(options.message, state.value, choices, highlighted, new Set(), [], options.scroll, options.info); - - while (true) { - const key = await environment.input.readKey(); - - if (key === null) { - return options.default; - } - - if (key === Key.down || key === Key.downArrow || key === Key.ctrlN || key === Key.tab) { - choices = await resolveSearchChoices(options.options, state.value); - highlighted = choices.length === 0 ? null : ((highlighted ?? (attempt > 0 ? 0 : -1)) + 1) % choices.length; - renderSearchChoices(options.message, state.value, choices, highlighted, new Set(), [], options.scroll, options.info); - continue; - } - - if (key === Key.up || key === Key.upArrow || key === Key.ctrlP || key === Key.shiftTab) { - choices = await resolveSearchChoices(options.options, state.value); - highlighted = choices.length === 0 ? null : ((highlighted ?? choices.length) - 1 + choices.length) % choices.length; - renderSearchChoices(options.message, state.value, choices, highlighted, new Set(), [], options.scroll, options.info); - continue; - } - - if (oneOf([Key.home, Key.ctrlA], key) && highlighted !== null) { - highlighted = 0; - renderSearchChoices(options.message, state.value, choices, highlighted, new Set(), [], options.scroll, options.info); - continue; - } - - if (oneOf([Key.end, Key.ctrlE], key) && highlighted !== null) { - highlighted = Math.max(0, choices.length - 1); - renderSearchChoices(options.message, state.value, choices, highlighted, new Set(), [], options.scroll, options.info); - continue; - } - - if (key === Key.enter) { - if (highlighted !== null) { - return choices[highlighted]?.value; - } - - choices = await resolveSearchChoices(options.options, state.value); - highlighted = choices.length > 0 ? 0 : null; - renderSearchChoices(options.message, state.value, choices, highlighted, new Set(), [], options.scroll, options.info); - continue; - } - - const next = applyTypedKey(state, key); - - if (next.cancelled) { - environment.error.write('Cancelled.\n'); - - return options.default; - } - - state = { cursor: next.cursor, value: next.value }; - highlighted = null; - choices = await resolveSearchChoices(options.options, state.value); - renderSearchChoices(options.message, state.value, choices, highlighted, new Set(), [], options.scroll, options.info); - } + const environment = promptEnvironment(); + + if (!environment.input.readKey) { + const query = await ask(options.message, options.hint); + + const choices = await resolveSearchChoices(options.options, query); + + const choice = findChoice(choices, query) ?? choices.find((candidate) => !candidate.disabled); + + return choice?.value ?? options.default ?? choices[0]?.value; + } + + let state = { cursor: 0, value: '' }; + + let choices = await resolveSearchChoices(options.options, state.value); + + let highlighted: number | null = null; + + renderSearchChoices(options.message, state.value, choices, highlighted, new Set(), [], options.scroll, options.info); + + while (true) { + const key = await environment.input.readKey(); + + if (key === null) { + return options.default; + } + + if (key === Key.down || key === Key.downArrow || key === Key.ctrlN || key === Key.tab) { + choices = await resolveSearchChoices(options.options, state.value); + + highlighted = choices.length === 0 ? null : ((highlighted ?? (attempt > 0 ? 0 : -1)) + 1) % choices.length; + renderSearchChoices(options.message, state.value, choices, highlighted, new Set(), [], options.scroll, options.info); + continue; + } + + if (key === Key.up || key === Key.upArrow || key === Key.ctrlP || key === Key.shiftTab) { + choices = await resolveSearchChoices(options.options, state.value); + + highlighted = choices.length === 0 ? null : ((highlighted ?? choices.length) - 1 + choices.length) % choices.length; + renderSearchChoices(options.message, state.value, choices, highlighted, new Set(), [], options.scroll, options.info); + continue; + } + + if (oneOf([Key.home, Key.ctrlA], key) && highlighted !== null) { + highlighted = 0; + renderSearchChoices(options.message, state.value, choices, highlighted, new Set(), [], options.scroll, options.info); + continue; + } + + if (oneOf([Key.end, Key.ctrlE], key) && highlighted !== null) { + highlighted = Math.max(0, choices.length - 1); + renderSearchChoices(options.message, state.value, choices, highlighted, new Set(), [], options.scroll, options.info); + continue; + } + + if (key === Key.enter) { + if (highlighted !== null) { + return choices[highlighted]?.value; + } + + choices = await resolveSearchChoices(options.options, state.value); + + highlighted = choices.length > 0 ? 0 : null; + renderSearchChoices(options.message, state.value, choices, highlighted, new Set(), [], options.scroll, options.info); + continue; + } + + const next = applyTypedKey(state, key); + + if (next.cancelled) { + environment.error.write('Cancelled.\n'); + + return options.default; + } + + state = { cursor: next.cursor, value: next.value }; + highlighted = null; + + choices = await resolveSearchChoices(options.options, state.value); + + renderSearchChoices(options.message, state.value, choices, highlighted, new Set(), [], options.scroll, options.info); + } }; export const search = async (options: SearchPromptOptions): Promise => { - return promptUntilValid(options, async (attempt) => { - const selected = await readSearchChoice(options, attempt); + return promptUntilValid(options, async (attempt) => { + const selected = await readSearchChoice(options, attempt); - if (selected === undefined) { - throw new PromptValidationError('Please select a valid option.'); - } + if (selected === undefined) { + throw new PromptValidationError('Please select a valid option.'); + } - return selected; - }); + return selected; + }); }; const readMultiSearchChoices = async (options: MultiSearchPromptOptions): Promise => { - const environment = promptEnvironment(); - - if (!environment.input.readKey) { - const query = await ask(options.message, options.hint); - const choices = await resolveSearchChoices(options.options, query); - - if (query.trim() === '' && options.default !== undefined) { - return options.default; - } - - const parts = query.split(',').map((part) => part.trim()).filter((part) => part.length > 0); - 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); - } - - let state = { cursor: 0, value: '' }; - let choices = await resolveSearchChoices(options.options, state.value); - let highlighted: number | null = null; - const selected = new Map(); - - const render = (): void => { - const marked = new Set(choices.flatMap((choice, index) => selected.has(choice.value) ? [index] : [])); - - renderSearchChoices(options.message, state.value, choices, highlighted, marked, [...selected.values()], options.scroll, options.info); - }; - - render(); - - while (true) { - const key = await environment.input.readKey(); - - if (key === null || key === Key.enter) { - return [...selected.keys()]; - } - - if (key === Key.down || key === Key.downArrow || key === Key.ctrlN || key === Key.tab) { - choices = await resolveSearchChoices(options.options, state.value); - highlighted = choices.length === 0 ? null : ((highlighted ?? -1) + 1) % choices.length; - render(); - continue; - } - - if (key === Key.up || key === Key.upArrow || key === Key.ctrlP || key === Key.shiftTab) { - choices = await resolveSearchChoices(options.options, state.value); - highlighted = choices.length === 0 ? null : ((highlighted ?? choices.length) - 1 + choices.length) % choices.length; - render(); - continue; - } - - if (key === Key.space && highlighted !== null) { - const choice = choices[highlighted]; - - if (choice) { - if (selected.has(choice.value as T)) { - selected.delete(choice.value as T); - } else { - selected.set(choice.value as T, choice.label); - } - } - - render(); - continue; - } - - const next = applyTypedKey(state, key); - - if (next.cancelled) { - environment.error.write('Cancelled.\n'); - - return options.default ?? []; - } - - state = { cursor: next.cursor, value: next.value }; - choices = await resolveSearchChoices(options.options, state.value); - highlighted = null; - render(); - } + const environment = promptEnvironment(); + + if (!environment.input.readKey) { + const query = await ask(options.message, options.hint); + + const choices = await resolveSearchChoices(options.options, query); + + if (query.trim() === '' && options.default !== undefined) { + return options.default; + } + + const parts = query + .split(',') + .map((part) => part.trim()) + .filter((part) => part.length > 0); + + 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); + } + + let state = { cursor: 0, value: '' }; + + let choices = await resolveSearchChoices(options.options, state.value); + + let highlighted: number | null = null; + + const selected = new Map(); + + const render = (): void => { + const marked = new Set(choices.flatMap((choice, index) => (selected.has(choice.value) ? [index] : []))); + + renderSearchChoices(options.message, state.value, choices, highlighted, marked, [...selected.values()], options.scroll, options.info); + }; + + render(); + + while (true) { + const key = await environment.input.readKey(); + + if (key === null || key === Key.enter) { + return [...selected.keys()]; + } + + if (key === Key.down || key === Key.downArrow || key === Key.ctrlN || key === Key.tab) { + choices = await resolveSearchChoices(options.options, state.value); + + highlighted = choices.length === 0 ? null : ((highlighted ?? -1) + 1) % choices.length; + render(); + continue; + } + + if (key === Key.up || key === Key.upArrow || key === Key.ctrlP || key === Key.shiftTab) { + choices = await resolveSearchChoices(options.options, state.value); + + highlighted = choices.length === 0 ? null : ((highlighted ?? choices.length) - 1 + choices.length) % choices.length; + render(); + continue; + } + + if (key === Key.space && highlighted !== null) { + const choice = choices[highlighted]; + + if (choice) { + if (selected.has(choice.value as T)) { + selected.delete(choice.value as T); + } else { + selected.set(choice.value as T, choice.label); + } + } + + render(); + continue; + } + + const next = applyTypedKey(state, key); + + if (next.cancelled) { + environment.error.write('Cancelled.\n'); + + return options.default ?? []; + } + + state = { cursor: next.cursor, value: next.value }; + + choices = await resolveSearchChoices(options.options, state.value); + + highlighted = null; + render(); + } }; export const multisearch = async (options: MultiSearchPromptOptions): Promise => { - return promptUntilValid(options, async () => readMultiSearchChoices(options)); + return promptUntilValid(options, async () => readMultiSearchChoices(options)); }; diff --git a/packages/tui/src/prompts/select.ts b/packages/tui/src/prompts/select.ts index f9e820e5..6f6bd92e 100644 --- a/packages/tui/src/prompts/select.ts +++ b/packages/tui/src/prompts/select.ts @@ -9,298 +9,284 @@ import type { Choice, ConfirmPromptOptions, MultiSelectPromptOptions, SelectProm const parseChoiceIndex = (key: string): number => (/^\d+$/u.test(key) ? Number.parseInt(key, 10) : Number.NaN); 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'; + 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'; }; 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'; + 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'; }; const renderSelectedChoice = (message: string, choices: Array>, selected: number, scroll: number | undefined, info: SelectPromptOptions['info']): void => { - renderInteractiveChoices(message, choices, selected, new Set(), scroll); + renderInteractiveChoices(message, choices, selected, new Set(), scroll); - const text = resolveInfo(info, choices[selected]?.value ?? null); + const text = resolveInfo(info, choices[selected]?.value ?? null); - if (text.length > 0) { - promptEnvironment().output.write(`${text}\n`); - } + if (text.length > 0) { + promptEnvironment().output.write(`${text}\n`); + } }; const readSelectedChoice = async (message: string, choices: Array>, hint?: string, scroll?: number, info?: SelectPromptOptions['info']): Promise => { - const environment = promptEnvironment(); + const environment = promptEnvironment(); - if (!environment.input.readKey) { - const rendered = renderChoices(choices); - const answer = await ask(`${message}\n${rendered}\n`, hint); - const choice = findChoice(choices, answer); + if (!environment.input.readKey) { + const rendered = renderChoices(choices); - if (!choice || choice.disabled) { - throw new PromptValidationError('Please select a valid option.'); - } + const answer = await ask(`${message}\n${rendered}\n`, hint); - return choice.value; - } + const choice = findChoice(choices, answer); - let selected = firstEnabledIndex(choices); - renderSelectedChoice(message, choices, selected, scroll, info); + if (!choice || choice.disabled) { + throw new PromptValidationError('Please select a valid option.'); + } - while (true) { - const key = await environment.input.readKey(); + return choice.value; + } - if (key === null) { - throw new PromptValidationError('Please select a valid option.'); - } + let selected = firstEnabledIndex(choices); - const numeric = parseChoiceIndex(key); + renderSelectedChoice(message, choices, selected, scroll, info); - if (!Number.isNaN(numeric) && choices[numeric - 1] && !choices[numeric - 1]?.disabled) { - return choices[numeric - 1].value; - } + while (true) { + const key = await environment.input.readKey(); - if (nextChoiceKeys(key)) { - selected = nextEnabledIndex(choices, selected, 1); - renderSelectedChoice(message, choices, selected, scroll, info); - continue; - } + if (key === null) { + throw new PromptValidationError('Please select a valid option.'); + } - if (previousChoiceKeys(key)) { - selected = nextEnabledIndex(choices, selected, -1); - renderSelectedChoice(message, choices, selected, scroll, info); - continue; - } + const numeric = parseChoiceIndex(key); - if (oneOf([Key.home, Key.ctrlA], key)) { - selected = firstEnabledIndex(choices); - renderSelectedChoice(message, choices, selected, scroll, info); - continue; - } + if (!Number.isNaN(numeric) && choices[numeric - 1] && !choices[numeric - 1]?.disabled) { + return choices[numeric - 1].value; + } - if (oneOf([Key.end, Key.ctrlE], key)) { - selected = choices.length - 1; + if (nextChoiceKeys(key)) { + selected = nextEnabledIndex(choices, selected, 1); + renderSelectedChoice(message, choices, selected, scroll, info); + continue; + } - while (choices[selected]?.disabled && selected > 0) { - selected -= 1; - } + if (previousChoiceKeys(key)) { + selected = nextEnabledIndex(choices, selected, -1); + renderSelectedChoice(message, choices, selected, scroll, info); + continue; + } - renderSelectedChoice(message, choices, selected, scroll, info); - continue; - } + if (oneOf([Key.home, Key.ctrlA], key)) { + selected = firstEnabledIndex(choices); + renderSelectedChoice(message, choices, selected, scroll, info); + continue; + } - if (key === Key.enter) { - const choice = choices[selected]; + if (oneOf([Key.end, Key.ctrlE], key)) { + selected = choices.length - 1; - if (!choice || choice.disabled) { - throw new PromptValidationError('Please select a valid option.'); - } + while (choices[selected]?.disabled && selected > 0) { + selected -= 1; + } - return choice.value; - } - } + renderSelectedChoice(message, choices, selected, scroll, info); + continue; + } + + if (key === Key.enter) { + const choice = choices[selected]; + + if (!choice || choice.disabled) { + throw new PromptValidationError('Please select a valid option.'); + } + + return choice.value; + } + } }; const choicesFromCommaSeparated = (choices: Array>, answer: string): T[] => { - const parts = answer.split(',').map((part) => part.trim()).filter((part) => part.length > 0); - const selected = parts - .map((part) => findChoice(choices, part)) - .filter((choice): choice is Choice => choice !== undefined && !choice.disabled); + const parts = answer + .split(',') + .map((part) => part.trim()) + .filter((part) => part.length > 0); + + const selected = parts.map((part) => findChoice(choices, part)).filter((choice): choice is Choice => choice !== undefined && !choice.disabled); - if (selected.length !== parts.length) { - throw new PromptValidationError('Please select valid options.'); - } + if (selected.length !== parts.length) { + throw new PromptValidationError('Please select valid options.'); + } - return selected.map((choice) => choice.value); + return selected.map((choice) => choice.value); }; const renderMultipleChoices = (message: string, choices: Array>, selected: number, marked: Set, scroll?: number, info?: MultiSelectPromptOptions['info']): void => { - renderInteractiveChoices(message, choices, selected, marked, scroll); + renderInteractiveChoices(message, choices, selected, marked, scroll); - const text = resolveInfo(info, choices[selected]?.value ?? null); + const text = resolveInfo(info, choices[selected]?.value ?? null); - if (text.length > 0) { - promptEnvironment().output.write(`${text}\n`); - } + if (text.length > 0) { + promptEnvironment().output.write(`${text}\n`); + } - const labels = [...marked] - .sort((left, right) => left - right) - .map((index) => choices[index]?.label) - .filter((label): label is string => label !== undefined); + const labels = [...marked] + .sort((left, right) => left - right) + .map((index) => choices[index]?.label) + .filter((label): label is string => label !== undefined); - if (labels.length > 0) { - promptEnvironment().output.write(`Selected: ${labels.join(', ')}\n`); - } + if (labels.length > 0) { + promptEnvironment().output.write(`Selected: ${labels.join(', ')}\n`); + } }; const readMultipleChoices = async (message: string, choices: Array>, defaults: T[] = [], hint?: string, scroll?: number, info?: MultiSelectPromptOptions['info']): Promise => { - const environment = promptEnvironment(); - const selectedValues = new Set(defaults); - - if (!environment.input.readKey) { - const rendered = renderChoices(choices); - const answer = await ask(`${message}\n${rendered}\n`, hint); - - return answer.trim() === '' ? defaults : choicesFromCommaSeparated(choices, answer); - } - - let selected = firstEnabledIndex(choices); - const marked = new Set(choices.flatMap((choice, index) => selectedValues.has(choice.value) ? [index] : [])); - renderMultipleChoices(message, choices, selected, marked, scroll, info); - - while (true) { - const key = await environment.input.readKey(); - - if (key === null) { - return [...marked].map((index) => choices[index]?.value).filter((value): value is T => value !== undefined); - } - - if (key.includes(',')) { - return choicesFromCommaSeparated(choices, key); - } - - const numeric = parseChoiceIndex(key); - - if (!Number.isNaN(numeric) && choices[numeric - 1] && !choices[numeric - 1]?.disabled) { - const index = numeric - 1; - - if (marked.has(index)) { - marked.delete(index); - } else { - marked.add(index); - } - - renderMultipleChoices(message, choices, selected, marked, scroll, info); - continue; - } - - if (nextChoiceKeys(key)) { - selected = nextEnabledIndex(choices, selected, 1); - renderMultipleChoices(message, choices, selected, marked, scroll, info); - continue; - } - - if (previousChoiceKeys(key)) { - selected = nextEnabledIndex(choices, selected, -1); - renderMultipleChoices(message, choices, selected, marked, scroll, info); - continue; - } - - if (oneOf([Key.home], key)) { - selected = firstEnabledIndex(choices); - renderMultipleChoices(message, choices, selected, marked, scroll, info); - continue; - } - - if (oneOf([Key.end], key)) { - selected = choices.length - 1; - - while (choices[selected]?.disabled && selected > 0) { - selected -= 1; - } - - renderMultipleChoices(message, choices, selected, marked, scroll, info); - continue; - } - - if (key === Key.ctrlA) { - if (marked.size === choices.filter((choice) => !choice.disabled).length) { - marked.clear(); - } else { - marked.clear(); - - for (const [index, choice] of choices.entries()) { - if (!choice.disabled) { - marked.add(index); - } - } - } - - renderMultipleChoices(message, choices, selected, marked, scroll, info); - continue; - } - - if (key === Key.space) { - if (marked.has(selected)) { - marked.delete(selected); - } else if (!choices[selected]?.disabled) { - marked.add(selected); - } - - renderMultipleChoices(message, choices, selected, marked, scroll, info); - continue; - } - - if (key === Key.enter) { - return [...marked].map((index) => choices[index]?.value).filter((value): value is T => value !== undefined); - } - } + const environment = promptEnvironment(); + const selectedValues = new Set(defaults); + + if (!environment.input.readKey) { + const rendered = renderChoices(choices); + + const answer = await ask(`${message}\n${rendered}\n`, hint); + + return answer.trim() === '' ? defaults : choicesFromCommaSeparated(choices, answer); + } + + let selected = firstEnabledIndex(choices); + + const marked = new Set(choices.flatMap((choice, index) => (selectedValues.has(choice.value) ? [index] : []))); + + renderMultipleChoices(message, choices, selected, marked, scroll, info); + + while (true) { + const key = await environment.input.readKey(); + + if (key === null) { + return [...marked].map((index) => choices[index]?.value).filter((value): value is T => value !== undefined); + } + + if (key.includes(',')) { + return choicesFromCommaSeparated(choices, key); + } + + const numeric = parseChoiceIndex(key); + + if (!Number.isNaN(numeric) && choices[numeric - 1] && !choices[numeric - 1]?.disabled) { + const index = numeric - 1; + + if (marked.has(index)) { + marked.delete(index); + } else { + marked.add(index); + } + + renderMultipleChoices(message, choices, selected, marked, scroll, info); + continue; + } + + if (nextChoiceKeys(key)) { + selected = nextEnabledIndex(choices, selected, 1); + renderMultipleChoices(message, choices, selected, marked, scroll, info); + continue; + } + + if (previousChoiceKeys(key)) { + selected = nextEnabledIndex(choices, selected, -1); + renderMultipleChoices(message, choices, selected, marked, scroll, info); + continue; + } + + if (oneOf([Key.home], key)) { + selected = firstEnabledIndex(choices); + renderMultipleChoices(message, choices, selected, marked, scroll, info); + continue; + } + + if (oneOf([Key.end], key)) { + selected = choices.length - 1; + + while (choices[selected]?.disabled && selected > 0) { + selected -= 1; + } + + renderMultipleChoices(message, choices, selected, marked, scroll, info); + continue; + } + + if (key === Key.ctrlA) { + if (marked.size === choices.filter((choice) => !choice.disabled).length) { + marked.clear(); + } else { + marked.clear(); + + for (const [index, choice] of choices.entries()) { + if (!choice.disabled) { + marked.add(index); + } + } + } + + renderMultipleChoices(message, choices, selected, marked, scroll, info); + continue; + } + + if (key === Key.space) { + if (marked.has(selected)) { + marked.delete(selected); + } else if (!choices[selected]?.disabled) { + marked.add(selected); + } + + renderMultipleChoices(message, choices, selected, marked, scroll, info); + continue; + } + + if (key === Key.enter) { + return [...marked].map((index) => choices[index]?.value).filter((value): value is T => value !== undefined); + } + } }; 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 -): Promise; + +export function confirm(label: string, defaultValue?: boolean, yes?: string, no?: string, required?: boolean | string, validate?: ConfirmPromptOptions['validate'], hint?: string): Promise; + export async function confirm( - message: string | ConfirmPromptOptions, - defaultValue = true, - yes = 'Yes', - no = 'No', - required: boolean | string = false, - validate: ConfirmPromptOptions['validate'] = undefined, - hint = '' + message: string | ConfirmPromptOptions, + defaultValue = true, + yes = 'Yes', + no = 'No', + required: boolean | string = false, + validate: ConfirmPromptOptions['validate'] = undefined, + hint = '', ): Promise { - const options: ConfirmPromptOptions = typeof message === 'string' - ? { message, label: message, default: defaultValue, yes, no, required, validate, hint } - : { ...message, default: message.default ?? true }; + const options: ConfirmPromptOptions = + typeof message === 'string' ? { message, label: message, default: defaultValue, yes, no, required, validate, hint } : { ...message, default: message.default ?? true }; + + return promptUntilValid(options, async () => { + const suffix = options.default === false ? ' [y/N]' : ' [Y/n]'; - return promptUntilValid(options, async () => { - const suffix = options.default === false ? ' [y/N]' : ' [Y/n]'; - const answer = (await ask(`${options.message}${suffix}`, options.hint)).trim().toLowerCase(); + const answer = (await ask(`${options.message}${suffix}`, options.hint)).trim().toLowerCase(); - if (answer === '' && options.default !== undefined) { - return options.default; - } + if (answer === '' && options.default !== undefined) { + return options.default; + } - return ['y', 'yes', options.yes?.toLowerCase()].includes(answer); - }); + return ['y', 'yes', options.yes?.toLowerCase()].includes(answer); + }); } export const select = async (options: SelectPromptOptions): Promise => { - const choices = normalizeChoices(options.options); + const choices = normalizeChoices(options.options); - return promptUntilValid(options, async () => { - return readSelectedChoice(options.message, choices, options.hint, options.scroll, options.info).catch((error: unknown) => { - if (options.default !== undefined && error instanceof PromptValidationError) { - return options.default; - } + return promptUntilValid(options, async () => { + return readSelectedChoice(options.message, choices, options.hint, options.scroll, options.info).catch((error: unknown) => { + if (options.default !== undefined && error instanceof PromptValidationError) { + return options.default; + } - throw error; - }); - }); + throw error; + }); + }); }; export const multiselect = async (options: MultiSelectPromptOptions): Promise => { - const choices = normalizeChoices(options.options); + const choices = normalizeChoices(options.options); - return promptUntilValid(options, async () => readMultipleChoices(options.message, choices, options.default, options.hint, options.scroll, options.info)); + return promptUntilValid(options, async () => readMultipleChoices(options.message, choices, options.default, options.hint, options.scroll, options.info)); }; diff --git a/packages/tui/src/prompts/suggest.ts b/packages/tui/src/prompts/suggest.ts index a8915607..bbafe7be 100644 --- a/packages/tui/src/prompts/suggest.ts +++ b/packages/tui/src/prompts/suggest.ts @@ -7,202 +7,210 @@ import { resolveInfo } from '#tui/concerns/info'; 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); + options: string[] | ((query: string) => MaybePromise); + scroll?: number; + info?: string | ((value: string | null) => string | null | undefined); }; 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 + 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, ): SuggestOptions => { - if (typeof message === 'string') { - return { message, label: message, options, placeholder, default: defaultValue, scroll, required, validate, hint, transform }; - } + if (typeof message === 'string') { + return { message, label: message, options, placeholder, default: defaultValue, scroll, required, validate, hint, transform }; + } - return { ...message, default: message.default ?? '' }; + return { ...message, default: message.default ?? '' }; }; const resolveSuggestions = async (source: SuggestOptions['options'], query: string): Promise => { - const options = typeof source === 'function' ? await source(query) : source; + const options = typeof source === 'function' ? await source(query) : source; - if (typeof source === 'function') { - return [...options]; - } + if (typeof source === 'function') { + return [...options]; + } - return options.filter((option: string) => option.toLowerCase().startsWith(query.toLowerCase())); + return options.filter((option: string) => option.toLowerCase().startsWith(query.toLowerCase())); }; const renderSuggestions = (message: string, value: string, matches: string[], highlighted: number | null, scroll?: number, info?: SuggestOptions['info']): void => { - renderInteractiveChoices( - value.length > 0 ? `${message} ${value}` : message, - matches.map((match) => ({ label: match, value: match })), - highlighted ?? 0, - new Set(), - scroll - ); - - const text = resolveInfo(info, highlighted === null ? null : matches[highlighted] ?? null); - - if (text.length > 0) { - promptEnvironment().output.write(`${text}\n`); - } + renderInteractiveChoices( + value.length > 0 ? `${message} ${value}` : message, + matches.map((match) => ({ label: match, value: match })), + highlighted ?? 0, + new Set(), + scroll, + ); + + const text = resolveInfo(info, highlighted === null ? null : (matches[highlighted] ?? null)); + + if (text.length > 0) { + promptEnvironment().output.write(`${text}\n`); + } }; const readSuggestionValue = async (options: SuggestOptions): Promise => { - const environment = promptEnvironment(); - - if (!environment.input.readKey) { - return ask(options.message, options.hint); - } - - let state = { - cursor: options.default?.length ?? 0, - value: options.default ?? '' - }; - let highlighted: number | null = null; - let matches: string[] = await resolveSuggestions(options.options, state.value); - - renderSuggestions(options.message, state.value, matches, highlighted, options.scroll, options.info); - - while (true) { - const key = await environment.input.readKey(); - - if (key === null) { - return state.value; - } - - if (key === Key.tab) { - matches = await resolveSuggestions(options.options, state.value); - const match = matches[highlighted ?? 0]; - - if (match !== undefined) { - state = { cursor: match.length, value: match }; - } - - matches = await resolveSuggestions(options.options, state.value); - highlighted = null; - renderSuggestions(options.message, state.value, matches, highlighted, options.scroll, options.info); - continue; - } - - if (key === Key.down || key === Key.downArrow || key === Key.ctrlN || key === Key.shiftTab) { - matches = await resolveSuggestions(options.options, state.value); - highlighted = matches.length === 0 ? null : ((highlighted ?? -1) + 1) % matches.length; - renderSuggestions(options.message, state.value, matches, highlighted, options.scroll, options.info); - continue; - } - - if (key === Key.up || key === Key.upArrow || key === Key.ctrlP) { - matches = await resolveSuggestions(options.options, state.value); - highlighted = matches.length === 0 ? null : ((highlighted ?? matches.length) - 1 + matches.length) % matches.length; - renderSuggestions(options.message, state.value, matches, highlighted, options.scroll, options.info); - continue; - } - - if (oneOf([Key.home, Key.ctrlA], key) && highlighted !== null) { - highlighted = 0; - renderSuggestions(options.message, state.value, matches, highlighted, options.scroll, options.info); - continue; - } - - if (oneOf([Key.end, Key.ctrlE], key) && highlighted !== null) { - highlighted = Math.max(0, matches.length - 1); - renderSuggestions(options.message, state.value, matches, highlighted, options.scroll, options.info); - continue; - } - - if (key === Key.enter) { - if (highlighted !== null && matches[highlighted] !== undefined) { - return matches[highlighted]; - } - - return state.value; - } - - const next = applyTypedKey(state, key); - - if (next.submitted) { - return state.value; - } - - if (next.cancelled) { - environment.error.write('Cancelled.\n'); - - return state.value; - } - - state = { cursor: next.cursor, value: next.value }; - highlighted = null; - matches = await resolveSuggestions(options.options, state.value); - renderSuggestions(options.message, state.value, matches, highlighted, options.scroll, options.info); - } + const environment = promptEnvironment(); + + if (!environment.input.readKey) { + return ask(options.message, options.hint); + } + + let state = { + cursor: options.default?.length ?? 0, + value: options.default ?? '', + }; + let highlighted: number | null = null; + + let matches: string[] = await resolveSuggestions(options.options, state.value); + + renderSuggestions(options.message, state.value, matches, highlighted, options.scroll, options.info); + + while (true) { + const key = await environment.input.readKey(); + + if (key === null) { + return state.value; + } + + if (key === Key.tab) { + matches = await resolveSuggestions(options.options, state.value); + + const match = matches[highlighted ?? 0]; + + if (match !== undefined) { + state = { cursor: match.length, value: match }; + } + + matches = await resolveSuggestions(options.options, state.value); + + highlighted = null; + renderSuggestions(options.message, state.value, matches, highlighted, options.scroll, options.info); + continue; + } + + if (key === Key.down || key === Key.downArrow || key === Key.ctrlN || key === Key.shiftTab) { + matches = await resolveSuggestions(options.options, state.value); + + highlighted = matches.length === 0 ? null : ((highlighted ?? -1) + 1) % matches.length; + renderSuggestions(options.message, state.value, matches, highlighted, options.scroll, options.info); + continue; + } + + if (key === Key.up || key === Key.upArrow || key === Key.ctrlP) { + matches = await resolveSuggestions(options.options, state.value); + + highlighted = matches.length === 0 ? null : ((highlighted ?? matches.length) - 1 + matches.length) % matches.length; + renderSuggestions(options.message, state.value, matches, highlighted, options.scroll, options.info); + continue; + } + + if (oneOf([Key.home, Key.ctrlA], key) && highlighted !== null) { + highlighted = 0; + renderSuggestions(options.message, state.value, matches, highlighted, options.scroll, options.info); + continue; + } + + if (oneOf([Key.end, Key.ctrlE], key) && highlighted !== null) { + highlighted = Math.max(0, matches.length - 1); + renderSuggestions(options.message, state.value, matches, highlighted, options.scroll, options.info); + continue; + } + + if (key === Key.enter) { + if (highlighted !== null && matches[highlighted] !== undefined) { + return matches[highlighted]; + } + + return state.value; + } + + const next = applyTypedKey(state, key); + + if (next.submitted) { + return state.value; + } + + if (next.cancelled) { + environment.error.write('Cancelled.\n'); + + return state.value; + } + + state = { cursor: next.cursor, value: next.value }; + highlighted = null; + + matches = await resolveSuggestions(options.options, state.value); + + renderSuggestions(options.message, state.value, matches, highlighted, options.scroll, options.info); + } }; 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'] + label: string, + options: string[] | ((query: string) => MaybePromise), + placeholder?: string, + defaultValue?: string, + scroll?: number, + required?: boolean | string, + validate?: TextPromptOptions['validate'], + hint?: string, + transform?: TextPromptOptions['transform'], ): 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 + 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, ): Promise { - const options = suggestOptions(message, source, placeholder, defaultValue, scroll, required, validate, hint, transform); + const options = suggestOptions(message, source, placeholder, defaultValue, scroll, required, validate, hint, transform); - return promptUntilValid(options, async () => { - const answer = await readSuggestionValue(options); - const value = answer === '' && options.default !== undefined ? options.default : answer; + return promptUntilValid(options, async () => { + const answer = await readSuggestionValue(options); - return options.transform ? options.transform(value) : value; - }); + const value = answer === '' && options.default !== undefined ? options.default : answer; + + return options.transform ? options.transform(value) : value; + }); } 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'] + label: string, + options?: string[] | ((query: string) => MaybePromise), + placeholder?: string, + defaultValue?: string, + required?: boolean | string, + validate?: TextPromptOptions['validate'], + hint?: string, + transform?: TextPromptOptions['transform'], ): 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 + message: string | SuggestOptions, + source: string[] | ((query: string) => MaybePromise) = [], + placeholder = '', + defaultValue = '', + required: boolean | string = false, + validate: TextPromptOptions['validate'] = undefined, + hint = '', + transform: TextPromptOptions['transform'] = undefined, ): Promise { - return suggest( - typeof message === 'string' - ? { message, label: message, options: source, placeholder, default: defaultValue, required, validate, hint, transform } - : message - ); + return suggest(typeof message === 'string' ? { message, label: message, options: source, placeholder, default: defaultValue, required, validate, hint, transform } : message); } diff --git a/packages/tui/src/prompts/text.ts b/packages/tui/src/prompts/text.ts index 1aab28fa..03b0650a 100644 --- a/packages/tui/src/prompts/text.ts +++ b/packages/tui/src/prompts/text.ts @@ -4,35 +4,36 @@ import { readTypedValue } from '#tui/typed-value'; 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'] + 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 + message: string | TextPromptOptions, + placeholder = '', + defaultValue = '', + required: boolean | string = false, + validate: TextPromptOptions['validate'] = undefined, + hint = '', + transform: TextPromptOptions['transform'] = undefined, ): Promise { - const options = typeof message === 'string' - ? textOptions({ message, label: message, placeholder, default: defaultValue, required, validate, hint, transform }) - : textOptions(message); + const options = typeof message === 'string' ? textOptions({ message, label: message, placeholder, default: defaultValue, required, validate, hint, transform }) : textOptions(message); + + return promptUntilValid(options, async () => { + const answer = await readTypedValue(options.message, { + default: options.default, + hint: options.hint, + }); - return promptUntilValid(options, async () => { - const answer = await readTypedValue(options.message, { - default: options.default, - hint: options.hint - }); - const value = answer === '' && options.default !== undefined ? options.default : answer; + const value = answer === '' && options.default !== undefined ? options.default : answer; - return options.transform ? options.transform(value) : value; - }); + return options.transform ? options.transform(value) : value; + }); } diff --git a/packages/tui/src/prompts/textarea.ts b/packages/tui/src/prompts/textarea.ts index af0f12eb..807a2cfb 100644 --- a/packages/tui/src/prompts/textarea.ts +++ b/packages/tui/src/prompts/textarea.ts @@ -4,38 +4,39 @@ import { readTypedValue } from '#tui/typed-value'; import type { TextPromptOptions } from '#tui/types'; export function textarea(options: TextPromptOptions & { rows?: number }): Promise; + export function textarea( - label: string, - placeholder?: string, - defaultValue?: string, - required?: boolean | string, - validate?: TextPromptOptions['validate'], - hint?: string, - rows?: number, - transform?: TextPromptOptions['transform'] + label: string, + placeholder?: string, + defaultValue?: string, + required?: boolean | string, + validate?: TextPromptOptions['validate'], + hint?: string, + rows?: number, + transform?: TextPromptOptions['transform'], ): Promise; + export async function textarea( - message: string | (TextPromptOptions & { rows?: number }), - placeholder = '', - defaultValue = '', - required: boolean | string = false, - validate: TextPromptOptions['validate'] = undefined, - hint = '', - _rows = 5, - transform: TextPromptOptions['transform'] = undefined + message: string | (TextPromptOptions & { rows?: number }), + placeholder = '', + defaultValue = '', + required: boolean | string = false, + validate: TextPromptOptions['validate'] = undefined, + hint = '', + _rows = 5, + transform: TextPromptOptions['transform'] = undefined, ): Promise { - const options = typeof message === 'string' - ? textOptions({ message, label: message, placeholder, default: defaultValue, required, validate, hint, transform }) - : textOptions(message); + const options = typeof message === 'string' ? textOptions({ message, label: message, placeholder, default: defaultValue, required, validate, hint, transform }) : textOptions(message); + + return promptUntilValid(options, async () => { + const answer = await readTypedValue(options.message, { + default: options.default, + hint: options.hint, + allowNewLine: true, + }); - return promptUntilValid(options, async () => { - const answer = await readTypedValue(options.message, { - default: options.default, - hint: options.hint, - allowNewLine: true - }); - const value = answer === '' && options.default !== undefined ? options.default : answer; + const value = answer === '' && options.default !== undefined ? options.default : answer; - return options.transform ? options.transform(value) : value; - }); + return options.transform ? options.transform(value) : value; + }); } diff --git a/packages/tui/src/status/progress.ts b/packages/tui/src/status/progress.ts index 5a0d4b98..fb2ce2b4 100644 --- a/packages/tui/src/status/progress.ts +++ b/packages/tui/src/status/progress.ts @@ -2,103 +2,107 @@ import { promptEnvironment } from '#tui/environment'; import type { MaybePromise } from '#tui/types'; export class Progress { - #current = 0; - #label: string; - #hint: string; - - constructor( - readonly total: number, - message = 'Progress', - hint = '' - ) { - if (total <= 0) { - throw new Error('Progress bar must have at least one item.'); - } - - this.#label = message; - this.#hint = hint; - } - - start(): void { - this.render(); - } - - advance(step = 1): void { - this.#current = Math.min(this.total, this.#current + step); - this.render(); - } - - finish(): void { - this.#current = this.total; - this.render(); - } - - label(value: string): this { - this.#label = value; - - return this; - } - - hint(value: string): this { - this.#hint = value; - - return this; - } - - percentage(): number { - return this.#current / this.total; - } - - value(): number { - return this.#current; - } - - render(): void { - const width = 20; - const filled = Math.round(width * this.percentage()); - const bar = `${'█'.repeat(filled)}${' '.repeat(width - filled)}`; - const hint = this.#hint ? ` ${this.#hint}` : ''; - - promptEnvironment().output.write(`${this.#label}: ${bar} ${this.#current} / ${this.total}${hint}\n`); - } + #current = 0; + #label: string; + #hint: string; + + constructor( + readonly total: number, + message = 'Progress', + hint = '', + ) { + if (total <= 0) { + throw new Error('Progress bar must have at least one item.'); + } + + this.#label = message; + this.#hint = hint; + } + + start(): void { + this.render(); + } + + advance(step = 1): void { + this.#current = Math.min(this.total, this.#current + step); + this.render(); + } + + finish(): void { + this.#current = this.total; + this.render(); + } + + label(value: string): this { + this.#label = value; + + return this; + } + + hint(value: string): this { + this.#hint = value; + + return this; + } + + percentage(): number { + return this.#current / this.total; + } + + value(): number { + return this.#current; + } + + render(): void { + const width = 20; + const filled = Math.round(width * this.percentage()); + const bar = `${'█'.repeat(filled)}${' '.repeat(width - filled)}`; + const hint = this.#hint ? ` ${this.#hint}` : ''; + + promptEnvironment().output.write(`${this.#label}: ${bar} ${this.#current} / ${this.total}${hint}\n`); + } } export function progress(total: number, message?: string): Progress; + export function progress(label: string, steps: Iterable | number, callback?: (step: T | number, progress: Progress) => MaybePromise, hint?: string): Progress | Promise; + export function progress( - labelOrTotal: string | number, - stepsOrMessage?: Iterable | number | string, - callback?: (step: T | number, progress: Progress) => MaybePromise, - hint = '' + labelOrTotal: string | number, + stepsOrMessage?: Iterable | number | string, + callback?: (step: T | number, progress: Progress) => MaybePromise, + hint = '', ): Progress | Promise { - if (typeof labelOrTotal === 'number') { - return new Progress(labelOrTotal, typeof stepsOrMessage === 'string' ? stepsOrMessage : undefined); - } + if (typeof labelOrTotal === 'number') { + return new Progress(labelOrTotal, typeof stepsOrMessage === 'string' ? stepsOrMessage : undefined); + } + + const steps = stepsOrMessage ?? 0; + + if (typeof steps === 'string') { + throw new Error('Progress steps must be an iterable or a number.'); + } - const steps = stepsOrMessage ?? 0; - if (typeof steps === 'string') { - throw new Error('Progress steps must be an iterable or a number.'); - } + const values = typeof steps === 'number' ? Array.from({ length: steps }, (_, index) => index) : Array.from(steps); + const bar = new Progress(values.length, labelOrTotal, hint); - const values = typeof steps === 'number' ? Array.from({ length: steps }, (_, index) => index) : Array.from(steps); - const bar = new Progress(values.length, labelOrTotal, hint); + if (!callback) { + return bar; + } - if (!callback) { - return bar; - } + return (async () => { + const results: R[] = []; - return (async () => { - const results: R[] = []; + bar.start(); - bar.start(); + for (const value of values) { + results.push(await callback(value, bar)); - for (const value of values) { - results.push(await callback(value, bar)); - bar.advance(); - } + bar.advance(); + } - bar.finish(); + bar.finish(); - return results; - })(); + return results; + })(); } diff --git a/packages/tui/src/status/spinner.ts b/packages/tui/src/status/spinner.ts index 8632273d..8132c77c 100644 --- a/packages/tui/src/status/spinner.ts +++ b/packages/tui/src/status/spinner.ts @@ -2,15 +2,16 @@ import { promptEnvironment } from '#tui/environment'; import type { MaybePromise, StatusOptions } from '#tui/types'; export const spin = async (callback: () => MaybePromise, options: StatusOptions = { message: 'Loading' }): Promise => { - promptEnvironment().output.write(`${options.message}...\n`); + promptEnvironment().output.write(`${options.message}...\n`); - try { - const result = await callback(); - promptEnvironment().output.write(`Done: ${options.message}\n`); + try { + const result = await callback(); - return result; - } catch (error) { - promptEnvironment().error.write(`Failed: ${options.message}\n`); - throw error; - } + promptEnvironment().output.write(`Done: ${options.message}\n`); + + return result; + } catch (error) { + promptEnvironment().error.write(`Failed: ${options.message}\n`); + throw error; + } }; diff --git a/packages/tui/src/status/stream.ts b/packages/tui/src/status/stream.ts index 4520defa..7f6c2c31 100644 --- a/packages/tui/src/status/stream.ts +++ b/packages/tui/src/status/stream.ts @@ -1,23 +1,25 @@ import { promptEnvironment } from '#tui/environment'; export class Stream { - write(content: string): this { - promptEnvironment().output.write(content); + write(content: string): this { + promptEnvironment().output.write(content); - return this; - } + return this; + } - async pipe(source: AsyncIterable | Iterable): Promise { - for await (const chunk of source) { - this.write(chunk); - } - } + async pipe(source: AsyncIterable | Iterable): Promise { + for await (const chunk of source) { + this.write(chunk); + } + } } export function stream(): Stream; + export function stream(source: AsyncIterable | Iterable): Promise; + export function stream(source?: AsyncIterable | Iterable): Stream | Promise { - const output = new Stream(); + const output = new Stream(); - return source === undefined ? output : output.pipe(source); + return source === undefined ? output : output.pipe(source); } diff --git a/packages/tui/src/status/task.ts b/packages/tui/src/status/task.ts index 7ee9c1dd..d2bdfa7c 100644 --- a/packages/tui/src/status/task.ts +++ b/packages/tui/src/status/task.ts @@ -1,70 +1,70 @@ import { promptEnvironment } from '#tui/environment'; import type { MaybePromise } from '#tui/types'; +const cursorReset = `${String.fromCharCode(27)}[1G`; +const eraseLine = `${String.fromCharCode(27)}[2K`; + export type TaskDefinition = { - title: string; - limit?: number; - task: (logger: Logger) => MaybePromise; + title: string; + limit?: number; + task: (logger: Logger) => MaybePromise; }; export class Logger { - readonly lines: string[] = []; + readonly lines: string[] = []; - constructor(private readonly limit: number) {} + constructor(private readonly limit: number) {} - log(message: string): void { - for (const line of message.split(/\r?\n/u).filter((value) => value.length > 0)) { - this.lines.push(line.replaceAll(/\u001B\[1G|\u001B\[2K/gu, '')); - } + log(message: string): void { + for (const line of message.split(/\r?\n/u).filter((value) => value.length > 0)) { + this.lines.push(line.replaceAll(cursorReset, '').replaceAll(eraseLine, '')); + } - while (this.lines.length > this.limit) { - this.lines.shift(); - } - } + while (this.lines.length > this.limit) { + this.lines.shift(); + } + } - info(message: string): void { - this.log(message); - } + info(message: string): void { + this.log(message); + } - success(message: string): void { - this.log(message); - } + success(message: string): void { + this.log(message); + } - warning(message: string): void { - this.log(message); - } + warning(message: string): void { + this.log(message); + } - error(message: string): void { - this.log(message); - } + error(message: string): void { + this.log(message); + } } 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 { - const title = typeof definitionOrLabel === 'string' ? definitionOrLabel : definitionOrLabel.title; - const run = typeof definitionOrLabel === 'string' ? callback : definitionOrLabel.task; - if (!run) { - throw new Error('A task callback is required.'); - } +export async function task(definitionOrLabel: TaskDefinition | string, callback?: (logger: Logger) => MaybePromise, limit = 10, _keepSummary = false, subLabel?: string): Promise { + const title = typeof definitionOrLabel === 'string' ? definitionOrLabel : definitionOrLabel.title; + const run = typeof definitionOrLabel === 'string' ? callback : definitionOrLabel.task; + + if (!run) { + throw new Error('A task callback is required.'); + } + + const logger = new Logger(typeof definitionOrLabel === 'string' ? limit : (definitionOrLabel.limit ?? limit)); - const logger = new Logger(typeof definitionOrLabel === 'string' ? limit : (definitionOrLabel.limit ?? limit)); + promptEnvironment().output.write(`${title}${subLabel ? ` ${subLabel}` : ''}\n`); - promptEnvironment().output.write(`${title}${subLabel ? ` ${subLabel}` : ''}\n`); - const result = await run(logger); + const result = await run(logger); - for (const line of logger.lines) { - promptEnvironment().output.write(`${line}\n`); - } + for (const line of logger.lines) { + promptEnvironment().output.write(`${line}\n`); + } - promptEnvironment().output.write(`Done: ${title}\n`); + promptEnvironment().output.write(`Done: ${title}\n`); - return result; + return result; } diff --git a/packages/tui/src/strings.ts b/packages/tui/src/strings.ts index 0034cb2b..5b860531 100644 --- a/packages/tui/src/strings.ts +++ b/packages/tui/src/strings.ts @@ -4,75 +4,75 @@ import stripAnsi from 'strip-ansi'; export const visibleWidth = (value: string): number => stringWidth(stripAnsi(value)); export const truncate = (value: string, width: number, marker = '...'): string => { - if (width <= 0) { - return ''; - } + if (width <= 0) { + return ''; + } - if (visibleWidth(value) <= width) { - return value; - } + if (visibleWidth(value) <= width) { + return value; + } - if (width <= marker.length) { - return marker.slice(0, width); - } + if (width <= marker.length) { + return marker.slice(0, width); + } - let result = ''; + let result = ''; - for (const char of value) { - if (visibleWidth(`${result}${char}${marker}`) > width) { - return `${result}${marker}`; - } + for (const char of value) { + if (visibleWidth(`${result}${char}${marker}`) > width) { + return `${result}${marker}`; + } - result += char; - } + result += char; + } - return result; + return result; }; export const wrap = (value: string, width: number): string[] => { - if (width <= 0) { - return [value]; - } + if (width <= 0) { + return [value]; + } - const lines: string[] = []; + const lines: string[] = []; - for (const originalLine of value.split('\n')) { - let line = ''; + for (const originalLine of value.split('\n')) { + let line = ''; - for (const word of originalLine.split(/(\s+)/u)) { - if (visibleWidth(`${line}${word}`) <= width) { - line += word; - continue; - } + for (const word of originalLine.split(/(\s+)/u)) { + if (visibleWidth(`${line}${word}`) <= width) { + line += word; + continue; + } - if (line.length > 0) { - lines.push(line.trimEnd()); - line = ''; - } + if (line.length > 0) { + lines.push(line.trimEnd()); + line = ''; + } - if (visibleWidth(word) > width) { - let chunk = ''; + if (visibleWidth(word) > width) { + let chunk = ''; - for (const char of word) { - if (visibleWidth(`${chunk}${char}`) > width) { - lines.push(chunk); - chunk = ''; - } + for (const char of word) { + if (visibleWidth(`${chunk}${char}`) > width) { + lines.push(chunk); + chunk = ''; + } - chunk += char; - } + chunk += char; + } - line = chunk; - continue; - } + line = chunk; + continue; + } - line = word.trimStart(); - } + line = word.trimStart(); + } - lines.push(line.trimEnd()); - } + lines.push(line.trimEnd()); + } - return lines; + return lines; }; export const parseAnsiText = (value: string): string => stripAnsi(value); diff --git a/packages/tui/src/terminal.ts b/packages/tui/src/terminal.ts index 5b2d9c5c..a2028d14 100644 --- a/packages/tui/src/terminal.ts +++ b/packages/tui/src/terminal.ts @@ -1,27 +1,27 @@ import { promptEnvironment } from '#tui/environment'; export type TerminalSize = { - columns: number; - rows: number; + columns: number; + rows: number; }; export const terminalSize = (): TerminalSize => ({ - columns: process.stdout.columns ?? 80, - rows: process.stdout.rows ?? 24 + columns: process.stdout.columns ?? 80, + rows: process.stdout.rows ?? 24, }); export const clearTerminal = (): void => { - promptEnvironment().output.write('\u001Bc'); + promptEnvironment().output.write('\u001Bc'); }; export const setTerminalTitle = (title: string): void => { - promptEnvironment().output.write(`\u001B]0;${title}\u0007`); + promptEnvironment().output.write(`\u001B]0;${title}\u0007`); }; export const eraseLine = (): void => { - promptEnvironment().output.write('\u001B[2K'); + promptEnvironment().output.write('\u001B[2K'); }; export const cursorToStart = (): void => { - promptEnvironment().output.write('\r'); + promptEnvironment().output.write('\r'); }; diff --git a/packages/tui/src/theme.ts b/packages/tui/src/theme.ts index f41a7ef1..968763aa 100644 --- a/packages/tui/src/theme.ts +++ b/packages/tui/src/theme.ts @@ -2,46 +2,46 @@ import { visibleWidth } from '#tui/strings'; import type { Choice } from '#tui/types'; export const symbols = { - question: '?', - answer: '>', - error: '!', - info: 'i', - selected: '●', - unselected: '○', - pointer: '›', - success: '✓', - warning: '!' + question: '?', + answer: '>', + error: '!', + info: 'i', + selected: '●', + unselected: '○', + pointer: '›', + success: '✓', + warning: '!', } as const; export const renderQuestion = (message: string, hint?: string): string => { - const suffix = hint ? ` ${hint}` : ''; + const suffix = hint ? ` ${hint}` : ''; - return `${symbols.question} ${message}${suffix} `; + return `${symbols.question} ${message}${suffix} `; }; export const renderError = (message: string): string => `${symbols.error} ${message}\n`; export const renderChoices = (choices: Array>): string => { - return choices - .map((choice, index) => { - const disabled = choice.disabled ? ` (${typeof choice.disabled === 'string' ? choice.disabled : 'disabled'})` : ''; - const hint = choice.hint ? ` ${choice.hint}` : ''; - - return ` ${index + 1}. ${choice.label}${hint}${disabled}`; - }) - .join('\n'); + return choices + .map((choice, index) => { + const disabled = choice.disabled ? ` (${typeof choice.disabled === 'string' ? choice.disabled : 'disabled'})` : ''; + const hint = choice.hint ? ` ${choice.hint}` : ''; + + return ` ${index + 1}. ${choice.label}${hint}${disabled}`; + }) + .join('\n'); }; export const renderTable = (headers: string[], rows: string[][]): string => { - const widths = headers.map((header, index) => { - return Math.max(visibleWidth(header), ...rows.map((row) => visibleWidth(row[index] ?? ''))); - }); + const widths = headers.map((header, index) => { + return Math.max(visibleWidth(header), ...rows.map((row) => visibleWidth(row[index] ?? ''))); + }); - const renderRow = (columns: string[]): string => { - return `| ${columns.map((column, index) => column.padEnd(widths[index] ?? 0)).join(' | ')} |`; - }; + const renderRow = (columns: string[]): string => { + return `| ${columns.map((column, index) => column.padEnd(widths[index] ?? 0)).join(' | ')} |`; + }; - const divider = `| ${widths.map((width) => '-'.repeat(width)).join(' | ')} |`; + const divider = `| ${widths.map((width) => '-'.repeat(width)).join(' | ')} |`; - return [renderRow(headers), divider, ...rows.map(renderRow)].join('\n'); + return [renderRow(headers), divider, ...rows.map(renderRow)].join('\n'); }; diff --git a/packages/tui/src/typed-value.ts b/packages/tui/src/typed-value.ts index 5ab1b718..c0b72e00 100644 --- a/packages/tui/src/typed-value.ts +++ b/packages/tui/src/typed-value.ts @@ -4,141 +4,141 @@ import { PromptValidationError } from '#tui/prompt'; import { renderQuestion } from '#tui/theme'; export type TypedValueState = { - cursor: number; - value: string; + cursor: number; + value: string; }; const characters = (value: string): string[] => [...value]; - const fromCharacters = (value: string[]): string => value.join(''); const isPrintable = (key: string): boolean => { - return [...key].every((character) => { - const code = character.codePointAt(0) ?? 0; + return [...key].every((character) => { + const code = character.codePointAt(0) ?? 0; - return code >= 32 && code !== 127; - }); + return code >= 32 && code !== 127; + }); }; export const applyTypedKey = (state: TypedValueState, key: string, allowNewLine = false): TypedValueState & { submitted: boolean; cancelled: boolean } => { - const value = characters(state.value); - let cursor = state.cursor; + const value = characters(state.value); + + let cursor = state.cursor; - if (key === Key.ctrlC) { - return { cursor, value: fromCharacters(value), submitted: false, cancelled: true }; - } + if (key === Key.ctrlC) { + return { cursor, value: fromCharacters(value), submitted: false, cancelled: true }; + } - if (key === Key.ctrlD && allowNewLine) { - return { cursor, value: fromCharacters(value), submitted: true, cancelled: false }; - } + if (key === Key.ctrlD && allowNewLine) { + return { cursor, value: fromCharacters(value), submitted: true, cancelled: false }; + } - if (key === Key.enter) { - if (!allowNewLine) { - return { cursor, value: fromCharacters(value), submitted: true, cancelled: false }; - } + if (key === Key.enter) { + if (!allowNewLine) { + return { cursor, value: fromCharacters(value), submitted: true, cancelled: false }; + } - value.splice(cursor, 0, '\n'); - cursor += 1; + value.splice(cursor, 0, '\n'); + cursor += 1; - return { cursor, value: fromCharacters(value), submitted: false, cancelled: false }; - } + return { cursor, value: fromCharacters(value), submitted: false, cancelled: false }; + } - if (key === Key.left || key === Key.leftArrow || key === Key.ctrlB) { - return { cursor: Math.max(0, cursor - 1), value: fromCharacters(value), submitted: false, cancelled: false }; - } + if (key === Key.left || key === Key.leftArrow || key === Key.ctrlB) { + return { cursor: Math.max(0, cursor - 1), value: fromCharacters(value), submitted: false, cancelled: false }; + } - if (key === Key.right || key === Key.rightArrow || key === Key.ctrlF) { - return { cursor: Math.min(value.length, cursor + 1), value: fromCharacters(value), submitted: false, cancelled: false }; - } + if (key === Key.right || key === Key.rightArrow || key === Key.ctrlF) { + return { cursor: Math.min(value.length, cursor + 1), value: fromCharacters(value), submitted: false, cancelled: false }; + } - if (oneOf([Key.home, Key.ctrlA], key)) { - return { cursor: 0, value: fromCharacters(value), submitted: false, cancelled: false }; - } + if (oneOf([Key.home, Key.ctrlA], key)) { + return { cursor: 0, value: fromCharacters(value), submitted: false, cancelled: false }; + } - if (oneOf([Key.end, Key.ctrlE], key)) { - return { cursor: value.length, value: fromCharacters(value), submitted: false, cancelled: false }; - } + if (oneOf([Key.end, Key.ctrlE], key)) { + return { cursor: value.length, value: fromCharacters(value), submitted: false, cancelled: false }; + } - if (key === Key.delete) { - value.splice(cursor, 1); + if (key === Key.delete) { + value.splice(cursor, 1); - return { cursor, value: fromCharacters(value), submitted: false, cancelled: false }; - } + return { cursor, value: fromCharacters(value), submitted: false, cancelled: false }; + } - if (key === Key.backspace || key === Key.ctrlH) { - if (cursor === 0) { - return { cursor, value: fromCharacters(value), submitted: false, cancelled: false }; - } + if (key === Key.backspace || key === Key.ctrlH) { + if (cursor === 0) { + return { cursor, value: fromCharacters(value), submitted: false, cancelled: false }; + } - value.splice(cursor - 1, 1); + value.splice(cursor - 1, 1); - return { cursor: cursor - 1, value: fromCharacters(value), submitted: false, cancelled: false }; - } + return { cursor: cursor - 1, value: fromCharacters(value), submitted: false, cancelled: false }; + } - if (key === Key.optionBackspace) { - const before = fromCharacters(value.slice(0, cursor)); - const match = before.match(/(?:[\p{L}\p{M}\p{N}]+|[^\p{L}\p{M}\p{N}\s]+)\s*$/u); - const start = match?.index ?? 0; + if (key === Key.optionBackspace) { + const before = fromCharacters(value.slice(0, cursor)); + const match = before.match(/(?:[\p{L}\p{M}\p{N}]+|[^\p{L}\p{M}\p{N}\s]+)\s*$/u); + const start = match?.index ?? 0; - value.splice(start, cursor - start); + value.splice(start, cursor - start); - return { cursor: start, value: fromCharacters(value), submitted: false, cancelled: false }; - } + return { cursor: start, value: fromCharacters(value), submitted: false, cancelled: false }; + } - if (isPrintable(key)) { - for (const character of characters(key)) { - value.splice(cursor, 0, character); - cursor += 1; - } - } + if (isPrintable(key)) { + for (const character of characters(key)) { + value.splice(cursor, 0, character); + cursor += 1; + } + } - return { cursor, value: fromCharacters(value), submitted: false, cancelled: false }; + return { cursor, value: fromCharacters(value), submitted: false, cancelled: false }; }; export const readTypedValue = async (message: string, options: { default?: string; hint?: string; allowNewLine?: boolean } = {}): Promise => { - const environment = promptEnvironment(); + const environment = promptEnvironment(); - if (!environment.input.readKey) { - if (!environment.input.readLine) { - throw new PromptValidationError('The configured prompt input cannot read input.'); - } + if (!environment.input.readKey) { + if (!environment.input.readLine) { + throw new PromptValidationError('The configured prompt input cannot read input.'); + } - const answer = await environment.input.readLine(renderQuestion(message, options.hint)); + const answer = await environment.input.readLine(renderQuestion(message, options.hint)); - return answer === '' && options.default !== undefined ? options.default : answer; - } + return answer === '' && options.default !== undefined ? options.default : answer; + } - environment.output.write(renderQuestion(message, options.hint)); + environment.output.write(renderQuestion(message, options.hint)); - let state: TypedValueState = { - cursor: options.default?.length ?? 0, - value: options.default ?? '' - }; + let state: TypedValueState = { + cursor: options.default?.length ?? 0, + value: options.default ?? '', + }; - while (true) { - const key = await environment.input.readKey(); + while (true) { + const key = await environment.input.readKey(); - if (key === null) { - return state.value; - } + if (key === null) { + return state.value; + } - const next = applyTypedKey(state, key, options.allowNewLine); + const next = applyTypedKey(state, key, options.allowNewLine); - if (next.cancelled) { - environment.error.write('Cancelled.\n'); + if (next.cancelled) { + environment.error.write('Cancelled.\n'); - return state.value; - } + return state.value; + } - state = { - cursor: next.cursor, - value: next.value - }; + state = { + cursor: next.cursor, + value: next.value, + }; - if (next.submitted) { - environment.output.write('\n'); + if (next.submitted) { + environment.output.write('\n'); - return state.value; - } - } + return state.value; + } + } }; diff --git a/packages/tui/src/types.ts b/packages/tui/src/types.ts index 7373bc5e..557b7788 100644 --- a/packages/tui/src/types.ts +++ b/packages/tui/src/types.ts @@ -7,34 +7,34 @@ export type Validator = (value: T) => MaybePromise; export type PromptValue = string | number | boolean | string[] | number[]; export type PromptOutput = { - write(content: string): void; + write(content: string): void; }; export type PromptInput = { - readKey?(): Promise; - readLine?(message: string): Promise; + readKey?(): Promise; + readLine?(message: string): Promise; }; export type PromptEnvironment = { - input: PromptInput; - output: PromptOutput; - error: PromptOutput; - interactive: boolean; + input: PromptInput; + output: PromptOutput; + error: PromptOutput; + interactive: boolean; }; export type BasePromptOptions = { - message: string; - default?: T; - required?: boolean | string; - hint?: string; - validate?: Validator; + message: string; + default?: T; + required?: boolean | string; + hint?: string; + validate?: Validator; }; export type Choice = { - label: string; - value: T; - hint?: string; - disabled?: boolean | string; + label: string; + value: T; + hint?: string; + disabled?: boolean | string; }; export type ChoiceInput = Choice | T; @@ -42,81 +42,78 @@ export type ChoiceInput = Choice | T; export type PromptInfo = string | ((value: T | null) => string | null | undefined); export type TextPromptOptions = BasePromptOptions & { - label?: string; - options?: string[] | ((query: string) => MaybePromise); - placeholder?: string; - transform?: (value: string) => MaybePromise; + label?: string; + options?: string[] | ((query: string) => MaybePromise); + placeholder?: string; + transform?: (value: string) => MaybePromise; }; export type NumberPromptOptions = BasePromptOptions & { - label?: string; - min?: number; - max?: number; - integer?: boolean; - step?: number; - transform?: (value: number) => MaybePromise; + label?: string; + min?: number; + max?: number; + integer?: boolean; + step?: number; + transform?: (value: number) => MaybePromise; }; export type ConfirmPromptOptions = BasePromptOptions & { - label?: string; - yes?: string; - no?: string; + label?: string; + yes?: string; + no?: string; }; export type SelectPromptOptions = BasePromptOptions & { - label?: string; - options: Array>; - scroll?: number; - info?: PromptInfo; + label?: string; + options: Array>; + scroll?: number; + info?: PromptInfo; }; export type MultiSelectPromptOptions = BasePromptOptions & { - label?: string; - options: Array>; - scroll?: number; - info?: PromptInfo; + label?: string; + options: Array>; + scroll?: number; + info?: PromptInfo; }; export type SearchPromptOptions = BasePromptOptions & { - label?: string; - options: Array> | Record | ((query: string) => MaybePromise> | Record>); - placeholder?: string; - scroll?: number; - info?: PromptInfo; + label?: string; + options: Array> | Record | ((query: string) => MaybePromise> | Record>); + placeholder?: string; + scroll?: number; + info?: PromptInfo; }; export type MultiSearchPromptOptions = BasePromptOptions & { - label?: string; - options: Array> | Record | ((query: string) => MaybePromise> | Record>); - placeholder?: string; - scroll?: number; - info?: PromptInfo; + label?: string; + options: Array> | Record | ((query: string) => MaybePromise> | Record>); + placeholder?: string; + scroll?: number; + info?: PromptInfo; }; export type TableCell = string | number | boolean | null | undefined; export type TableOptions = { - headers?: string[]; - rows: Array | Record>; + headers?: string[]; + rows: Array | Record>; }; export type DataTablePromptOptions = BasePromptOptions & { - filter?: (query: string, row: DataTableRow) => boolean; - headers?: string[]; - rows: Array>; - scroll?: number; + filter?: (query: string, row: DataTableRow) => boolean; + headers?: string[]; + rows: Array>; + scroll?: number; }; export type DataTableObjectRow = { - cells: Record; - value?: T; + cells: Record; + value?: T; }; -export type DataTableRow = - | Array - | Record - | DataTableObjectRow; +export type DataTableRow = Array | Record | DataTableObjectRow; export type StatusOptions = { - message: string; + message: string; }; diff --git a/packages/tui/tests/choices.test.ts b/packages/tui/tests/choices.test.ts index 7a0794fb..e2137178 100644 --- a/packages/tui/tests/choices.test.ts +++ b/packages/tui/tests/choices.test.ts @@ -2,150 +2,160 @@ import { describe, expect, it } from 'vitest'; import { createMemoryOutput, createScriptedInput, Key, multiselect, select, withPromptEnvironment } from '../src/index'; describe('choice prompts', () => { - it('selects with arrow keys and enter', async () => { - const output = createMemoryOutput(); - const result = await withPromptEnvironment( - { - input: createScriptedInput([Key.down, Key.enter]), - output, - error: output, - interactive: true - }, - () => select({ message: 'Pick one', options: ['first', 'second'] }) - ); - - expect(result).toBe('second'); - expect(output.text()).toContain('Pick one'); - }); - - it('renders a scrolling select window around the highlighted choice', async () => { - const output = createMemoryOutput(); - const result = await withPromptEnvironment( - { - input: createScriptedInput([Key.down, Key.down, Key.enter]), - output, - error: output, - interactive: true - }, - () => select({ message: 'Pick one', options: ['first', 'second', 'third', 'fourth'], scroll: 3 }) - ); - const latestFrame = output.text().split('Pick one\n').at(-1) ?? ''; - - expect(result).toBe('third'); - expect(latestFrame).not.toContain('first'); - expect(latestFrame).toContain('second'); - expect(latestFrame).toContain('third'); - expect(latestFrame).toContain('fourth'); - }); - - it('supports alternate select navigation keys', async () => { - const output = createMemoryOutput(); - const result = await withPromptEnvironment( - { - input: createScriptedInput([Key.tab, 'l', Key.right, 'h', Key.enter]), - output, - error: output, - interactive: true - }, - () => select({ message: 'Pick one', options: ['first', 'second', 'third'] }) - ); - - expect(result).toBe('third'); - }); - - it('supports home and end select navigation keys', async () => { - const output = createMemoryOutput(); - const result = await withPromptEnvironment( - { - input: createScriptedInput([Key.end[0], Key.home[0], Key.enter]), - output, - error: output, - interactive: true - }, - () => select({ message: 'Pick one', options: ['first', 'second', 'third'] }) - ); - - expect(result).toBe('first'); - }); - - it('renders select info for the highlighted option', async () => { - const output = createMemoryOutput(); - await withPromptEnvironment( - { - input: createScriptedInput([Key.down, Key.enter]), - output, - error: output, - interactive: true - }, - () => select({ message: 'Pick one', options: ['first', 'second'], info: (value) => `About ${value ?? 'none'}` }) - ); - - expect(output.text()).toContain('About first'); - expect(output.text()).toContain('About second'); - }); - - it('toggles multiselect choices with the space bar', async () => { - const output = createMemoryOutput(); - const result = await withPromptEnvironment( - { - input: createScriptedInput([Key.space, Key.down, Key.space, Key.enter]), - output, - error: output, - interactive: true - }, - () => multiselect({ message: 'Pick many', options: ['first', 'second'] }) - ); - - expect(result).toEqual(['first', 'second']); - expect(output.text()).toContain('Selected: first'); - expect(output.text()).toContain('Selected: first, second'); - }); - - it('requires multiselect choices when configured', async () => { - const output = createMemoryOutput(); - const result = await withPromptEnvironment( - { - input: createScriptedInput([Key.enter, Key.space, Key.enter]), - output, - error: output, - interactive: true - }, - () => multiselect({ message: 'Pick many', options: ['first', 'second'], required: true }) - ); - - expect(result).toEqual(['first']); - expect(output.text()).toContain('A value is required.'); - }); - - it('toggles all multiselect choices with ctrl-a', async () => { - const output = createMemoryOutput(); - const result = await withPromptEnvironment( - { - input: createScriptedInput([Key.ctrlA, Key.enter]), - output, - error: output, - interactive: true - }, - () => multiselect({ message: 'Pick many', options: ['first', 'second', 'third'] }) - ); - - expect(result).toEqual(['first', 'second', 'third']); - expect(output.text()).toContain('Selected: first, second, third'); - }); - - it('renders multiselect info for the highlighted option', async () => { - const output = createMemoryOutput(); - await withPromptEnvironment( - { - input: createScriptedInput([Key.down, Key.enter]), - output, - error: output, - interactive: true - }, - () => multiselect({ message: 'Pick many', options: ['first', 'second'], info: (value) => `About ${value ?? 'none'}` }) - ); - - expect(output.text()).toContain('About first'); - expect(output.text()).toContain('About second'); - }); + it('selects with arrow keys and enter', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.enter]), + output, + error: output, + interactive: true, + }, + () => select({ message: 'Pick one', options: ['first', 'second'] }), + ); + + expect(result).toBe('second'); + expect(output.text()).toContain('Pick one'); + }); + + it('renders a scrolling select window around the highlighted choice', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.down, Key.enter]), + output, + error: output, + interactive: true, + }, + () => select({ message: 'Pick one', options: ['first', 'second', 'third', 'fourth'], scroll: 3 }), + ); + + const latestFrame = output.text().split('Pick one\n').at(-1) ?? ''; + + expect(result).toBe('third'); + expect(latestFrame).not.toContain('first'); + expect(latestFrame).toContain('second'); + expect(latestFrame).toContain('third'); + expect(latestFrame).toContain('fourth'); + }); + + it('supports alternate select navigation keys', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.tab, 'l', Key.right, 'h', Key.enter]), + output, + error: output, + interactive: true, + }, + () => select({ message: 'Pick one', options: ['first', 'second', 'third'] }), + ); + + expect(result).toBe('third'); + }); + + it('supports home and end select navigation keys', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.end[0], Key.home[0], Key.enter]), + output, + error: output, + interactive: true, + }, + () => select({ message: 'Pick one', options: ['first', 'second', 'third'] }), + ); + + expect(result).toBe('first'); + }); + + it('renders select info for the highlighted option', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.enter]), + output, + error: output, + interactive: true, + }, + () => select({ message: 'Pick one', options: ['first', 'second'], info: (value) => `About ${value ?? 'none'}` }), + ); + + expect(output.text()).toContain('About first'); + expect(output.text()).toContain('About second'); + }); + + it('toggles multiselect choices with the space bar', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.space, Key.down, Key.space, Key.enter]), + output, + error: output, + interactive: true, + }, + () => multiselect({ message: 'Pick many', options: ['first', 'second'] }), + ); + + expect(result).toEqual(['first', 'second']); + expect(output.text()).toContain('Selected: first'); + expect(output.text()).toContain('Selected: first, second'); + }); + + it('requires multiselect choices when configured', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.enter, Key.space, Key.enter]), + output, + error: output, + interactive: true, + }, + () => multiselect({ message: 'Pick many', options: ['first', 'second'], required: true }), + ); + + expect(result).toEqual(['first']); + expect(output.text()).toContain('A value is required.'); + }); + + it('toggles all multiselect choices with ctrl-a', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.ctrlA, Key.enter]), + output, + error: output, + interactive: true, + }, + () => multiselect({ message: 'Pick many', options: ['first', 'second', 'third'] }), + ); + + expect(result).toEqual(['first', 'second', 'third']); + expect(output.text()).toContain('Selected: first, second, third'); + }); + + it('renders multiselect info for the highlighted option', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.enter]), + output, + error: output, + interactive: true, + }, + () => multiselect({ message: 'Pick many', options: ['first', 'second'], info: (value) => `About ${value ?? 'none'}` }), + ); + + expect(output.text()).toContain('About first'); + expect(output.text()).toContain('About second'); + }); }); diff --git a/packages/tui/tests/form.test.ts b/packages/tui/tests/form.test.ts index 428aa78b..02c1bc0d 100644 --- a/packages/tui/tests/form.test.ts +++ b/packages/tui/tests/form.test.ts @@ -2,127 +2,143 @@ import { describe, expect, it } from 'vitest'; import { createMemoryOutput, createScriptedInput, form, Key, withPromptEnvironment } from '../src/index'; describe('form builder', () => { - it('runs chained steps and returns positional responses', async () => { - const output = createMemoryOutput(); - const responses = await withPromptEnvironment( - { - input: createScriptedInput(['A', 'd', 'a', Key.enter, Key.enter, Key.enter]), - output, - error: output, - interactive: true - }, - () => - form() - .text('Name') - .select('Runtime', ['TS', 'JS']) - .confirm('Active') - .submit() - ); - - expect(responses[0]).toBe('Ada'); - expect(responses[1]).toBe('TS'); - expect(responses[2]).toBe(true); - }); - - it('keys named responses and passes prior responses to custom steps', async () => { - const output = createMemoryOutput(); - const responses = await withPromptEnvironment( - { - input: createScriptedInput(['A', 'd', 'a', Key.enter]), - output, - error: output, - interactive: true - }, - () => - form() - .text('Name', '', '', false, undefined, '', 'name') - .add((values) => `Hello ${values.name}`, 'greeting') - .submit() - ); - - expect(responses.name).toBe('Ada'); - expect(responses.greeting).toBe('Hello Ada'); - }); - - it('stores null for skipped conditional steps', async () => { - const responses = await withPromptEnvironment( - { - input: createScriptedInput(['n']), - output: createMemoryOutput(), - error: createMemoryOutput(), - interactive: true - }, - () => - form() - .confirm('Include details?', false, 'Yes', 'No', false, undefined, '', 'include') - .addIf((values) => values.include === true, () => 'details', 'details') - .submit() - ); - - expect(responses.include).toBe(false); - expect(responses.details).toBeNull(); - }); - - it('runs suggest, search, and multisearch form steps', async () => { - const output = createMemoryOutput(); - const responses = await withPromptEnvironment( - { - input: createScriptedInput(['b', Key.tab, Key.enter, 'g', Key.down, Key.enter, 'r', Key.down, Key.space, Key.enter]), - output, - error: output, - interactive: true - }, - () => - form() - .suggest('Suggested color', ['Red', 'Green', 'Blue'], '', 5, false, undefined, '', 'suggested') - .search({ - message: 'Searched color', - options: (value) => { - const options = { green: 'Green', blue: 'Blue', red: 'Red' }; - - return Object.fromEntries(Object.entries(options).filter(([, label]) => label.toLowerCase().includes(value.toLowerCase()))); - } - }, 'searched') - .multisearch({ - message: 'Many colors', - options: (value) => { - const options = { red: 'Red', green: 'Green', blue: 'Blue' }; - - return Object.fromEntries(Object.entries(options).filter(([, label]) => label.toLowerCase().includes(value.toLowerCase()))); - } - }, 'many') - .submit() - ); - - expect(responses.suggested).toBe('Blue'); - expect(responses.searched).toBe('green'); - expect(responses.many).toEqual(['red']); - }); - - it('runs task, pause, and stream form steps', async () => { - const output = createMemoryOutput(); - const responses = await withPromptEnvironment( - { - input: createScriptedInput([Key.enter]), - output, - error: output, - interactive: true - }, - () => - form() - .task('Build', (logger) => { - logger.log('done'); - - return 1; - }, 10, false, '', 'task') - .pause('Continue') - .stream(['line one\n'], 'streamed') - .submit() - ); - - expect(responses.task).toBe(1); - expect(responses.streamed).toBeNull(); - expect(output.text()).toContain('Build'); - expect(output.text()).toContain('line one'); - }); + it('runs chained steps and returns positional responses', async () => { + const output = createMemoryOutput(); + + const responses = await withPromptEnvironment( + { + input: createScriptedInput(['A', 'd', 'a', Key.enter, Key.enter, Key.enter]), + output, + error: output, + interactive: true, + }, + () => form().text('Name').select('Runtime', ['TS', 'JS']).confirm('Active').submit(), + ); + + expect(responses[0]).toBe('Ada'); + expect(responses[1]).toBe('TS'); + expect(responses[2]).toBe(true); + }); + + it('keys named responses and passes prior responses to custom steps', async () => { + const output = createMemoryOutput(); + + const responses = await withPromptEnvironment( + { + input: createScriptedInput(['A', 'd', 'a', Key.enter]), + output, + error: output, + interactive: true, + }, + () => + form() + .text('Name', '', '', false, undefined, '', 'name') + .add((values) => `Hello ${values.name}`, 'greeting') + .submit(), + ); + + expect(responses.name).toBe('Ada'); + expect(responses.greeting).toBe('Hello Ada'); + }); + + it('stores null for skipped conditional steps', async () => { + const responses = await withPromptEnvironment( + { + input: createScriptedInput(['n']), + output: createMemoryOutput(), + error: createMemoryOutput(), + interactive: true, + }, + () => + form() + .confirm('Include details?', false, 'Yes', 'No', false, undefined, '', 'include') + .addIf( + (values) => values.include === true, + () => 'details', + 'details', + ) + .submit(), + ); + + expect(responses.include).toBe(false); + expect(responses.details).toBeNull(); + }); + + it('runs suggest, search, and multisearch form steps', async () => { + const output = createMemoryOutput(); + + const responses = await withPromptEnvironment( + { + input: createScriptedInput(['b', Key.tab, Key.enter, 'g', Key.down, Key.enter, 'r', Key.down, Key.space, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + form() + .suggest('Suggested color', ['Red', 'Green', 'Blue'], '', 5, false, undefined, '', 'suggested') + .search( + { + message: 'Searched color', + options: (value) => { + const options = { green: 'Green', blue: 'Blue', red: 'Red' }; + + return Object.fromEntries(Object.entries(options).filter(([, label]) => label.toLowerCase().includes(value.toLowerCase()))); + }, + }, + 'searched', + ) + .multisearch( + { + message: 'Many colors', + options: (value) => { + const options = { red: 'Red', green: 'Green', blue: 'Blue' }; + + return Object.fromEntries(Object.entries(options).filter(([, label]) => label.toLowerCase().includes(value.toLowerCase()))); + }, + }, + 'many', + ) + .submit(), + ); + + expect(responses.suggested).toBe('Blue'); + expect(responses.searched).toBe('green'); + expect(responses.many).toEqual(['red']); + }); + + it('runs task, pause, and stream form steps', async () => { + const output = createMemoryOutput(); + + const responses = await withPromptEnvironment( + { + input: createScriptedInput([Key.enter]), + output, + error: output, + interactive: true, + }, + () => + form() + .task( + 'Build', + (logger) => { + logger.log('done'); + + return 1; + }, + 10, + false, + '', + 'task', + ) + .pause('Continue') + .stream(['line one\n'], 'streamed') + .submit(), + ); + + expect(responses.task).toBe(1); + expect(responses.streamed).toBeNull(); + expect(output.text()).toContain('Build'); + expect(output.text()).toContain('line one'); + }); }); diff --git a/packages/tui/tests/key.test.ts b/packages/tui/tests/key.test.ts index 7e45440f..e0d913d1 100644 --- a/packages/tui/tests/key.test.ts +++ b/packages/tui/tests/key.test.ts @@ -2,12 +2,12 @@ import { describe, expect, it } from 'vitest'; import { Key, keyFromEvent } from '../src/index'; describe('keyboard mapping', () => { - it('maps named keys', () => { - expect(keyFromEvent({ name: 'enter' })).toBe(Key.enter); - }); + it('maps named keys', () => { + expect(keyFromEvent({ name: 'enter' })).toBe(Key.enter); + }); - it('maps space and newline sequences', () => { - expect(keyFromEvent({ sequence: ' ' })).toBe(Key.space); - expect(keyFromEvent({ sequence: '\n' })).toBe(Key.enter); - }); + it('maps space and newline sequences', () => { + expect(keyFromEvent({ sequence: ' ' })).toBe(Key.space); + expect(keyFromEvent({ sequence: '\n' })).toBe(Key.enter); + }); }); diff --git a/packages/tui/tests/number.test.ts b/packages/tui/tests/number.test.ts index c23d1170..091aab32 100644 --- a/packages/tui/tests/number.test.ts +++ b/packages/tui/tests/number.test.ts @@ -2,35 +2,37 @@ import { describe, expect, it } from 'vitest'; import { createMemoryOutput, createScriptedInput, Key, number, withPromptEnvironment } from '../src/index'; describe('number prompt', () => { - it('rejects partial decimal input and accepts a retry', async () => { - const output = createMemoryOutput(); - const result = await withPromptEnvironment( - { - input: createScriptedInput(['1', '2', 'a', Key.enter, '4', '2', Key.enter]), - output, - error: output, - interactive: true - }, - () => number({ message: 'Amount' }) - ); + it('rejects partial decimal input and accepts a retry', async () => { + const output = createMemoryOutput(); - expect(result).toBe(42); - expect(output.text()).toContain('Please enter a valid number.'); - }); + const result = await withPromptEnvironment( + { + input: createScriptedInput(['1', '2', 'a', Key.enter, '4', '2', Key.enter]), + output, + error: output, + interactive: true, + }, + () => number({ message: 'Amount' }), + ); - it('rejects decimal values for integer prompts', async () => { - const output = createMemoryOutput(); - const result = await withPromptEnvironment( - { - input: createScriptedInput(['1', '.', '5', Key.enter, '2', Key.enter]), - output, - error: output, - interactive: true - }, - () => number({ message: 'Count', integer: true }) - ); + expect(result).toBe(42); + expect(output.text()).toContain('Please enter a valid number.'); + }); - expect(result).toBe(2); - expect(output.text()).toContain('Please enter a valid number.'); - }); + it('rejects decimal values for integer prompts', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['1', '.', '5', Key.enter, '2', Key.enter]), + output, + error: output, + interactive: true, + }, + () => number({ message: 'Count', integer: true }), + ); + + expect(result).toBe(2); + expect(output.text()).toContain('Please enter a valid number.'); + }); }); diff --git a/packages/tui/tests/progress.test.ts b/packages/tui/tests/progress.test.ts index 8e3d976a..980e8aa0 100644 --- a/packages/tui/tests/progress.test.ts +++ b/packages/tui/tests/progress.test.ts @@ -2,31 +2,32 @@ import { describe, expect, it } from 'vitest'; import { createMemoryOutput, progress, withPromptEnvironment } from '../src/index'; describe('progress helper', () => { - it('maps iterable steps and returns callback results', async () => { - const output = createMemoryOutput(); - const result = await withPromptEnvironment({ output, error: output }, async () => { - return progress('Uppercasing States', ['Alabama', 'Alaska'], (state) => String(state).toUpperCase()); - }); + it('maps iterable steps and returns callback results', async () => { + const output = createMemoryOutput(); - expect(result).toEqual(['ALABAMA', 'ALASKA']); - expect(output.text()).toContain('Uppercasing States'); - expect(output.text()).toContain('2 / 2'); - }); + const result = await withPromptEnvironment({ output, error: output }, async () => { + return progress('Uppercasing States', ['Alabama', 'Alaska'], (state) => String(state).toUpperCase()); + }); - it('supports manual progress updates with label and hint changes', async () => { - const output = createMemoryOutput(); + expect(result).toEqual(['ALABAMA', 'ALASKA']); + expect(output.text()).toContain('Uppercasing States'); + expect(output.text()).toContain('2 / 2'); + }); - await withPromptEnvironment({ output, error: output }, async () => { - const bar = progress('Adding States', 2); + it('supports manual progress updates with label and hint changes', async () => { + const output = createMemoryOutput(); - bar.start(); - bar.label('ALABAMA').hint('alabama').advance(); - bar.label('ALASKA').hint('alaska').advance(); - bar.finish(); - }); + await withPromptEnvironment({ output, error: output }, async () => { + const bar = progress('Adding States', 2); - expect(output.text()).toContain('Adding States'); - expect(output.text()).toContain('ALABAMA'); - expect(output.text()).toContain('alaska'); - }); + bar.start(); + bar.label('ALABAMA').hint('alabama').advance(); + bar.label('ALASKA').hint('alaska').advance(); + bar.finish(); + }); + + expect(output.text()).toContain('Adding States'); + expect(output.text()).toContain('ALABAMA'); + expect(output.text()).toContain('alaska'); + }); }); diff --git a/packages/tui/tests/search.test.ts b/packages/tui/tests/search.test.ts index 38c94ece..d80e8dcd 100644 --- a/packages/tui/tests/search.test.ts +++ b/packages/tui/tests/search.test.ts @@ -2,148 +2,156 @@ import { describe, expect, it } from 'vitest'; import { createMemoryOutput, createScriptedInput, Key, multisearch, search, withPromptEnvironment } from '../src/index'; const colors = (value: string): Record => { - const options = { - red: 'Red', - green: 'Green', - blue: 'Blue' - }; + const options = { + red: 'Red', + green: 'Green', + blue: 'Blue', + }; - return Object.fromEntries(Object.entries(options).filter(([, label]) => label.toLowerCase().includes(value.toLowerCase()))); + return Object.fromEntries(Object.entries(options).filter(([, label]) => label.toLowerCase().includes(value.toLowerCase()))); }; describe('search prompt', () => { - it('returns keys from associative options', async () => { - const output = createMemoryOutput(); - const result = await withPromptEnvironment( - { - input: createScriptedInput(['u', 'e', Key.down, Key.enter]), - output, - error: output, - interactive: true - }, - () => search({ message: 'Favorite color?', options: colors }) - ); - - expect(result).toBe('blue'); - expect(output.text()).toContain('Favorite color? ue'); - }); - - it('validates selected values and allows another selection', async () => { - const output = createMemoryOutput(); - const result = await withPromptEnvironment( - { - input: createScriptedInput([Key.down, Key.enter, Key.down, Key.enter]), - output, - error: output, - interactive: true - }, - () => - search({ - message: 'Favorite color?', - options: () => ({ red: 'Red', green: 'Green', blue: 'Blue' }), - validate: (value) => (value === 'red' ? 'Please choose green.' : null) - }) - ); - - expect(result).toBe('green'); - expect(output.text()).toContain('Please choose green.'); - }); - - it('renders the typed query while navigating results', async () => { - const output = createMemoryOutput(); - await withPromptEnvironment( - { - input: createScriptedInput(['r', Key.down, Key.enter]), - output, - error: output, - interactive: true - }, - () => search({ message: 'Favorite color?', options: colors }) - ); - - expect(output.text()).toContain('Favorite color? r'); - expect(output.text()).toContain('›'); - }); - - it('respects scroll windows when rendering results', async () => { - const output = createMemoryOutput(); - const result = await withPromptEnvironment( - { - input: createScriptedInput([Key.down, Key.down, Key.down, Key.enter]), - output, - error: output, - interactive: true - }, - () => - search({ - message: 'Pick number', - options: ['one', 'two', 'three', 'four'], - scroll: 3 - }) - ); - const latestFrame = output.text().split('Pick number\n').at(-1) ?? ''; - - expect(result).toBe('three'); - expect(latestFrame).not.toContain('one'); - expect(latestFrame).toContain('two'); - expect(latestFrame).toContain('three'); - expect(latestFrame).toContain('four'); - }); - - it('renders search info for the highlighted result', async () => { - const output = createMemoryOutput(); - await withPromptEnvironment( - { - input: createScriptedInput([Key.down, Key.enter]), - output, - error: output, - interactive: true - }, - () => search({ message: 'Favorite color?', options: colors, info: (value) => `About ${value ?? 'none'}` }) - ); - - expect(output.text()).toContain('About red'); - }); + it('returns keys from associative options', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['u', 'e', Key.down, Key.enter]), + output, + error: output, + interactive: true, + }, + () => search({ message: 'Favorite color?', options: colors }), + ); + + expect(result).toBe('blue'); + expect(output.text()).toContain('Favorite color? ue'); + }); + + it('validates selected values and allows another selection', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.enter, Key.down, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + search({ + message: 'Favorite color?', + options: () => ({ red: 'Red', green: 'Green', blue: 'Blue' }), + validate: (value) => (value === 'red' ? 'Please choose green.' : null), + }), + ); + + expect(result).toBe('green'); + expect(output.text()).toContain('Please choose green.'); + }); + + it('renders the typed query while navigating results', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment( + { + input: createScriptedInput(['r', Key.down, Key.enter]), + output, + error: output, + interactive: true, + }, + () => search({ message: 'Favorite color?', options: colors }), + ); + + expect(output.text()).toContain('Favorite color? r'); + expect(output.text()).toContain('›'); + }); + + it('respects scroll windows when rendering results', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.down, Key.down, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + search({ + message: 'Pick number', + options: ['one', 'two', 'three', 'four'], + scroll: 3, + }), + ); + + const latestFrame = output.text().split('Pick number\n').at(-1) ?? ''; + + expect(result).toBe('three'); + expect(latestFrame).not.toContain('one'); + expect(latestFrame).toContain('two'); + expect(latestFrame).toContain('three'); + expect(latestFrame).toContain('four'); + }); + + it('renders search info for the highlighted result', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.enter]), + output, + error: output, + interactive: true, + }, + () => search({ message: 'Favorite color?', options: colors, info: (value) => `About ${value ?? 'none'}` }), + ); + + expect(output.text()).toContain('About red'); + }); }); describe('multisearch prompt', () => { - it('toggles highlighted search results', async () => { - const output = createMemoryOutput(); - const result = await withPromptEnvironment( - { - input: createScriptedInput(['V', Key.down, Key.space, Key.backspace, 'G', 'r', Key.down, Key.space, Key.enter]), - output, - error: output, - interactive: true - }, - () => - multisearch({ - message: 'Favorite colors?', - options: (value) => { - const options = { green: 'Green', violet: 'Violet' }; - - return Object.fromEntries(Object.entries(options).filter(([, label]) => label.toLowerCase().includes(value.toLowerCase()))); - } - }) - ); - - expect(result).toEqual(['violet', 'green']); - expect(output.text()).toContain('Selected: Violet'); - expect(output.text()).toContain('Selected: Violet, Green'); - }); - - it('renders multisearch info for the highlighted result', async () => { - const output = createMemoryOutput(); - await withPromptEnvironment( - { - input: createScriptedInput([Key.down, Key.enter]), - output, - error: output, - interactive: true - }, - () => multisearch({ message: 'Favorite colors?', options: colors, info: (value) => `About ${value ?? 'none'}` }) - ); - - expect(output.text()).toContain('About red'); - }); + it('toggles highlighted search results', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['V', Key.down, Key.space, Key.backspace, 'G', 'r', Key.down, Key.space, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + multisearch({ + message: 'Favorite colors?', + options: (value) => { + const options = { green: 'Green', violet: 'Violet' }; + + return Object.fromEntries(Object.entries(options).filter(([, label]) => label.toLowerCase().includes(value.toLowerCase()))); + }, + }), + ); + + expect(result).toEqual(['violet', 'green']); + expect(output.text()).toContain('Selected: Violet'); + expect(output.text()).toContain('Selected: Violet, Green'); + }); + + it('renders multisearch info for the highlighted result', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.enter]), + output, + error: output, + interactive: true, + }, + () => multisearch({ message: 'Favorite colors?', options: colors, info: (value) => `About ${value ?? 'none'}` }), + ); + + expect(output.text()).toContain('About red'); + }); }); diff --git a/packages/tui/tests/strings.test.ts b/packages/tui/tests/strings.test.ts index a2c9e77a..e043d00d 100644 --- a/packages/tui/tests/strings.test.ts +++ b/packages/tui/tests/strings.test.ts @@ -2,19 +2,19 @@ import { describe, expect, it } from 'vitest'; import { parseAnsiText, truncate, visibleWidth, wrap } from '../src/index'; describe('string utilities', () => { - it('measures visible width without ANSI control sequences', () => { - expect(visibleWidth('\u001B[31mPrompts\u001B[39m')).toBe(7); - }); + it('measures visible width without ANSI control sequences', () => { + expect(visibleWidth('\u001B[31mPrompts\u001B[39m')).toBe(7); + }); - it('truncates by visible width', () => { - expect(truncate('Ollin Prompts', 10)).toBe('Ollin P...'); - }); + it('truncates by visible width', () => { + expect(truncate('Ollin Prompts', 10)).toBe('Ollin P...'); + }); - it('wraps words to the requested width', () => { - expect(wrap('Ollin Prompts', 7)).toEqual(['Ollin', 'Prompts']); - }); + it('wraps words to the requested width', () => { + expect(wrap('Ollin Prompts', 7)).toEqual(['Ollin', 'Prompts']); + }); - it('strips ANSI text', () => { - expect(parseAnsiText('\u001B[32mDone\u001B[39m')).toBe('Done'); - }); + it('strips ANSI text', () => { + expect(parseAnsiText('\u001B[32mDone\u001B[39m')).toBe('Done'); + }); }); diff --git a/packages/tui/tests/suggest.test.ts b/packages/tui/tests/suggest.test.ts index 2e52aaf3..4658a141 100644 --- a/packages/tui/tests/suggest.test.ts +++ b/packages/tui/tests/suggest.test.ts @@ -2,85 +2,87 @@ import { describe, expect, it } from 'vitest'; import { createMemoryOutput, createScriptedInput, Key, suggest, withPromptEnvironment } from '../src/index'; const runSuggest = async (keys: string[], options: string[] | ((query: string) => string[])): Promise<{ output: string; result: string }> => { - const output = createMemoryOutput(); - const result = await withPromptEnvironment( - { - input: createScriptedInput(keys), - output, - error: output, - interactive: true - }, - () => suggest('Favorite color?', options) - ); - - return { output: output.text(), result }; + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(keys), + output, + error: output, + interactive: true, + }, + () => suggest('Favorite color?', options), + ); + + return { output: output.text(), result }; }; const expectSuggestion = async (keys: string[], options: string[] | ((query: string) => string[]), result: string): Promise => { - await expect(runSuggest(keys, options)).resolves.toMatchObject({ result }); + await expect(runSuggest(keys, options)).resolves.toMatchObject({ result }); }; const outputFor = async (keys: string[], options: string[] | ((query: string) => string[])): Promise => { - const output = createMemoryOutput(); - - await withPromptEnvironment( - { - input: createScriptedInput(keys), - output, - error: output, - interactive: true - }, - () => suggest('Favorite color?', options) - ); - - return output.text(); + const output = createMemoryOutput(); + + await withPromptEnvironment( + { + input: createScriptedInput(keys), + output, + error: output, + interactive: true, + }, + () => suggest('Favorite color?', options), + ); + + return output.text(); }; describe('suggest prompt', () => { - it('accepts arbitrary typed input', async () => { - await expectSuggestion(['B', 'l', 'a', 'c', 'k', Key.enter], ['Red', 'Green', 'Blue'], 'Black'); - }); - - it('completes input using tab', async () => { - await expectSuggestion(['b', Key.tab, Key.enter], ['Red', 'Green', 'Blue'], 'Blue'); - }); - - it('navigates matches using arrow and emacs keys', async () => { - await expectSuggestion(['b', Key.down, Key.down, Key.down, Key.up, Key.enter], ['Red', 'Blue', 'Black', 'Blurple'], 'Black'); - await expectSuggestion(['b', Key.ctrlN, Key.ctrlN, Key.ctrlN, Key.ctrlP, Key.enter], ['Red', 'Blue', 'Black', 'Blurple'], 'Black'); - }); - - it('supports callback options', async () => { - await expectSuggestion(['e', 'e', Key.down, Key.enter], (value) => ['Red', 'Green', 'Blue'].filter((option) => option.toLowerCase().includes(value.toLowerCase())), 'Green'); - }); - - it('renders typed queries and suggestion matches', async () => { - const output = await outputFor(['b', Key.down, Key.enter], ['Red', 'Green', 'Blue']); - - expect(output).toContain('Favorite color? b'); - expect(output).toContain('›'); - expect(output).toContain('Blue'); - }); - - it('renders completed values after tab completion', async () => { - const output = await outputFor(['b', Key.tab, Key.enter], ['Red', 'Green', 'Blue']); - - expect(output).toContain('Favorite color? Blue'); - }); - - it('renders suggest info for the highlighted result', async () => { - const output = createMemoryOutput(); - - await withPromptEnvironment( - { - input: createScriptedInput(['b', Key.down, Key.enter]), - output, - error: output, - interactive: true - }, - () => suggest({ message: 'Favorite color?', options: ['Red', 'Green', 'Blue'], info: (value) => `About ${value ?? 'none'}` }) - ); - - expect(output.text()).toContain('About Blue'); - }); + it('accepts arbitrary typed input', async () => { + await expectSuggestion(['B', 'l', 'a', 'c', 'k', Key.enter], ['Red', 'Green', 'Blue'], 'Black'); + }); + + it('completes input using tab', async () => { + await expectSuggestion(['b', Key.tab, Key.enter], ['Red', 'Green', 'Blue'], 'Blue'); + }); + + it('navigates matches using arrow and emacs keys', async () => { + await expectSuggestion(['b', Key.down, Key.down, Key.down, Key.up, Key.enter], ['Red', 'Blue', 'Black', 'Blurple'], 'Black'); + + await expectSuggestion(['b', Key.ctrlN, Key.ctrlN, Key.ctrlN, Key.ctrlP, Key.enter], ['Red', 'Blue', 'Black', 'Blurple'], 'Black'); + }); + + it('supports callback options', async () => { + await expectSuggestion(['e', 'e', Key.down, Key.enter], (value) => ['Red', 'Green', 'Blue'].filter((option) => option.toLowerCase().includes(value.toLowerCase())), 'Green'); + }); + + it('renders typed queries and suggestion matches', async () => { + const output = await outputFor(['b', Key.down, Key.enter], ['Red', 'Green', 'Blue']); + + expect(output).toContain('Favorite color? b'); + expect(output).toContain('›'); + expect(output).toContain('Blue'); + }); + + it('renders completed values after tab completion', async () => { + const output = await outputFor(['b', Key.tab, Key.enter], ['Red', 'Green', 'Blue']); + + expect(output).toContain('Favorite color? Blue'); + }); + + it('renders suggest info for the highlighted result', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment( + { + input: createScriptedInput(['b', Key.down, Key.enter]), + output, + error: output, + interactive: true, + }, + () => suggest({ message: 'Favorite color?', options: ['Red', 'Green', 'Blue'], info: (value) => `About ${value ?? 'none'}` }), + ); + + expect(output.text()).toContain('About Blue'); + }); }); diff --git a/packages/tui/tests/table.test.ts b/packages/tui/tests/table.test.ts index f5e9cfc7..fe6e616e 100644 --- a/packages/tui/tests/table.test.ts +++ b/packages/tui/tests/table.test.ts @@ -2,109 +2,116 @@ import { describe, expect, it } from 'vitest'; import { createMemoryOutput, createScriptedInput, datatable, form, Key, table, withPromptEnvironment } from '../src/index'; describe('table output', () => { - it('renders static table rows', async () => { - const output = createMemoryOutput(); + it('renders static table rows', async () => { + const output = createMemoryOutput(); - await withPromptEnvironment({ output, error: output }, async () => { - table(['Name', 'Runtime'], [['Ollin', 'OpenTUI']]); - }); + await withPromptEnvironment({ output, error: output }, async () => { + table(['Name', 'Runtime'], [['Ollin', 'OpenTUI']]); + }); - expect(output.text()).toContain('| Name | Runtime |'); - expect(output.text()).toContain('| Ollin | OpenTUI |'); - }); + expect(output.text()).toContain('| Name | Runtime |'); + expect(output.text()).toContain('| Ollin | OpenTUI |'); + }); }); describe('data table prompt', () => { - it('returns the selected explicit row value', async () => { - const output = createMemoryOutput(); - const selected = { id: 2 }; - const result = await withPromptEnvironment( - { - input: createScriptedInput([Key.down, Key.enter]), - output, - error: output, - interactive: true - }, - () => - datatable({ - message: 'Pick project', - headers: ['Name', 'Runtime'], - rows: [ - { value: { id: 1 }, cells: { Name: 'Alpha', Runtime: 'Node' } }, - { value: selected, cells: { Name: 'Beta', Runtime: 'OpenTUI' } } - ] - }) - ); + it('returns the selected explicit row value', async () => { + const output = createMemoryOutput(); + const selected = { id: 2 }; - expect(result).toBe(selected); - expect(output.text()).toContain('Pick project'); - expect(output.text()).toContain('›'); - }); + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + datatable({ + message: 'Pick project', + headers: ['Name', 'Runtime'], + rows: [ + { value: { id: 1 }, cells: { Name: 'Alpha', Runtime: 'Node' } }, + { value: selected, cells: { Name: 'Beta', Runtime: 'OpenTUI' } }, + ], + }), + ); - it('filters rows from typed input', async () => { - const output = createMemoryOutput(); - const result = await withPromptEnvironment( - { - input: createScriptedInput(['B', Key.enter]), - output, - error: output, - interactive: true - }, - () => - datatable({ - message: 'Pick project', - rows: [ - { Name: 'Alpha', value: 'alpha' }, - { Name: 'Beta', value: 'beta' } - ] - }) - ); + expect(result).toBe(selected); + expect(output.text()).toContain('Pick project'); + expect(output.text()).toContain('›'); + }); - expect(result).toBe('beta'); - expect(output.text()).toContain('Pick project B'); - }); + it('filters rows from typed input', async () => { + const output = createMemoryOutput(); - it('falls back to row indexes when rows have no explicit value', async () => { - const output = createMemoryOutput(); - const result = await withPromptEnvironment( - { - input: createScriptedInput([Key.down, Key.enter]), - output, - error: output, - interactive: true - }, - () => - datatable({ - message: 'Pick row', - headers: ['Name'], - rows: [['First'], ['Second']] - }) - ); + const result = await withPromptEnvironment( + { + input: createScriptedInput(['B', Key.enter]), + output, + error: output, + interactive: true, + }, + () => + datatable({ + message: 'Pick project', + rows: [ + { Name: 'Alpha', value: 'alpha' }, + { Name: 'Beta', value: 'beta' }, + ], + }), + ); - expect(result).toBe(1); - }); + expect(result).toBe('beta'); + expect(output.text()).toContain('Pick project B'); + }); - it('works from form builders', async () => { - const output = createMemoryOutput(); - const responses = await withPromptEnvironment( - { - input: createScriptedInput([Key.down, Key.enter]), - output, - error: output, - interactive: true - }, - () => - form() - .datatable({ - message: 'Pick project', - rows: [ - { Name: 'Alpha', value: 'alpha' }, - { Name: 'Beta', value: 'beta' } - ] - }, 'project') - .submit() - ); + it('falls back to row indexes when rows have no explicit value', async () => { + const output = createMemoryOutput(); - expect(responses.project).toBe('beta'); - }); + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + datatable({ + message: 'Pick row', + headers: ['Name'], + rows: [['First'], ['Second']], + }), + ); + + expect(result).toBe(1); + }); + + it('works from form builders', async () => { + const output = createMemoryOutput(); + + const responses = await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + form() + .datatable( + { + message: 'Pick project', + rows: [ + { Name: 'Alpha', value: 'alpha' }, + { Name: 'Beta', value: 'beta' }, + ], + }, + 'project', + ) + .submit(), + ); + + expect(responses.project).toBe('beta'); + }); }); diff --git a/packages/tui/tests/task.test.ts b/packages/tui/tests/task.test.ts index 1a3440de..d2ff26e3 100644 --- a/packages/tui/tests/task.test.ts +++ b/packages/tui/tests/task.test.ts @@ -2,34 +2,39 @@ import { describe, expect, it } from 'vitest'; import { createMemoryOutput, task, withPromptEnvironment } from '../src/index'; describe('task helper', () => { - it('passes a bounded logger to the callback and returns the callback value', async () => { - const output = createMemoryOutput(); - const result = await withPromptEnvironment({ output, error: output }, async () => { - return task('Running...', (logger) => { - logger.log('line one'); - logger.log('line two'); - logger.log('line three'); + it('passes a bounded logger to the callback and returns the callback value', async () => { + const output = createMemoryOutput(); - return 'done'; - }, 2); - }); + const result = await withPromptEnvironment({ output, error: output }, async () => { + return task( + 'Running...', + (logger) => { + logger.log('line one'); + logger.log('line two'); + logger.log('line three'); - expect(result).toBe('done'); - expect(output.text()).toContain('Running...'); - expect(output.text()).not.toContain('line one'); - expect(output.text()).toContain('line two'); - expect(output.text()).toContain('line three'); - }); + return 'done'; + }, + 2, + ); + }); - it('strips cursor reset control sequences from log lines', async () => { - const output = createMemoryOutput(); + expect(result).toBe('done'); + expect(output.text()).toContain('Running...'); + expect(output.text()).not.toContain('line one'); + expect(output.text()).toContain('line two'); + expect(output.text()).toContain('line three'); + }); - await withPromptEnvironment({ output, error: output }, async () => { - await task('Running...', (logger) => { - logger.log('before\u001B[1G\u001B[2Kafter'); - }); - }); + it('strips cursor reset control sequences from log lines', async () => { + const output = createMemoryOutput(); - expect(output.text()).toContain('beforeafter'); - }); + await withPromptEnvironment({ output, error: output }, async () => { + await task('Running...', (logger) => { + logger.log('before\u001B[1G\u001B[2Kafter'); + }); + }); + + expect(output.text()).toContain('beforeafter'); + }); }); diff --git a/packages/tui/tests/textarea.test.ts b/packages/tui/tests/textarea.test.ts index c0613bd4..257ffe7c 100644 --- a/packages/tui/tests/textarea.test.ts +++ b/packages/tui/tests/textarea.test.ts @@ -2,19 +2,20 @@ import { describe, expect, it } from 'vitest'; import { createMemoryOutput, createScriptedInput, Key, textarea, withPromptEnvironment } from '../src/index'; describe('textarea prompt', () => { - it('accepts multiline input and submits with ctrl-d', async () => { - const output = createMemoryOutput(); - const result = await withPromptEnvironment( - { - input: createScriptedInput(['A', Key.enter, 'B', Key.ctrlD]), - output, - error: output, - interactive: true - }, - () => textarea('Description') - ); + it('accepts multiline input and submits with ctrl-d', async () => { + const output = createMemoryOutput(); - expect(result).toBe('A\nB'); - expect(output.text()).toContain('Description'); - }); + const result = await withPromptEnvironment( + { + input: createScriptedInput(['A', Key.enter, 'B', Key.ctrlD]), + output, + error: output, + interactive: true, + }, + () => textarea('Description'), + ); + + expect(result).toBe('A\nB'); + expect(output.text()).toContain('Description'); + }); }); diff --git a/packages/tui/tests/typed-value.test.ts b/packages/tui/tests/typed-value.test.ts index 0c98498f..11ebc99b 100644 --- a/packages/tui/tests/typed-value.test.ts +++ b/packages/tui/tests/typed-value.test.ts @@ -2,51 +2,53 @@ import { describe, expect, it } from 'vitest'; import { applyTypedKey, Key } from '../src/index'; const apply = (keys: string[]): string => { - let state = { cursor: 0, value: '' }; + let state = { cursor: 0, value: '' }; - for (const key of keys) { - const next = applyTypedKey(state, key); - state = { cursor: next.cursor, value: next.value }; - } + for (const key of keys) { + const next = applyTypedKey(state, key); - return state.value; + state = { cursor: next.cursor, value: next.value }; + } + + return state.value; }; const applyMultiline = (keys: string[]): { submitted: boolean; value: string } => { - let state = { cursor: 0, value: '' }; - let submitted = false; + let state = { cursor: 0, value: '' }; + let submitted = false; + + for (const key of keys) { + const next = applyTypedKey(state, key, true); - for (const key of keys) { - const next = applyTypedKey(state, key, true); - state = { cursor: next.cursor, value: next.value }; - submitted = next.submitted; - } + state = { cursor: next.cursor, value: next.value }; + submitted = next.submitted; + } - return { submitted, value: state.value }; + return { submitted, value: state.value }; }; describe('typed value editing', () => { - it('inserts buffered characters', () => { - expect(apply(['Je', 'ss'])).toBe('Jess'); - }); - - it('handles backspace and delete', () => { - expect(apply(['J', 'e', 'z', Key.backspace, 's', 's'])).toBe('Jess'); - expect(apply(['J', 'e', 'z', Key.left, Key.delete, 's', 's'])).toBe('Jess'); - }); - - it('supports emacs-style movement keys', () => { - expect(apply(['J', 'z', 'e', Key.ctrlB, Key.ctrlH, Key.ctrlF, 's', 's'])).toBe('Jess'); - }); - - it('moves to the start and end of a line', () => { - expect(apply(['A', 'r', Key.home[0], 'J', Key.end[0], 'c', 'h', 'e', 'r'])).toBe('JArcher'); - }); - - it('submits multiline input with ctrl-d', () => { - expect(applyMultiline(['A', Key.enter, 'B', Key.ctrlD])).toEqual({ - submitted: true, - value: 'A\nB' - }); - }); + it('inserts buffered characters', () => { + expect(apply(['Je', 'ss'])).toBe('Jess'); + }); + + it('handles backspace and delete', () => { + expect(apply(['J', 'e', 'z', Key.backspace, 's', 's'])).toBe('Jess'); + expect(apply(['J', 'e', 'z', Key.left, Key.delete, 's', 's'])).toBe('Jess'); + }); + + it('supports emacs-style movement keys', () => { + expect(apply(['J', 'z', 'e', Key.ctrlB, Key.ctrlH, Key.ctrlF, 's', 's'])).toBe('Jess'); + }); + + it('moves to the start and end of a line', () => { + expect(apply(['A', 'r', Key.home[0], 'J', Key.end[0], 'c', 'h', 'e', 'r'])).toBe('JArcher'); + }); + + it('submits multiline input with ctrl-d', () => { + expect(applyMultiline(['A', Key.enter, 'B', Key.ctrlD])).toEqual({ + submitted: true, + value: 'A\nB', + }); + }); }); diff --git a/packages/tui/vitest.config.ts b/packages/tui/vitest.config.ts index 262b8b24..dfeeb7cb 100644 --- a/packages/tui/vitest.config.ts +++ b/packages/tui/vitest.config.ts @@ -1,8 +1,8 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ - test: { - environment: 'node', - globals: false - } + test: { + environment: 'node', + globals: false, + }, }); From 3fa22f88d97164a8f81dad0666d9ae0b70975691 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 14:30:31 +0800 Subject: [PATCH 023/675] Support multisearch bulk selection keys --- packages/tui/src/prompts/search.ts | 36 ++++++++++++++++++++++++++++++ packages/tui/tests/search.test.ts | 34 ++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/packages/tui/src/prompts/search.ts b/packages/tui/src/prompts/search.ts index 721f8b25..8bc18e9e 100644 --- a/packages/tui/src/prompts/search.ts +++ b/packages/tui/src/prompts/search.ts @@ -169,6 +169,7 @@ const readMultiSearchChoices = async (options: MultiSearchPromptOptions): let highlighted: number | null = null; const selected = new Map(); + const selectableChoices = (): Array> => choices.filter((choice) => !choice.disabled); const render = (): void => { const marked = new Set(choices.flatMap((choice, index) => (selected.has(choice.value) ? [index] : []))); @@ -201,6 +202,41 @@ const readMultiSearchChoices = async (options: MultiSearchPromptOptions): continue; } + if (oneOf([Key.home], key) && highlighted !== null) { + highlighted = 0; + render(); + continue; + } + + if (oneOf([Key.end], key) && highlighted !== null) { + highlighted = Math.max(0, choices.length - 1); + render(); + continue; + } + + if (key === Key.ctrlE && highlighted !== null) { + render(); + continue; + } + + if (key === Key.ctrlA && highlighted !== null) { + const currentChoices = selectableChoices(); + const allCurrentChoicesSelected = currentChoices.every((choice) => selected.has(choice.value)); + + if (allCurrentChoicesSelected) { + for (const choice of currentChoices) { + selected.delete(choice.value); + } + } else { + for (const choice of currentChoices) { + selected.set(choice.value, choice.label); + } + } + + render(); + continue; + } + if (key === Key.space && highlighted !== null) { const choice = choices[highlighted]; diff --git a/packages/tui/tests/search.test.ts b/packages/tui/tests/search.test.ts index d80e8dcd..66dec8f5 100644 --- a/packages/tui/tests/search.test.ts +++ b/packages/tui/tests/search.test.ts @@ -154,4 +154,38 @@ describe('multisearch prompt', () => { expect(output.text()).toContain('About red'); }); + + it('toggles all current multisearch results with ctrl-a', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.ctrlA, Key.enter]), + output, + error: output, + interactive: true, + }, + () => multisearch({ message: 'Favorite colors?', options: colors }), + ); + + expect(result).toEqual(['red', 'green', 'blue']); + expect(output.text()).toContain('Selected: Red, Green, Blue'); + }); + + it('supports home and end multisearch navigation keys', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.end[0], Key.space, Key.home[0], Key.space, Key.enter]), + output, + error: output, + interactive: true, + }, + () => multisearch({ message: 'Favorite colors?', options: colors }), + ); + + expect(result).toEqual(['blue', 'red']); + expect(output.text()).toContain('Selected: Blue, Red'); + }); }); From 99938cab63128390c4fa5fb044baaf75a062cb82 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 14:33:01 +0800 Subject: [PATCH 024/675] Respect data table scroll windows --- packages/tui/src/output/data-table.ts | 21 +++++++++++++++++++- packages/tui/tests/table.test.ts | 28 +++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/packages/tui/src/output/data-table.ts b/packages/tui/src/output/data-table.ts index 8e204874..e363e57d 100644 --- a/packages/tui/src/output/data-table.ts +++ b/packages/tui/src/output/data-table.ts @@ -44,6 +44,10 @@ const derivedHeaders = (rows: Array>): string[] => { }; const rowCells = (headers: string[], row: DataTableRow): string[] => { + if (Array.isArray(row)) { + return headers.length > 0 ? headers.map((_, index) => stringify(row[index])) : row.map(stringify); + } + const fields = rowFields(row); return headers.map((header) => stringify(fields[header])); @@ -71,6 +75,17 @@ const clampSelected = (selected: number, rows: Array>): number return Math.min(selected, rows.length - 1); }; +const rowWindow = (total: number, selected: number, scroll?: number): { end: number; start: number } => { + if (scroll === undefined || scroll <= 0 || scroll >= total) { + return { end: total, start: 0 }; + } + + const before = Math.floor((scroll - 1) / 2); + const start = Math.max(0, Math.min(selected - before, total - scroll)); + + return { end: start + scroll, start }; +}; + const isPrintable = (key: string): boolean => { return [...key].every((character) => (character.codePointAt(0) ?? 0) >= 32); }; @@ -93,7 +108,11 @@ export const datatable = async (options: DataTablePromptOptions) selected = clampSelected(selected, rows); - const renderedRows = rows.map(({ row }, index) => { + const window = rowWindow(rows.length, selected, options.scroll); + + const renderedRows = rows.slice(window.start, window.end).map(({ row }, offset) => { + const index = window.start + offset; + return [index === selected ? '›' : ' ', ...rowCells(headers, row)]; }); diff --git a/packages/tui/tests/table.test.ts b/packages/tui/tests/table.test.ts index fe6e616e..a49bb017 100644 --- a/packages/tui/tests/table.test.ts +++ b/packages/tui/tests/table.test.ts @@ -87,6 +87,34 @@ describe('data table prompt', () => { expect(result).toBe(1); }); + it('renders a scrolling data table window around the selected row', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.down, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + datatable({ + message: 'Pick row', + headers: ['Name'], + rows: [['First'], ['Second'], ['Third'], ['Fourth']], + scroll: 3, + }), + ); + + const latestFrame = output.text().split('Pick row\n').at(-1) ?? ''; + + expect(result).toBe(2); + expect(latestFrame).not.toContain('First'); + expect(latestFrame).toContain('Second'); + expect(latestFrame).toContain('Third'); + expect(latestFrame).toContain('Fourth'); + }); + it('works from form builders', async () => { const output = createMemoryOutput(); From 44866815c738d5c25e3802c3f76b35f69cf31521 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 14:36:36 +0800 Subject: [PATCH 025/675] Skip disabled search results --- packages/tui/src/prompts/search.ts | 43 ++++++++++++++++++-------- packages/tui/tests/search.test.ts | 48 ++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 12 deletions(-) diff --git a/packages/tui/src/prompts/search.ts b/packages/tui/src/prompts/search.ts index 8bc18e9e..9046abf8 100644 --- a/packages/tui/src/prompts/search.ts +++ b/packages/tui/src/prompts/search.ts @@ -2,7 +2,7 @@ import { promptEnvironment } from '#tui/environment'; import { Key, oneOf } from '#tui/key'; import { ask, promptUntilValid, PromptValidationError } from '#tui/prompt'; import { applyTypedKey } from '#tui/typed-value'; -import { findChoice, normalizeSearchChoices, renderInteractiveChoices } from '#tui/concerns/choices'; +import { findChoice, firstEnabledIndex, nextEnabledIndex, normalizeSearchChoices, renderInteractiveChoices } from '#tui/concerns/choices'; import { resolveInfo } from '#tui/concerns/info'; import type { Choice, MultiSearchPromptOptions, SearchPromptOptions } from '#tui/types'; @@ -16,6 +16,16 @@ const searchMessage = (message: string, query: string): string => { return query.length > 0 ? `${message} ${query}` : message; }; +const lastEnabledIndex = (choices: Array>): number => { + for (let index = choices.length - 1; index >= 0; index -= 1) { + if (!choices[index]?.disabled) { + return index; + } + } + + return 0; +}; + const renderSearchChoices = ( message: string, query: string, @@ -70,7 +80,14 @@ const readSearchChoice = async (options: SearchPromptOptions, attempt = 0) if (key === Key.down || key === Key.downArrow || key === Key.ctrlN || key === Key.tab) { choices = await resolveSearchChoices(options.options, state.value); - highlighted = choices.length === 0 ? null : ((highlighted ?? (attempt > 0 ? 0 : -1)) + 1) % choices.length; + highlighted = + choices.length === 0 + ? null + : highlighted === null + ? attempt > 0 + ? nextEnabledIndex(choices, firstEnabledIndex(choices), 1) + : firstEnabledIndex(choices) + : nextEnabledIndex(choices, highlighted, 1); renderSearchChoices(options.message, state.value, choices, highlighted, new Set(), [], options.scroll, options.info); continue; } @@ -78,31 +95,33 @@ const readSearchChoice = async (options: SearchPromptOptions, attempt = 0) if (key === Key.up || key === Key.upArrow || key === Key.ctrlP || key === Key.shiftTab) { choices = await resolveSearchChoices(options.options, state.value); - highlighted = choices.length === 0 ? null : ((highlighted ?? choices.length) - 1 + choices.length) % choices.length; + highlighted = choices.length === 0 ? null : highlighted === null ? lastEnabledIndex(choices) : nextEnabledIndex(choices, highlighted, -1); renderSearchChoices(options.message, state.value, choices, highlighted, new Set(), [], options.scroll, options.info); continue; } if (oneOf([Key.home, Key.ctrlA], key) && highlighted !== null) { - highlighted = 0; + highlighted = firstEnabledIndex(choices); renderSearchChoices(options.message, state.value, choices, highlighted, new Set(), [], options.scroll, options.info); continue; } if (oneOf([Key.end, Key.ctrlE], key) && highlighted !== null) { - highlighted = Math.max(0, choices.length - 1); + highlighted = lastEnabledIndex(choices); renderSearchChoices(options.message, state.value, choices, highlighted, new Set(), [], options.scroll, options.info); continue; } if (key === Key.enter) { if (highlighted !== null) { - return choices[highlighted]?.value; + const choice = choices[highlighted]; + + return choice?.disabled ? undefined : choice?.value; } choices = await resolveSearchChoices(options.options, state.value); - highlighted = choices.length > 0 ? 0 : null; + highlighted = choices.length > 0 ? firstEnabledIndex(choices) : null; renderSearchChoices(options.message, state.value, choices, highlighted, new Set(), [], options.scroll, options.info); continue; } @@ -189,7 +208,7 @@ const readMultiSearchChoices = async (options: MultiSearchPromptOptions): if (key === Key.down || key === Key.downArrow || key === Key.ctrlN || key === Key.tab) { choices = await resolveSearchChoices(options.options, state.value); - highlighted = choices.length === 0 ? null : ((highlighted ?? -1) + 1) % choices.length; + highlighted = choices.length === 0 ? null : highlighted === null ? firstEnabledIndex(choices) : nextEnabledIndex(choices, highlighted, 1); render(); continue; } @@ -197,19 +216,19 @@ const readMultiSearchChoices = async (options: MultiSearchPromptOptions): if (key === Key.up || key === Key.upArrow || key === Key.ctrlP || key === Key.shiftTab) { choices = await resolveSearchChoices(options.options, state.value); - highlighted = choices.length === 0 ? null : ((highlighted ?? choices.length) - 1 + choices.length) % choices.length; + highlighted = choices.length === 0 ? null : highlighted === null ? lastEnabledIndex(choices) : nextEnabledIndex(choices, highlighted, -1); render(); continue; } if (oneOf([Key.home], key) && highlighted !== null) { - highlighted = 0; + highlighted = firstEnabledIndex(choices); render(); continue; } if (oneOf([Key.end], key) && highlighted !== null) { - highlighted = Math.max(0, choices.length - 1); + highlighted = lastEnabledIndex(choices); render(); continue; } @@ -240,7 +259,7 @@ const readMultiSearchChoices = async (options: MultiSearchPromptOptions): if (key === Key.space && highlighted !== null) { const choice = choices[highlighted]; - if (choice) { + if (choice && !choice.disabled) { if (selected.has(choice.value as T)) { selected.delete(choice.value as T); } else { diff --git a/packages/tui/tests/search.test.ts b/packages/tui/tests/search.test.ts index 66dec8f5..51860703 100644 --- a/packages/tui/tests/search.test.ts +++ b/packages/tui/tests/search.test.ts @@ -110,6 +110,29 @@ describe('search prompt', () => { expect(output.text()).toContain('About red'); }); + + it('skips disabled search results while navigating', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + search({ + message: 'Favorite color?', + options: [ + { label: 'Red', value: 'red', disabled: true }, + { label: 'Green', value: 'green' }, + ], + }), + ); + + expect(result).toBe('green'); + }); }); describe('multisearch prompt', () => { @@ -188,4 +211,29 @@ describe('multisearch prompt', () => { expect(result).toEqual(['blue', 'red']); expect(output.text()).toContain('Selected: Blue, Red'); }); + + it('skips disabled multisearch results while navigating and toggling all', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.ctrlA, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + multisearch({ + message: 'Favorite colors?', + options: [ + { label: 'Red', value: 'red', disabled: true }, + { label: 'Green', value: 'green' }, + { label: 'Blue', value: 'blue' }, + ], + }), + ); + + expect(result).toEqual(['green', 'blue']); + expect(output.text()).toContain('Selected: Green, Blue'); + }); }); From 4e06b96882ace7f9b21efdff9abb2aeb4a7a3330 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 14:38:12 +0800 Subject: [PATCH 026/675] Cover terminal helper sequences --- packages/tui/tests/terminal.test.ts | 37 +++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 packages/tui/tests/terminal.test.ts diff --git a/packages/tui/tests/terminal.test.ts b/packages/tui/tests/terminal.test.ts new file mode 100644 index 00000000..e4fece6c --- /dev/null +++ b/packages/tui/tests/terminal.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; +import { clear, clearTerminal, createMemoryOutput, cursorToStart, eraseLine, setTerminalTitle, title, withPromptEnvironment } from '../src/index'; + +describe('terminal helpers', () => { + it('writes clear terminal control sequences', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + clearTerminal(); + clear(); + }); + + expect(output.text()).toBe('\u001Bc\u001Bc'); + }); + + it('writes terminal title control sequences', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + setTerminalTitle('Demo'); + title('Prompt'); + }); + + expect(output.text()).toBe('\u001B]0;Demo\u0007\u001B]0;Prompt\u0007'); + }); + + it('writes cursor and erase-line control sequences', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + cursorToStart(); + eraseLine(); + }); + + expect(output.text()).toBe('\r\u001B[2K'); + }); +}); From a19e93c0d954c5423bbdb35db7fb8dbb70fee562 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 14:40:41 +0800 Subject: [PATCH 027/675] Support key driven pause prompt --- packages/tui/src/prompts/pause.ts | 23 +++++++++++++++- packages/tui/tests/pause.test.ts | 45 +++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 packages/tui/tests/pause.test.ts diff --git a/packages/tui/src/prompts/pause.ts b/packages/tui/src/prompts/pause.ts index bc3aa9b1..2d33611f 100644 --- a/packages/tui/src/prompts/pause.ts +++ b/packages/tui/src/prompts/pause.ts @@ -1,5 +1,26 @@ +import { promptEnvironment } from '#tui/environment'; +import { Key } from '#tui/key'; import { ask } from '#tui/prompt'; +import { renderQuestion } from '#tui/theme'; export const pause = async (message = 'Press enter to continue'): Promise => { - await ask(message); + const environment = promptEnvironment(); + + if (!environment.input.readKey) { + await ask(message); + + return; + } + + environment.output.write(renderQuestion(message)); + + while (true) { + const key = await environment.input.readKey(); + + if (key === null || key === Key.enter) { + environment.output.write('\n'); + + return; + } + } }; diff --git a/packages/tui/tests/pause.test.ts b/packages/tui/tests/pause.test.ts new file mode 100644 index 00000000..68af9220 --- /dev/null +++ b/packages/tui/tests/pause.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import { createMemoryOutput, createScriptedInput, Key, pause, withPromptEnvironment } from '../src/index'; + +describe('pause prompt', () => { + it('waits for enter from key-driven input', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment( + { + input: createScriptedInput(['x', Key.enter]), + output, + error: output, + interactive: true, + }, + () => pause('Continue'), + ); + + expect(output.text()).toContain('Continue'); + expect(output.text().endsWith('\n')).toBe(true); + }); + + it('falls back to line input', async () => { + const output = createMemoryOutput(); + + let asked = ''; + + await withPromptEnvironment( + { + input: { + async readLine(message: string): Promise { + asked = message; + + return ''; + }, + }, + output, + error: output, + interactive: true, + }, + () => pause('Continue'), + ); + + expect(asked).toContain('Continue'); + }); +}); From 23bdb1c6c98155e8ac69bd10e3ccb265ef1b41aa Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 14:44:40 +0800 Subject: [PATCH 028/675] Cover notification command behavior --- packages/tui/src/index.ts | 3 ++- packages/tui/src/output.ts | 3 ++- packages/tui/src/output/notify.ts | 31 ++++++++++++++++++++------ packages/tui/tests/notify.test.ts | 37 +++++++++++++++++++++++++++++++ 4 files changed, 65 insertions(+), 9 deletions(-) create mode 100644 packages/tui/tests/notify.test.ts diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index 8e3dc331..9d1dd7fd 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -4,7 +4,8 @@ export { Key, keyFromEvent, oneOf } from '#tui/key'; export { renderOpenTuiTextFrame } from '#tui/opentui'; export { confirm, select, multiselect, suggest, search, multisearch, autocomplete, pause } from '#tui/prompts/choices'; export { number, password, text, textarea } from '#tui/prompts/basic'; -export { alert, clear, dataTable, datatable, error, grid, info, intro, note, notify, outro, table, title, warning } from '#tui/output'; +export { alert, clear, dataTable, datatable, error, grid, info, intro, note, notificationCommand, notify, notifyForPlatform, outro, table, title, warning } from '#tui/output'; +export type { NotificationCommand } from '#tui/output'; export { PromptValidationError } from '#tui/prompt'; export { Logger, Progress, Stream, progress, spin, stream, task } from '#tui/status'; export { clearTerminal, cursorToStart, eraseLine, setTerminalTitle, terminalSize } from '#tui/terminal'; diff --git a/packages/tui/src/output.ts b/packages/tui/src/output.ts index 410dc9cf..ee6782b7 100644 --- a/packages/tui/src/output.ts +++ b/packages/tui/src/output.ts @@ -1,6 +1,7 @@ export { alert, error, info, intro, note, outro, warning } from '#tui/output/notes'; export type { NoteType } from '#tui/output/notes'; -export { notify } from '#tui/output/notify'; +export { notificationCommand, notify, notifyForPlatform } from '#tui/output/notify'; +export type { NotificationCommand } 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/notify.ts b/packages/tui/src/output/notify.ts index 64068ba5..7f11c535 100644 --- a/packages/tui/src/output/notify.ts +++ b/packages/tui/src/output/notify.ts @@ -2,8 +2,13 @@ import { spawn } from 'node:child_process'; import { platform } from 'node:process'; import { note } from '#tui/output/notes'; -export const notify = (title: string, body = '', subtitle = '', sound = '', icon = ''): void => { - if (platform === 'darwin') { +export type NotificationCommand = { + args: string[]; + bin: string; +}; + +export const notificationCommand = (targetPlatform: NodeJS.Platform, title: string, body = '', subtitle = '', sound = '', icon = ''): NotificationCommand | null => { + if (targetPlatform === 'darwin') { const script = [ 'display notification', JSON.stringify(body), @@ -15,18 +20,30 @@ export const notify = (title: string, body = '', subtitle = '', sound = '', icon .filter(Boolean) .join(' '); - spawn('osascript', ['-e', script], { detached: true, stdio: 'ignore' }).unref(); - - return; + return { args: ['-e', script], bin: 'osascript' }; } - if (platform === 'linux') { + if (targetPlatform === 'linux') { const args = icon ? ['--icon', icon, title, body] : [title, body]; - spawn('notify-send', args, { detached: true, stdio: 'ignore' }).unref(); + return { args, bin: 'notify-send' }; + } + + return null; +}; + +export const notifyForPlatform = (targetPlatform: NodeJS.Platform, title: string, body = '', subtitle = '', sound = '', icon = ''): void => { + const command = notificationCommand(targetPlatform, title, body, subtitle, sound, icon); + + if (command) { + spawn(command.bin, command.args, { detached: true, stdio: 'ignore' }).unref(); return; } note(body ? `${title}: ${body}` : title, 'info'); }; + +export const notify = (title: string, body = '', subtitle = '', sound = '', icon = ''): void => { + notifyForPlatform(platform, title, body, subtitle, sound, icon); +}; diff --git a/packages/tui/tests/notify.test.ts b/packages/tui/tests/notify.test.ts new file mode 100644 index 00000000..0abe29da --- /dev/null +++ b/packages/tui/tests/notify.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; +import { createMemoryOutput, notificationCommand, notifyForPlatform, withPromptEnvironment } from '../src/index'; + +describe('notify helper', () => { + it('builds macOS notification commands', () => { + const command = notificationCommand('darwin', 'Deploy', 'Done', 'Preview', 'Glass'); + + expect(command?.bin).toBe('osascript'); + expect(command?.args[0]).toBe('-e'); + expect(command?.args[1]).toContain('display notification "Done"'); + expect(command?.args[1]).toContain('with title "Deploy"'); + expect(command?.args[1]).toContain('subtitle "Preview"'); + expect(command?.args[1]).toContain('sound name "Glass"'); + }); + + it('builds Linux notification commands', () => { + expect(notificationCommand('linux', 'Deploy', 'Done')).toEqual({ + args: ['Deploy', 'Done'], + bin: 'notify-send', + }); + + expect(notificationCommand('linux', 'Deploy', 'Done', '', '', '/tmp/icon.png')).toEqual({ + args: ['--icon', '/tmp/icon.png', 'Deploy', 'Done'], + bin: 'notify-send', + }); + }); + + it('falls back to note output on unsupported platforms', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + notifyForPlatform('freebsd', 'Deploy', 'Done'); + }); + + expect(output.text()).toContain('Deploy: Done'); + }); +}); From 92d07c23e0f8f1388b882c0a44bf2fa1260fb3b8 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 14:46:02 +0800 Subject: [PATCH 029/675] Use aliases in tui tests --- packages/tui/tests/choices.test.ts | 2 +- packages/tui/tests/form.test.ts | 2 +- packages/tui/tests/key.test.ts | 2 +- packages/tui/tests/notify.test.ts | 2 +- packages/tui/tests/number.test.ts | 2 +- packages/tui/tests/pause.test.ts | 2 +- packages/tui/tests/progress.test.ts | 2 +- packages/tui/tests/search.test.ts | 2 +- packages/tui/tests/strings.test.ts | 2 +- packages/tui/tests/suggest.test.ts | 2 +- packages/tui/tests/table.test.ts | 2 +- packages/tui/tests/task.test.ts | 2 +- packages/tui/tests/terminal.test.ts | 2 +- packages/tui/tests/textarea.test.ts | 2 +- packages/tui/tests/typed-value.test.ts | 2 +- 15 files changed, 15 insertions(+), 15 deletions(-) diff --git a/packages/tui/tests/choices.test.ts b/packages/tui/tests/choices.test.ts index e2137178..e3eaca3b 100644 --- a/packages/tui/tests/choices.test.ts +++ b/packages/tui/tests/choices.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { createMemoryOutput, createScriptedInput, Key, multiselect, select, withPromptEnvironment } from '../src/index'; +import { createMemoryOutput, createScriptedInput, Key, multiselect, select, withPromptEnvironment } from '#tui/index'; describe('choice prompts', () => { it('selects with arrow keys and enter', async () => { diff --git a/packages/tui/tests/form.test.ts b/packages/tui/tests/form.test.ts index 02c1bc0d..4991cbef 100644 --- a/packages/tui/tests/form.test.ts +++ b/packages/tui/tests/form.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { createMemoryOutput, createScriptedInput, form, Key, withPromptEnvironment } from '../src/index'; +import { createMemoryOutput, createScriptedInput, form, Key, withPromptEnvironment } from '#tui/index'; describe('form builder', () => { it('runs chained steps and returns positional responses', async () => { diff --git a/packages/tui/tests/key.test.ts b/packages/tui/tests/key.test.ts index e0d913d1..3a2d3c27 100644 --- a/packages/tui/tests/key.test.ts +++ b/packages/tui/tests/key.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { Key, keyFromEvent } from '../src/index'; +import { Key, keyFromEvent } from '#tui/index'; describe('keyboard mapping', () => { it('maps named keys', () => { diff --git a/packages/tui/tests/notify.test.ts b/packages/tui/tests/notify.test.ts index 0abe29da..da219058 100644 --- a/packages/tui/tests/notify.test.ts +++ b/packages/tui/tests/notify.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { createMemoryOutput, notificationCommand, notifyForPlatform, withPromptEnvironment } from '../src/index'; +import { createMemoryOutput, notificationCommand, notifyForPlatform, withPromptEnvironment } from '#tui/index'; describe('notify helper', () => { it('builds macOS notification commands', () => { diff --git a/packages/tui/tests/number.test.ts b/packages/tui/tests/number.test.ts index 091aab32..1b27f05a 100644 --- a/packages/tui/tests/number.test.ts +++ b/packages/tui/tests/number.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { createMemoryOutput, createScriptedInput, Key, number, withPromptEnvironment } from '../src/index'; +import { createMemoryOutput, createScriptedInput, Key, number, withPromptEnvironment } from '#tui/index'; describe('number prompt', () => { it('rejects partial decimal input and accepts a retry', async () => { diff --git a/packages/tui/tests/pause.test.ts b/packages/tui/tests/pause.test.ts index 68af9220..669795d0 100644 --- a/packages/tui/tests/pause.test.ts +++ b/packages/tui/tests/pause.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { createMemoryOutput, createScriptedInput, Key, pause, withPromptEnvironment } from '../src/index'; +import { createMemoryOutput, createScriptedInput, Key, pause, withPromptEnvironment } from '#tui/index'; describe('pause prompt', () => { it('waits for enter from key-driven input', async () => { diff --git a/packages/tui/tests/progress.test.ts b/packages/tui/tests/progress.test.ts index 980e8aa0..a1ebb798 100644 --- a/packages/tui/tests/progress.test.ts +++ b/packages/tui/tests/progress.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { createMemoryOutput, progress, withPromptEnvironment } from '../src/index'; +import { createMemoryOutput, progress, withPromptEnvironment } from '#tui/index'; describe('progress helper', () => { it('maps iterable steps and returns callback results', async () => { diff --git a/packages/tui/tests/search.test.ts b/packages/tui/tests/search.test.ts index 51860703..4723e0eb 100644 --- a/packages/tui/tests/search.test.ts +++ b/packages/tui/tests/search.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { createMemoryOutput, createScriptedInput, Key, multisearch, search, withPromptEnvironment } from '../src/index'; +import { createMemoryOutput, createScriptedInput, Key, multisearch, search, withPromptEnvironment } from '#tui/index'; const colors = (value: string): Record => { const options = { diff --git a/packages/tui/tests/strings.test.ts b/packages/tui/tests/strings.test.ts index e043d00d..a520155f 100644 --- a/packages/tui/tests/strings.test.ts +++ b/packages/tui/tests/strings.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { parseAnsiText, truncate, visibleWidth, wrap } from '../src/index'; +import { parseAnsiText, truncate, visibleWidth, wrap } from '#tui/index'; describe('string utilities', () => { it('measures visible width without ANSI control sequences', () => { diff --git a/packages/tui/tests/suggest.test.ts b/packages/tui/tests/suggest.test.ts index 4658a141..3524422f 100644 --- a/packages/tui/tests/suggest.test.ts +++ b/packages/tui/tests/suggest.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { createMemoryOutput, createScriptedInput, Key, suggest, withPromptEnvironment } from '../src/index'; +import { createMemoryOutput, createScriptedInput, Key, suggest, withPromptEnvironment } from '#tui/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/packages/tui/tests/table.test.ts index a49bb017..268db1c4 100644 --- a/packages/tui/tests/table.test.ts +++ b/packages/tui/tests/table.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { createMemoryOutput, createScriptedInput, datatable, form, Key, table, withPromptEnvironment } from '../src/index'; +import { createMemoryOutput, createScriptedInput, datatable, form, Key, table, withPromptEnvironment } from '#tui/index'; describe('table output', () => { it('renders static table rows', async () => { diff --git a/packages/tui/tests/task.test.ts b/packages/tui/tests/task.test.ts index d2ff26e3..e16b5dd2 100644 --- a/packages/tui/tests/task.test.ts +++ b/packages/tui/tests/task.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { createMemoryOutput, task, withPromptEnvironment } from '../src/index'; +import { createMemoryOutput, task, withPromptEnvironment } from '#tui/index'; 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/packages/tui/tests/terminal.test.ts index e4fece6c..244f6e60 100644 --- a/packages/tui/tests/terminal.test.ts +++ b/packages/tui/tests/terminal.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { clear, clearTerminal, createMemoryOutput, cursorToStart, eraseLine, setTerminalTitle, title, withPromptEnvironment } from '../src/index'; +import { clear, clearTerminal, createMemoryOutput, cursorToStart, eraseLine, setTerminalTitle, title, withPromptEnvironment } from '#tui/index'; describe('terminal helpers', () => { it('writes clear terminal control sequences', async () => { diff --git a/packages/tui/tests/textarea.test.ts b/packages/tui/tests/textarea.test.ts index 257ffe7c..2d4d3eb6 100644 --- a/packages/tui/tests/textarea.test.ts +++ b/packages/tui/tests/textarea.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { createMemoryOutput, createScriptedInput, Key, textarea, withPromptEnvironment } from '../src/index'; +import { createMemoryOutput, createScriptedInput, Key, textarea, withPromptEnvironment } from '#tui/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/packages/tui/tests/typed-value.test.ts index 11ebc99b..9e2bc26d 100644 --- a/packages/tui/tests/typed-value.test.ts +++ b/packages/tui/tests/typed-value.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { applyTypedKey, Key } from '../src/index'; +import { applyTypedKey, Key } from '#tui/index'; const apply = (keys: string[]): string => { let state = { cursor: 0, value: '' }; From c243d6848666083c8707622662e94e28272a1b32 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 14:49:11 +0800 Subject: [PATCH 030/675] Use Zod for runtime shape checks --- packages/tui/package.json | 3 ++- packages/tui/src/concerns/choices.ts | 16 ++++++++++++++-- packages/tui/src/output/data-table.ts | 12 +++++++++++- pnpm-lock.yaml | 8 ++++++++ 4 files changed, 35 insertions(+), 4 deletions(-) diff --git a/packages/tui/package.json b/packages/tui/package.json index b9e440cb..93fbc6f8 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -21,7 +21,8 @@ "dependencies": { "@opentui/core": "^0.4.0", "string-width": "^7.2.0", - "strip-ansi": "^7.1.2" + "strip-ansi": "^7.1.2", + "zod": "^4.4.3" }, "devDependencies": { "@ollin/artefacts": "workspace:*", diff --git a/packages/tui/src/concerns/choices.ts b/packages/tui/src/concerns/choices.ts index 9fe13875..97df33c2 100644 --- a/packages/tui/src/concerns/choices.ts +++ b/packages/tui/src/concerns/choices.ts @@ -1,10 +1,22 @@ import { promptEnvironment } from '#tui/environment'; +import { z } from 'zod'; import type { Choice, ChoiceInput } from '#tui/types'; +const choiceSchema = z + .object({ + disabled: z.union([z.boolean(), z.string()]).optional(), + hint: z.string().optional(), + label: z.string(), + value: z.unknown(), + }) + .passthrough(); + export const normalizeChoices = (options: Array>): Array> => { return options.map((choice) => { - if (typeof choice === 'object' && choice !== null && 'value' in choice && 'label' in choice) { - return choice; + const parsed = choiceSchema.safeParse(choice); + + if (parsed.success) { + return parsed.data as Choice; } return { diff --git a/packages/tui/src/output/data-table.ts b/packages/tui/src/output/data-table.ts index e363e57d..8a13573a 100644 --- a/packages/tui/src/output/data-table.ts +++ b/packages/tui/src/output/data-table.ts @@ -2,6 +2,7 @@ import { promptEnvironment } from '#tui/environment'; import { Key } from '#tui/key'; import { promptUntilValid, PromptValidationError } from '#tui/prompt'; import { renderTable } from '#tui/theme'; +import { z } from 'zod'; import type { DataTableObjectRow, DataTablePromptOptions, DataTableRow, TableCell } from '#tui/types'; type VisibleRow = { @@ -13,8 +14,17 @@ const stringify = (value: TableCell): string => { return value === null || value === undefined ? '' : String(value); }; +const tableCellSchema = z.union([z.string(), z.number(), z.boolean(), z.null(), z.undefined()]); + +const dataObjectRowSchema = z + .object({ + cells: z.record(z.string(), tableCellSchema), + value: z.unknown().optional(), + }) + .passthrough(); + const isDataObjectRow = (row: DataTableRow): row is DataTableObjectRow => { - return !Array.isArray(row) && 'cells' in row && typeof row.cells === 'object' && row.cells !== null; + return dataObjectRowSchema.safeParse(row).success; }; const rowFields = (row: DataTableRow): Record => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aa948a84..e78e003a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -77,6 +77,9 @@ importers: strip-ansi: specifier: ^7.1.2 version: 7.2.0 + zod: + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@ollin/artefacts': specifier: workspace:* @@ -663,6 +666,9 @@ packages: yoga-layout@3.2.1: resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==} + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + snapshots: '@emnapi/core@1.10.0': @@ -1089,3 +1095,5 @@ snapshots: stackback: 0.0.2 yoga-layout@3.2.1: {} + + zod@4.4.3: {} From 6d715824083c7afefc5aa9e33e8397427801f857 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 14:55:15 +0800 Subject: [PATCH 031/675] Split prompt and form concerns --- packages/tui/src/concerns/choices.ts | 17 +- .../tui/src/concerns/validators/choice.ts | 17 + packages/tui/src/form.ts | 4 +- .../src/form/{builder.ts => builder/index.ts} | 82 ++--- packages/tui/src/form/builder/previous.ts | 11 + packages/tui/src/form/builder/step.ts | 9 + packages/tui/src/output/data-table.ts | 17 +- .../tui/src/output/validators/data-table.ts | 15 + packages/tui/src/prompts/choices.ts | 8 +- packages/tui/src/prompts/search.ts | 293 ------------------ packages/tui/src/prompts/search/choices.ts | 22 ++ packages/tui/src/prompts/search/index.ts | 20 ++ packages/tui/src/prompts/search/read-multi.ts | 141 +++++++++ .../tui/src/prompts/search/read-single.ts | 102 ++++++ packages/tui/src/prompts/search/render.ts | 28 ++ packages/tui/src/prompts/select.ts | 292 ----------------- packages/tui/src/prompts/select/confirm.ts | 31 ++ packages/tui/src/prompts/select/index.ts | 27 ++ packages/tui/src/prompts/select/navigation.ts | 22 ++ .../tui/src/prompts/select/read-multiple.ts | 132 ++++++++ .../tui/src/prompts/select/read-selected.ts | 78 +++++ packages/tui/src/prompts/select/render.ts | 33 ++ packages/tui/src/prompts/suggest.ts | 216 ------------- packages/tui/src/prompts/suggest/index.ts | 69 +++++ packages/tui/src/prompts/suggest/options.ts | 25 ++ packages/tui/src/prompts/suggest/read.ts | 104 +++++++ packages/tui/src/prompts/suggest/render.ts | 20 ++ packages/tui/src/prompts/suggest/resolve.ts | 11 + 28 files changed, 947 insertions(+), 899 deletions(-) create mode 100644 packages/tui/src/concerns/validators/choice.ts rename packages/tui/src/form/{builder.ts => builder/index.ts} (81%) create mode 100644 packages/tui/src/form/builder/previous.ts create mode 100644 packages/tui/src/form/builder/step.ts create mode 100644 packages/tui/src/output/validators/data-table.ts delete mode 100644 packages/tui/src/prompts/search.ts create mode 100644 packages/tui/src/prompts/search/choices.ts create mode 100644 packages/tui/src/prompts/search/index.ts create mode 100644 packages/tui/src/prompts/search/read-multi.ts create mode 100644 packages/tui/src/prompts/search/read-single.ts create mode 100644 packages/tui/src/prompts/search/render.ts delete mode 100644 packages/tui/src/prompts/select.ts create mode 100644 packages/tui/src/prompts/select/confirm.ts create mode 100644 packages/tui/src/prompts/select/index.ts create mode 100644 packages/tui/src/prompts/select/navigation.ts create mode 100644 packages/tui/src/prompts/select/read-multiple.ts create mode 100644 packages/tui/src/prompts/select/read-selected.ts create mode 100644 packages/tui/src/prompts/select/render.ts delete mode 100644 packages/tui/src/prompts/suggest.ts create mode 100644 packages/tui/src/prompts/suggest/index.ts create mode 100644 packages/tui/src/prompts/suggest/options.ts create mode 100644 packages/tui/src/prompts/suggest/read.ts create mode 100644 packages/tui/src/prompts/suggest/render.ts create mode 100644 packages/tui/src/prompts/suggest/resolve.ts diff --git a/packages/tui/src/concerns/choices.ts b/packages/tui/src/concerns/choices.ts index 97df33c2..9a97f1c8 100644 --- a/packages/tui/src/concerns/choices.ts +++ b/packages/tui/src/concerns/choices.ts @@ -1,22 +1,13 @@ import { promptEnvironment } from '#tui/environment'; -import { z } from 'zod'; +import { parseChoice } from '#tui/concerns/validators/choice'; import type { Choice, ChoiceInput } from '#tui/types'; -const choiceSchema = z - .object({ - disabled: z.union([z.boolean(), z.string()]).optional(), - hint: z.string().optional(), - label: z.string(), - value: z.unknown(), - }) - .passthrough(); - export const normalizeChoices = (options: Array>): Array> => { return options.map((choice) => { - const parsed = choiceSchema.safeParse(choice); + const parsed = parseChoice(choice); - if (parsed.success) { - return parsed.data as Choice; + if (parsed) { + return parsed; } return { diff --git a/packages/tui/src/concerns/validators/choice.ts b/packages/tui/src/concerns/validators/choice.ts new file mode 100644 index 00000000..230a51c8 --- /dev/null +++ b/packages/tui/src/concerns/validators/choice.ts @@ -0,0 +1,17 @@ +import { z } from 'zod'; +import type { Choice } from '#tui/types'; + +const choiceSchema = z + .object({ + disabled: z.union([z.boolean(), z.string()]).optional(), + hint: z.string().optional(), + label: z.string(), + value: z.unknown(), + }) + .passthrough(); + +export const parseChoice = (value: unknown): Choice | null => { + const parsed = choiceSchema.safeParse(value); + + return parsed.success ? (parsed.data as Choice) : null; +}; diff --git a/packages/tui/src/form.ts b/packages/tui/src/form.ts index 888f61e7..d9b45536 100644 --- a/packages/tui/src/form.ts +++ b/packages/tui/src/form.ts @@ -1,7 +1,7 @@ -export { FormBuilder } from '#tui/form/builder'; +export { FormBuilder } from '#tui/form/builder/index'; export type { FormResponses, FormStep } from '#tui/form/types'; -import { FormBuilder } from '#tui/form/builder'; +import { FormBuilder } from '#tui/form/builder/index'; import type { MaybePromise } from '#tui/types'; export function form(): FormBuilder; diff --git a/packages/tui/src/form/builder.ts b/packages/tui/src/form/builder/index.ts similarity index 81% rename from packages/tui/src/form/builder.ts rename to packages/tui/src/form/builder/index.ts index 02a99980..beb86fcb 100644 --- a/packages/tui/src/form/builder.ts +++ b/packages/tui/src/form/builder/index.ts @@ -2,6 +2,8 @@ import { alert, datatable, error, info, intro, note, outro, table, warning } fro import { autocomplete, confirm, multiselect, multisearch, pause, search, select, suggest } from '#tui/prompts/choices'; import { number, password, text, textarea } from '#tui/prompts/basic'; import { progress, spin, stream, task } from '#tui/status'; +import { previousArray, previousNumber, previousString } from '#tui/form/builder/previous'; +import { sideEffectStep } from '#tui/form/builder/step'; import type { FormResponses, FormStep } from '#tui/form/types'; import type { ChoiceInput, DataTablePromptOptions, MaybePromise, MultiSearchPromptOptions, SearchPromptOptions, TableOptions, TextPromptOptions } from '#tui/types'; import type { SuggestOptions } from '#tui/prompts/choices'; @@ -64,7 +66,7 @@ export class FormBuilder { name?: string, transform?: TextPromptOptions['transform'], ): this { - return this.add((_, previous) => text(label, placeholder, previous === undefined || previous === null ? defaultValue : String(previous), required, validate, hint, transform), name); + return this.add((_, previous) => text(label, placeholder, previousString(previous, defaultValue), required, validate, hint, transform), name); } textarea( @@ -78,7 +80,7 @@ export class FormBuilder { name?: string, transform?: TextPromptOptions['transform'], ): this { - return this.add((_, previous) => textarea(label, placeholder, previous === undefined || previous === null ? defaultValue : String(previous), required, validate, hint, rows, transform), name); + return this.add((_, previous) => textarea(label, placeholder, previousString(previous, defaultValue), required, validate, hint, rows, transform), name); } password( @@ -105,7 +107,7 @@ export class FormBuilder { step?: number, name?: string, ): this { - return this.add((_, previous) => number(label, placeholder, typeof previous === 'number' ? previous : defaultValue, required, validate, hint, min, max, step), name); + return this.add((_, previous) => number(label, placeholder, previousNumber(previous, defaultValue), required, validate, hint, min, max, step), name); } confirm( @@ -144,7 +146,7 @@ export class FormBuilder { hint = 'Use the space bar to select options.', name?: string, ): this { - return this.add((_, previous) => multiselect({ message: label, options, default: Array.isArray(previous) ? (previous as T[]) : defaultValue, scroll, required, validate, hint }), name); + return this.add((_, previous) => multiselect({ message: label, options, default: previousArray(previous, defaultValue), scroll, required, validate, hint }), name); } suggest( @@ -158,11 +160,7 @@ export class FormBuilder { name?: string, transform?: TextPromptOptions['transform'], ): this { - return this.add( - (_, previous) => - suggest({ message: label, label, options, default: previous === undefined || previous === null ? defaultValue : String(previous), scroll, required, validate, hint, transform }), - name, - ); + return this.add((_, previous) => suggest({ message: label, label, options, default: previousString(previous, defaultValue), scroll, required, validate, hint, transform }), name); } autocomplete( @@ -175,11 +173,7 @@ export class FormBuilder { name?: string, transform?: TextPromptOptions['transform'], ): this { - return this.add( - (_, previous) => - autocomplete({ message: label, label, options, default: previous === undefined || previous === null ? defaultValue : String(previous), required, validate, hint, transform }), - name, - ); + return this.add((_, previous) => autocomplete({ message: label, label, options, default: previousString(previous, defaultValue), required, validate, hint, transform }), name); } search(options: SearchPromptOptions, name?: string): this { @@ -200,11 +194,7 @@ export class FormBuilder { pause(message = 'Press enter to continue', name?: string): this { return this.add( - async () => { - await pause(message); - - return null; - }, + sideEffectStep(() => pause(message)), name, true, ); @@ -212,11 +202,7 @@ export class FormBuilder { stream(source: AsyncIterable | Iterable, name?: string): this { return this.add( - async () => { - await stream(source); - - return null; - }, + sideEffectStep(() => stream(source)), name, true, ); @@ -224,11 +210,7 @@ export class FormBuilder { note(message: string, type: string | null = null, name?: string): this { return this.add( - () => { - note(message, type); - - return null; - }, + sideEffectStep(() => note(message, type)), name, true, ); @@ -236,11 +218,7 @@ export class FormBuilder { error(message: string, name?: string): this { return this.add( - () => { - error(message); - - return null; - }, + sideEffectStep(() => error(message)), name, true, ); @@ -248,11 +226,7 @@ export class FormBuilder { warning(message: string, name?: string): this { return this.add( - () => { - warning(message); - - return null; - }, + sideEffectStep(() => warning(message)), name, true, ); @@ -260,11 +234,7 @@ export class FormBuilder { alert(message: string, name?: string): this { return this.add( - () => { - alert(message); - - return null; - }, + sideEffectStep(() => alert(message)), name, true, ); @@ -272,11 +242,7 @@ export class FormBuilder { info(message: string, name?: string): this { return this.add( - () => { - info(message); - - return null; - }, + sideEffectStep(() => info(message)), name, true, ); @@ -284,11 +250,7 @@ export class FormBuilder { intro(message: string, name?: string): this { return this.add( - () => { - intro(message); - - return null; - }, + sideEffectStep(() => intro(message)), name, true, ); @@ -296,11 +258,7 @@ export class FormBuilder { outro(message: string, name?: string): this { return this.add( - () => { - outro(message); - - return null; - }, + sideEffectStep(() => outro(message)), name, true, ); @@ -308,11 +266,7 @@ export class FormBuilder { table(headersOrOptions: TableOptions | string[] = [], rows: TableOptions['rows'] | null = null, name?: string): this { return this.add( - () => { - table(headersOrOptions, rows); - - return null; - }, + sideEffectStep(() => table(headersOrOptions, rows)), name, true, ); diff --git a/packages/tui/src/form/builder/previous.ts b/packages/tui/src/form/builder/previous.ts new file mode 100644 index 00000000..c2449320 --- /dev/null +++ b/packages/tui/src/form/builder/previous.ts @@ -0,0 +1,11 @@ +export const previousString = (previous: unknown, defaultValue: string): string => { + return previous === undefined || previous === null ? defaultValue : String(previous); +}; + +export const previousNumber = (previous: unknown, defaultValue: number): number => { + return typeof previous === 'number' ? previous : defaultValue; +}; + +export const previousArray = (previous: unknown, defaultValue: T[]): T[] => { + return Array.isArray(previous) ? (previous as T[]) : defaultValue; +}; diff --git a/packages/tui/src/form/builder/step.ts b/packages/tui/src/form/builder/step.ts new file mode 100644 index 00000000..c0f30adc --- /dev/null +++ b/packages/tui/src/form/builder/step.ts @@ -0,0 +1,9 @@ +import type { MaybePromise } from '#tui/types'; + +export const sideEffectStep = (callback: () => MaybePromise): (() => MaybePromise) => { + return async () => { + await callback(); + + return null; + }; +}; diff --git a/packages/tui/src/output/data-table.ts b/packages/tui/src/output/data-table.ts index 8a13573a..9b531a62 100644 --- a/packages/tui/src/output/data-table.ts +++ b/packages/tui/src/output/data-table.ts @@ -2,8 +2,8 @@ import { promptEnvironment } from '#tui/environment'; import { Key } from '#tui/key'; import { promptUntilValid, PromptValidationError } from '#tui/prompt'; import { renderTable } from '#tui/theme'; -import { z } from 'zod'; -import type { DataTableObjectRow, DataTablePromptOptions, DataTableRow, TableCell } from '#tui/types'; +import { isDataObjectRow } from '#tui/output/validators/data-table'; +import type { DataTablePromptOptions, DataTableRow, TableCell } from '#tui/types'; type VisibleRow = { index: number; @@ -14,19 +14,6 @@ const stringify = (value: TableCell): string => { return value === null || value === undefined ? '' : String(value); }; -const tableCellSchema = z.union([z.string(), z.number(), z.boolean(), z.null(), z.undefined()]); - -const dataObjectRowSchema = z - .object({ - cells: z.record(z.string(), tableCellSchema), - value: z.unknown().optional(), - }) - .passthrough(); - -const isDataObjectRow = (row: DataTableRow): row is DataTableObjectRow => { - return dataObjectRowSchema.safeParse(row).success; -}; - const rowFields = (row: DataTableRow): Record => { if (Array.isArray(row)) { return Object.fromEntries(row.map((value, index) => [String(index), value])); diff --git a/packages/tui/src/output/validators/data-table.ts b/packages/tui/src/output/validators/data-table.ts new file mode 100644 index 00000000..7f7aed6a --- /dev/null +++ b/packages/tui/src/output/validators/data-table.ts @@ -0,0 +1,15 @@ +import { z } from 'zod'; +import type { DataTableObjectRow, DataTableRow, TableCell } from '#tui/types'; + +const tableCellSchema: z.ZodType = z.union([z.string(), z.number(), z.boolean(), z.null(), z.undefined()]); + +const dataObjectRowSchema = z + .object({ + cells: z.record(z.string(), tableCellSchema), + value: z.unknown().optional(), + }) + .passthrough(); + +export const isDataObjectRow = (row: DataTableRow): row is DataTableObjectRow => { + return dataObjectRowSchema.safeParse(row).success; +}; diff --git a/packages/tui/src/prompts/choices.ts b/packages/tui/src/prompts/choices.ts index 66208d82..34ae33b4 100644 --- a/packages/tui/src/prompts/choices.ts +++ b/packages/tui/src/prompts/choices.ts @@ -1,5 +1,5 @@ -export { confirm, multiselect, select } from '#tui/prompts/select'; -export { autocomplete, suggest } from '#tui/prompts/suggest'; -export { multisearch, search } from '#tui/prompts/search'; +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'; +export type { SuggestOptions } from '#tui/prompts/suggest/index'; diff --git a/packages/tui/src/prompts/search.ts b/packages/tui/src/prompts/search.ts deleted file mode 100644 index 9046abf8..00000000 --- a/packages/tui/src/prompts/search.ts +++ /dev/null @@ -1,293 +0,0 @@ -import { promptEnvironment } from '#tui/environment'; -import { Key, oneOf } from '#tui/key'; -import { ask, promptUntilValid, PromptValidationError } from '#tui/prompt'; -import { applyTypedKey } from '#tui/typed-value'; -import { findChoice, firstEnabledIndex, nextEnabledIndex, normalizeSearchChoices, renderInteractiveChoices } from '#tui/concerns/choices'; -import { resolveInfo } from '#tui/concerns/info'; -import type { Choice, MultiSearchPromptOptions, SearchPromptOptions } from '#tui/types'; - -const resolveSearchChoices = async (source: SearchPromptOptions['options'], query: string): Promise>> => { - const options = typeof source === 'function' ? await source(query) : source; - - return normalizeSearchChoices(options); -}; - -const searchMessage = (message: string, query: string): string => { - return query.length > 0 ? `${message} ${query}` : message; -}; - -const lastEnabledIndex = (choices: Array>): number => { - for (let index = choices.length - 1; index >= 0; index -= 1) { - if (!choices[index]?.disabled) { - return index; - } - } - - return 0; -}; - -const renderSearchChoices = ( - message: string, - query: string, - choices: Array>, - highlighted: number | null, - marked: Set = new Set(), - selectedLabels: string[] = [], - scroll?: number, - info?: SearchPromptOptions['info'] | MultiSearchPromptOptions['info'], -): void => { - renderInteractiveChoices(searchMessage(message, query), choices, highlighted ?? 0, marked, scroll); - - const text = resolveInfo(info, highlighted === null ? null : (choices[highlighted]?.value ?? null)); - - if (text.length > 0) { - promptEnvironment().output.write(`${text}\n`); - } - - if (selectedLabels.length > 0) { - promptEnvironment().output.write(`Selected: ${selectedLabels.join(', ')}\n`); - } -}; - -const readSearchChoice = async (options: SearchPromptOptions, attempt = 0): Promise => { - const environment = promptEnvironment(); - - if (!environment.input.readKey) { - const query = await ask(options.message, options.hint); - - const choices = await resolveSearchChoices(options.options, query); - - const choice = findChoice(choices, query) ?? choices.find((candidate) => !candidate.disabled); - - return choice?.value ?? options.default ?? choices[0]?.value; - } - - let state = { cursor: 0, value: '' }; - - let choices = await resolveSearchChoices(options.options, state.value); - - let highlighted: number | null = null; - - renderSearchChoices(options.message, state.value, choices, highlighted, new Set(), [], options.scroll, options.info); - - while (true) { - const key = await environment.input.readKey(); - - if (key === null) { - return options.default; - } - - if (key === Key.down || key === Key.downArrow || key === Key.ctrlN || key === Key.tab) { - choices = await resolveSearchChoices(options.options, state.value); - - highlighted = - choices.length === 0 - ? null - : highlighted === null - ? attempt > 0 - ? nextEnabledIndex(choices, firstEnabledIndex(choices), 1) - : firstEnabledIndex(choices) - : nextEnabledIndex(choices, highlighted, 1); - renderSearchChoices(options.message, state.value, choices, highlighted, new Set(), [], options.scroll, options.info); - continue; - } - - if (key === Key.up || key === Key.upArrow || key === Key.ctrlP || key === Key.shiftTab) { - choices = await resolveSearchChoices(options.options, state.value); - - highlighted = choices.length === 0 ? null : highlighted === null ? lastEnabledIndex(choices) : nextEnabledIndex(choices, highlighted, -1); - renderSearchChoices(options.message, state.value, choices, highlighted, new Set(), [], options.scroll, options.info); - continue; - } - - if (oneOf([Key.home, Key.ctrlA], key) && highlighted !== null) { - highlighted = firstEnabledIndex(choices); - renderSearchChoices(options.message, state.value, choices, highlighted, new Set(), [], options.scroll, options.info); - continue; - } - - if (oneOf([Key.end, Key.ctrlE], key) && highlighted !== null) { - highlighted = lastEnabledIndex(choices); - renderSearchChoices(options.message, state.value, choices, highlighted, new Set(), [], options.scroll, options.info); - continue; - } - - if (key === Key.enter) { - if (highlighted !== null) { - const choice = choices[highlighted]; - - return choice?.disabled ? undefined : choice?.value; - } - - choices = await resolveSearchChoices(options.options, state.value); - - highlighted = choices.length > 0 ? firstEnabledIndex(choices) : null; - renderSearchChoices(options.message, state.value, choices, highlighted, new Set(), [], options.scroll, options.info); - continue; - } - - const next = applyTypedKey(state, key); - - if (next.cancelled) { - environment.error.write('Cancelled.\n'); - - return options.default; - } - - state = { cursor: next.cursor, value: next.value }; - highlighted = null; - - choices = await resolveSearchChoices(options.options, state.value); - - renderSearchChoices(options.message, state.value, choices, highlighted, new Set(), [], options.scroll, options.info); - } -}; - -export const search = async (options: SearchPromptOptions): Promise => { - return promptUntilValid(options, async (attempt) => { - const selected = await readSearchChoice(options, attempt); - - if (selected === undefined) { - throw new PromptValidationError('Please select a valid option.'); - } - - return selected; - }); -}; - -const readMultiSearchChoices = async (options: MultiSearchPromptOptions): Promise => { - const environment = promptEnvironment(); - - if (!environment.input.readKey) { - const query = await ask(options.message, options.hint); - - const choices = await resolveSearchChoices(options.options, query); - - if (query.trim() === '' && options.default !== undefined) { - return options.default; - } - - const parts = query - .split(',') - .map((part) => part.trim()) - .filter((part) => part.length > 0); - - 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); - } - - let state = { cursor: 0, value: '' }; - - let choices = await resolveSearchChoices(options.options, state.value); - - let highlighted: number | null = null; - - const selected = new Map(); - const selectableChoices = (): Array> => choices.filter((choice) => !choice.disabled); - - const render = (): void => { - const marked = new Set(choices.flatMap((choice, index) => (selected.has(choice.value) ? [index] : []))); - - renderSearchChoices(options.message, state.value, choices, highlighted, marked, [...selected.values()], options.scroll, options.info); - }; - - render(); - - while (true) { - const key = await environment.input.readKey(); - - if (key === null || key === Key.enter) { - return [...selected.keys()]; - } - - if (key === Key.down || key === Key.downArrow || key === Key.ctrlN || key === Key.tab) { - choices = await resolveSearchChoices(options.options, state.value); - - highlighted = choices.length === 0 ? null : highlighted === null ? firstEnabledIndex(choices) : nextEnabledIndex(choices, highlighted, 1); - render(); - continue; - } - - if (key === Key.up || key === Key.upArrow || key === Key.ctrlP || key === Key.shiftTab) { - choices = await resolveSearchChoices(options.options, state.value); - - highlighted = choices.length === 0 ? null : highlighted === null ? lastEnabledIndex(choices) : nextEnabledIndex(choices, highlighted, -1); - render(); - continue; - } - - if (oneOf([Key.home], key) && highlighted !== null) { - highlighted = firstEnabledIndex(choices); - render(); - continue; - } - - if (oneOf([Key.end], key) && highlighted !== null) { - highlighted = lastEnabledIndex(choices); - render(); - continue; - } - - if (key === Key.ctrlE && highlighted !== null) { - render(); - continue; - } - - if (key === Key.ctrlA && highlighted !== null) { - const currentChoices = selectableChoices(); - const allCurrentChoicesSelected = currentChoices.every((choice) => selected.has(choice.value)); - - if (allCurrentChoicesSelected) { - for (const choice of currentChoices) { - selected.delete(choice.value); - } - } else { - for (const choice of currentChoices) { - selected.set(choice.value, choice.label); - } - } - - render(); - continue; - } - - if (key === Key.space && highlighted !== null) { - const choice = choices[highlighted]; - - if (choice && !choice.disabled) { - if (selected.has(choice.value as T)) { - selected.delete(choice.value as T); - } else { - selected.set(choice.value as T, choice.label); - } - } - - render(); - continue; - } - - const next = applyTypedKey(state, key); - - if (next.cancelled) { - environment.error.write('Cancelled.\n'); - - return options.default ?? []; - } - - state = { cursor: next.cursor, value: next.value }; - - choices = await resolveSearchChoices(options.options, state.value); - - highlighted = null; - render(); - } -}; - -export const multisearch = async (options: MultiSearchPromptOptions): Promise => { - return promptUntilValid(options, async () => readMultiSearchChoices(options)); -}; diff --git a/packages/tui/src/prompts/search/choices.ts b/packages/tui/src/prompts/search/choices.ts new file mode 100644 index 00000000..6fd1533b --- /dev/null +++ b/packages/tui/src/prompts/search/choices.ts @@ -0,0 +1,22 @@ +import { normalizeSearchChoices } from '#tui/concerns/choices'; +import type { Choice, SearchPromptOptions } from '#tui/types'; + +export const resolveSearchChoices = async (source: SearchPromptOptions['options'], query: string): Promise>> => { + const options = typeof source === 'function' ? await source(query) : source; + + 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/index.ts b/packages/tui/src/prompts/search/index.ts new file mode 100644 index 00000000..19cd73bc --- /dev/null +++ b/packages/tui/src/prompts/search/index.ts @@ -0,0 +1,20 @@ +import { promptUntilValid, PromptValidationError } from '#tui/prompt'; +import { readMultiSearchChoices } from '#tui/prompts/search/read-multi'; +import { readSearchChoice } from '#tui/prompts/search/read-single'; +import type { MultiSearchPromptOptions, SearchPromptOptions } from '#tui/types'; + +export const search = async (options: SearchPromptOptions): Promise => { + return promptUntilValid(options, async (attempt) => { + const selected = await readSearchChoice(options, attempt); + + if (selected === undefined) { + throw new PromptValidationError('Please select a valid option.'); + } + + return selected; + }); +}; + +export const multisearch = async (options: MultiSearchPromptOptions): Promise => { + return promptUntilValid(options, async () => readMultiSearchChoices(options)); +}; diff --git a/packages/tui/src/prompts/search/read-multi.ts b/packages/tui/src/prompts/search/read-multi.ts new file mode 100644 index 00000000..7f77d89d --- /dev/null +++ b/packages/tui/src/prompts/search/read-multi.ts @@ -0,0 +1,141 @@ +import { promptEnvironment } from '#tui/environment'; +import { Key, oneOf } from '#tui/key'; +import { ask, PromptValidationError } from '#tui/prompt'; +import { applyTypedKey } from '#tui/typed-value'; +import { findChoice, firstEnabledIndex, nextEnabledIndex } from '#tui/concerns/choices'; +import { lastEnabledIndex, resolveSearchChoices } from '#tui/prompts/search/choices'; +import { renderSearchChoices } from '#tui/prompts/search/render'; +import type { Choice, MultiSearchPromptOptions } from '#tui/types'; + +export const readMultiSearchChoices = async (options: MultiSearchPromptOptions): Promise => { + const environment = promptEnvironment(); + + if (!environment.input.readKey) { + const query = await ask(options.message, options.hint); + + const choices = await resolveSearchChoices(options.options, query); + + if (query.trim() === '' && options.default !== undefined) { + return options.default; + } + + const parts = query + .split(',') + .map((part) => part.trim()) + .filter((part) => part.length > 0); + + 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); + } + + let state = { cursor: 0, value: '' }; + + let choices = await resolveSearchChoices(options.options, state.value); + + let highlighted: number | null = null; + + const selected = new Map(); + const selectableChoices = (): Array> => choices.filter((choice) => !choice.disabled); + + const render = (): void => { + const marked = new Set(choices.flatMap((choice, index) => (selected.has(choice.value) ? [index] : []))); + + renderSearchChoices(options.message, state.value, choices, highlighted, marked, [...selected.values()], options.scroll, options.info); + }; + + render(); + + while (true) { + const key = await environment.input.readKey(); + + if (key === null || key === Key.enter) { + return [...selected.keys()]; + } + + if (key === Key.down || key === Key.downArrow || key === Key.ctrlN || key === Key.tab) { + choices = await resolveSearchChoices(options.options, state.value); + + highlighted = choices.length === 0 ? null : highlighted === null ? firstEnabledIndex(choices) : nextEnabledIndex(choices, highlighted, 1); + render(); + continue; + } + + if (key === Key.up || key === Key.upArrow || key === Key.ctrlP || key === Key.shiftTab) { + choices = await resolveSearchChoices(options.options, state.value); + + highlighted = choices.length === 0 ? null : highlighted === null ? lastEnabledIndex(choices) : nextEnabledIndex(choices, highlighted, -1); + render(); + continue; + } + + if (oneOf([Key.home], key) && highlighted !== null) { + highlighted = firstEnabledIndex(choices); + render(); + continue; + } + + if (oneOf([Key.end], key) && highlighted !== null) { + highlighted = lastEnabledIndex(choices); + render(); + continue; + } + + if (key === Key.ctrlE && highlighted !== null) { + render(); + continue; + } + + if (key === Key.ctrlA && highlighted !== null) { + const currentChoices = selectableChoices(); + const allCurrentChoicesSelected = currentChoices.every((choice) => selected.has(choice.value)); + + if (allCurrentChoicesSelected) { + for (const choice of currentChoices) { + selected.delete(choice.value); + } + } else { + for (const choice of currentChoices) { + selected.set(choice.value, choice.label); + } + } + + render(); + continue; + } + + if (key === Key.space && highlighted !== null) { + const choice = choices[highlighted]; + + if (choice && !choice.disabled) { + if (selected.has(choice.value as T)) { + selected.delete(choice.value as T); + } else { + selected.set(choice.value as T, choice.label); + } + } + + render(); + continue; + } + + const next = applyTypedKey(state, key); + + if (next.cancelled) { + environment.error.write('Cancelled.\n'); + + return options.default ?? []; + } + + state = { cursor: next.cursor, value: next.value }; + + choices = await resolveSearchChoices(options.options, state.value); + + highlighted = null; + render(); + } +}; diff --git a/packages/tui/src/prompts/search/read-single.ts b/packages/tui/src/prompts/search/read-single.ts new file mode 100644 index 00000000..1584b932 --- /dev/null +++ b/packages/tui/src/prompts/search/read-single.ts @@ -0,0 +1,102 @@ +import { promptEnvironment } from '#tui/environment'; +import { Key, oneOf } from '#tui/key'; +import { ask } from '#tui/prompt'; +import { applyTypedKey } from '#tui/typed-value'; +import { findChoice, firstEnabledIndex, nextEnabledIndex } from '#tui/concerns/choices'; +import { lastEnabledIndex, resolveSearchChoices } from '#tui/prompts/search/choices'; +import { renderSearchChoices } from '#tui/prompts/search/render'; +import type { SearchPromptOptions } from '#tui/types'; + +export const readSearchChoice = async (options: SearchPromptOptions, attempt = 0): Promise => { + const environment = promptEnvironment(); + + if (!environment.input.readKey) { + const query = await ask(options.message, options.hint); + + const choices = await resolveSearchChoices(options.options, query); + + const choice = findChoice(choices, query) ?? choices.find((candidate) => !candidate.disabled); + + return choice?.value ?? options.default ?? choices[0]?.value; + } + + let state = { cursor: 0, value: '' }; + + let choices = await resolveSearchChoices(options.options, state.value); + + let highlighted: number | null = null; + + renderSearchChoices(options.message, state.value, choices, highlighted, new Set(), [], options.scroll, options.info); + + while (true) { + const key = await environment.input.readKey(); + + if (key === null) { + return options.default; + } + + if (key === Key.down || key === Key.downArrow || key === Key.ctrlN || key === Key.tab) { + choices = await resolveSearchChoices(options.options, state.value); + + highlighted = + choices.length === 0 + ? null + : highlighted === null + ? attempt > 0 + ? nextEnabledIndex(choices, firstEnabledIndex(choices), 1) + : firstEnabledIndex(choices) + : nextEnabledIndex(choices, highlighted, 1); + renderSearchChoices(options.message, state.value, choices, highlighted, new Set(), [], options.scroll, options.info); + continue; + } + + if (key === Key.up || key === Key.upArrow || key === Key.ctrlP || key === Key.shiftTab) { + choices = await resolveSearchChoices(options.options, state.value); + + highlighted = choices.length === 0 ? null : highlighted === null ? lastEnabledIndex(choices) : nextEnabledIndex(choices, highlighted, -1); + renderSearchChoices(options.message, state.value, choices, highlighted, new Set(), [], options.scroll, options.info); + continue; + } + + if (oneOf([Key.home, Key.ctrlA], key) && highlighted !== null) { + highlighted = firstEnabledIndex(choices); + renderSearchChoices(options.message, state.value, choices, highlighted, new Set(), [], options.scroll, options.info); + continue; + } + + if (oneOf([Key.end, Key.ctrlE], key) && highlighted !== null) { + highlighted = lastEnabledIndex(choices); + renderSearchChoices(options.message, state.value, choices, highlighted, new Set(), [], options.scroll, options.info); + continue; + } + + if (key === Key.enter) { + if (highlighted !== null) { + const choice = choices[highlighted]; + + return choice?.disabled ? undefined : choice?.value; + } + + choices = await resolveSearchChoices(options.options, state.value); + + highlighted = choices.length > 0 ? firstEnabledIndex(choices) : null; + renderSearchChoices(options.message, state.value, choices, highlighted, new Set(), [], options.scroll, options.info); + continue; + } + + const next = applyTypedKey(state, key); + + if (next.cancelled) { + environment.error.write('Cancelled.\n'); + + return options.default; + } + + state = { cursor: next.cursor, value: next.value }; + highlighted = null; + + choices = await resolveSearchChoices(options.options, state.value); + + renderSearchChoices(options.message, state.value, choices, highlighted, new Set(), [], options.scroll, options.info); + } +}; diff --git a/packages/tui/src/prompts/search/render.ts b/packages/tui/src/prompts/search/render.ts new file mode 100644 index 00000000..8799c0e7 --- /dev/null +++ b/packages/tui/src/prompts/search/render.ts @@ -0,0 +1,28 @@ +import { promptEnvironment } from '#tui/environment'; +import { renderInteractiveChoices } from '#tui/concerns/choices'; +import { resolveInfo } from '#tui/concerns/info'; +import { searchMessage } from '#tui/prompts/search/choices'; +import type { Choice, MultiSearchPromptOptions, SearchPromptOptions } from '#tui/types'; + +export const renderSearchChoices = ( + message: string, + query: string, + choices: Array>, + highlighted: number | null, + marked: Set = new Set(), + selectedLabels: string[] = [], + scroll?: number, + info?: SearchPromptOptions['info'] | MultiSearchPromptOptions['info'], +): void => { + renderInteractiveChoices(searchMessage(message, query), choices, highlighted ?? 0, marked, scroll); + + const text = resolveInfo(info, highlighted === null ? null : (choices[highlighted]?.value ?? null)); + + if (text.length > 0) { + promptEnvironment().output.write(`${text}\n`); + } + + if (selectedLabels.length > 0) { + promptEnvironment().output.write(`Selected: ${selectedLabels.join(', ')}\n`); + } +}; diff --git a/packages/tui/src/prompts/select.ts b/packages/tui/src/prompts/select.ts deleted file mode 100644 index 6f6bd92e..00000000 --- a/packages/tui/src/prompts/select.ts +++ /dev/null @@ -1,292 +0,0 @@ -import { promptEnvironment } from '#tui/environment'; -import { Key, oneOf } from '#tui/key'; -import { ask, promptUntilValid, PromptValidationError } from '#tui/prompt'; -import { renderChoices } from '#tui/theme'; -import { findChoice, firstEnabledIndex, nextEnabledIndex, normalizeChoices, renderInteractiveChoices } from '#tui/concerns/choices'; -import { resolveInfo } from '#tui/concerns/info'; -import type { Choice, ConfirmPromptOptions, MultiSelectPromptOptions, SelectPromptOptions } from '#tui/types'; - -const parseChoiceIndex = (key: string): number => (/^\d+$/u.test(key) ? Number.parseInt(key, 10) : Number.NaN); - -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'; -}; - -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'; -}; - -const renderSelectedChoice = (message: string, choices: Array>, selected: number, scroll: number | undefined, info: SelectPromptOptions['info']): void => { - renderInteractiveChoices(message, choices, selected, new Set(), scroll); - - const text = resolveInfo(info, choices[selected]?.value ?? null); - - if (text.length > 0) { - promptEnvironment().output.write(`${text}\n`); - } -}; - -const readSelectedChoice = async (message: string, choices: Array>, hint?: string, scroll?: number, info?: SelectPromptOptions['info']): Promise => { - const environment = promptEnvironment(); - - if (!environment.input.readKey) { - const rendered = renderChoices(choices); - - const answer = await ask(`${message}\n${rendered}\n`, hint); - - const choice = findChoice(choices, answer); - - if (!choice || choice.disabled) { - throw new PromptValidationError('Please select a valid option.'); - } - - return choice.value; - } - - let selected = firstEnabledIndex(choices); - - renderSelectedChoice(message, choices, selected, scroll, info); - - while (true) { - const key = await environment.input.readKey(); - - if (key === null) { - throw new PromptValidationError('Please select a valid option.'); - } - - const numeric = parseChoiceIndex(key); - - if (!Number.isNaN(numeric) && choices[numeric - 1] && !choices[numeric - 1]?.disabled) { - return choices[numeric - 1].value; - } - - if (nextChoiceKeys(key)) { - selected = nextEnabledIndex(choices, selected, 1); - renderSelectedChoice(message, choices, selected, scroll, info); - continue; - } - - if (previousChoiceKeys(key)) { - selected = nextEnabledIndex(choices, selected, -1); - renderSelectedChoice(message, choices, selected, scroll, info); - continue; - } - - if (oneOf([Key.home, Key.ctrlA], key)) { - selected = firstEnabledIndex(choices); - renderSelectedChoice(message, choices, selected, scroll, info); - continue; - } - - if (oneOf([Key.end, Key.ctrlE], key)) { - selected = choices.length - 1; - - while (choices[selected]?.disabled && selected > 0) { - selected -= 1; - } - - renderSelectedChoice(message, choices, selected, scroll, info); - continue; - } - - if (key === Key.enter) { - const choice = choices[selected]; - - if (!choice || choice.disabled) { - throw new PromptValidationError('Please select a valid option.'); - } - - return choice.value; - } - } -}; - -const choicesFromCommaSeparated = (choices: Array>, answer: string): T[] => { - const parts = answer - .split(',') - .map((part) => part.trim()) - .filter((part) => part.length > 0); - - const selected = parts.map((part) => findChoice(choices, part)).filter((choice): choice is Choice => choice !== undefined && !choice.disabled); - - if (selected.length !== parts.length) { - throw new PromptValidationError('Please select valid options.'); - } - - return selected.map((choice) => choice.value); -}; - -const renderMultipleChoices = (message: string, choices: Array>, selected: number, marked: Set, scroll?: number, info?: MultiSelectPromptOptions['info']): void => { - renderInteractiveChoices(message, choices, selected, marked, scroll); - - const text = resolveInfo(info, choices[selected]?.value ?? null); - - if (text.length > 0) { - promptEnvironment().output.write(`${text}\n`); - } - - const labels = [...marked] - .sort((left, right) => left - right) - .map((index) => choices[index]?.label) - .filter((label): label is string => label !== undefined); - - if (labels.length > 0) { - promptEnvironment().output.write(`Selected: ${labels.join(', ')}\n`); - } -}; - -const readMultipleChoices = async (message: string, choices: Array>, defaults: T[] = [], hint?: string, scroll?: number, info?: MultiSelectPromptOptions['info']): Promise => { - const environment = promptEnvironment(); - const selectedValues = new Set(defaults); - - if (!environment.input.readKey) { - const rendered = renderChoices(choices); - - const answer = await ask(`${message}\n${rendered}\n`, hint); - - return answer.trim() === '' ? defaults : choicesFromCommaSeparated(choices, answer); - } - - let selected = firstEnabledIndex(choices); - - const marked = new Set(choices.flatMap((choice, index) => (selectedValues.has(choice.value) ? [index] : []))); - - renderMultipleChoices(message, choices, selected, marked, scroll, info); - - while (true) { - const key = await environment.input.readKey(); - - if (key === null) { - return [...marked].map((index) => choices[index]?.value).filter((value): value is T => value !== undefined); - } - - if (key.includes(',')) { - return choicesFromCommaSeparated(choices, key); - } - - const numeric = parseChoiceIndex(key); - - if (!Number.isNaN(numeric) && choices[numeric - 1] && !choices[numeric - 1]?.disabled) { - const index = numeric - 1; - - if (marked.has(index)) { - marked.delete(index); - } else { - marked.add(index); - } - - renderMultipleChoices(message, choices, selected, marked, scroll, info); - continue; - } - - if (nextChoiceKeys(key)) { - selected = nextEnabledIndex(choices, selected, 1); - renderMultipleChoices(message, choices, selected, marked, scroll, info); - continue; - } - - if (previousChoiceKeys(key)) { - selected = nextEnabledIndex(choices, selected, -1); - renderMultipleChoices(message, choices, selected, marked, scroll, info); - continue; - } - - if (oneOf([Key.home], key)) { - selected = firstEnabledIndex(choices); - renderMultipleChoices(message, choices, selected, marked, scroll, info); - continue; - } - - if (oneOf([Key.end], key)) { - selected = choices.length - 1; - - while (choices[selected]?.disabled && selected > 0) { - selected -= 1; - } - - renderMultipleChoices(message, choices, selected, marked, scroll, info); - continue; - } - - if (key === Key.ctrlA) { - if (marked.size === choices.filter((choice) => !choice.disabled).length) { - marked.clear(); - } else { - marked.clear(); - - for (const [index, choice] of choices.entries()) { - if (!choice.disabled) { - marked.add(index); - } - } - } - - renderMultipleChoices(message, choices, selected, marked, scroll, info); - continue; - } - - if (key === Key.space) { - if (marked.has(selected)) { - marked.delete(selected); - } else if (!choices[selected]?.disabled) { - marked.add(selected); - } - - renderMultipleChoices(message, choices, selected, marked, scroll, info); - continue; - } - - if (key === Key.enter) { - return [...marked].map((index) => choices[index]?.value).filter((value): value is T => value !== undefined); - } - } -}; - -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): Promise; - -export async function confirm( - message: string | ConfirmPromptOptions, - defaultValue = true, - yes = 'Yes', - no = 'No', - required: boolean | string = false, - validate: ConfirmPromptOptions['validate'] = undefined, - hint = '', -): Promise { - const options: ConfirmPromptOptions = - typeof message === 'string' ? { message, label: message, default: defaultValue, yes, no, required, validate, hint } : { ...message, default: message.default ?? true }; - - return promptUntilValid(options, async () => { - const suffix = options.default === false ? ' [y/N]' : ' [Y/n]'; - - const answer = (await ask(`${options.message}${suffix}`, options.hint)).trim().toLowerCase(); - - if (answer === '' && options.default !== undefined) { - return options.default; - } - - return ['y', 'yes', options.yes?.toLowerCase()].includes(answer); - }); -} - -export const select = async (options: SelectPromptOptions): Promise => { - const choices = normalizeChoices(options.options); - - return promptUntilValid(options, async () => { - return readSelectedChoice(options.message, choices, options.hint, options.scroll, options.info).catch((error: unknown) => { - if (options.default !== undefined && error instanceof PromptValidationError) { - return options.default; - } - - throw error; - }); - }); -}; - -export const multiselect = async (options: MultiSelectPromptOptions): Promise => { - const choices = normalizeChoices(options.options); - - return promptUntilValid(options, async () => readMultipleChoices(options.message, choices, options.default, options.hint, options.scroll, options.info)); -}; diff --git a/packages/tui/src/prompts/select/confirm.ts b/packages/tui/src/prompts/select/confirm.ts new file mode 100644 index 00000000..2334818b --- /dev/null +++ b/packages/tui/src/prompts/select/confirm.ts @@ -0,0 +1,31 @@ +import { ask, promptUntilValid } from '#tui/prompt'; +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): Promise; + +export async function confirm( + message: string | ConfirmPromptOptions, + defaultValue = true, + yes = 'Yes', + no = 'No', + required: boolean | string = false, + validate: ConfirmPromptOptions['validate'] = undefined, + hint = '', +): Promise { + const options: ConfirmPromptOptions = + typeof message === 'string' ? { message, label: message, default: defaultValue, yes, no, required, validate, hint } : { ...message, default: message.default ?? true }; + + return promptUntilValid(options, async () => { + const suffix = options.default === false ? ' [y/N]' : ' [Y/n]'; + + const answer = (await ask(`${options.message}${suffix}`, options.hint)).trim().toLowerCase(); + + if (answer === '' && options.default !== undefined) { + return options.default; + } + + return ['y', 'yes', options.yes?.toLowerCase()].includes(answer); + }); +} diff --git a/packages/tui/src/prompts/select/index.ts b/packages/tui/src/prompts/select/index.ts new file mode 100644 index 00000000..687bf355 --- /dev/null +++ b/packages/tui/src/prompts/select/index.ts @@ -0,0 +1,27 @@ +export { confirm } from '#tui/prompts/select/confirm'; + +import { promptUntilValid, PromptValidationError } from '#tui/prompt'; +import { normalizeChoices } from '#tui/concerns/choices'; +import { readMultipleChoices } from '#tui/prompts/select/read-multiple'; +import { readSelectedChoice } from '#tui/prompts/select/read-selected'; +import type { MultiSelectPromptOptions, SelectPromptOptions } from '#tui/types'; + +export const select = async (options: SelectPromptOptions): Promise => { + const choices = normalizeChoices(options.options); + + return promptUntilValid(options, async () => { + return readSelectedChoice(options.message, choices, options.hint, options.scroll, options.info).catch((error: unknown) => { + if (options.default !== undefined && error instanceof PromptValidationError) { + return options.default; + } + + throw error; + }); + }); +}; + +export const multiselect = async (options: MultiSelectPromptOptions): Promise => { + const choices = normalizeChoices(options.options); + + return promptUntilValid(options, async () => readMultipleChoices(options.message, choices, options.default, options.hint, options.scroll, options.info)); +}; diff --git a/packages/tui/src/prompts/select/navigation.ts b/packages/tui/src/prompts/select/navigation.ts new file mode 100644 index 00000000..a59a25cc --- /dev/null +++ b/packages/tui/src/prompts/select/navigation.ts @@ -0,0 +1,22 @@ +import { Key } from '#tui/key'; +import type { Choice } from '#tui/types'; + +export const parseChoiceIndex = (key: string): number => (/^\d+$/u.test(key) ? Number.parseInt(key, 10) : Number.NaN); + +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; +}; diff --git a/packages/tui/src/prompts/select/read-multiple.ts b/packages/tui/src/prompts/select/read-multiple.ts new file mode 100644 index 00000000..f3586675 --- /dev/null +++ b/packages/tui/src/prompts/select/read-multiple.ts @@ -0,0 +1,132 @@ +import { promptEnvironment } from '#tui/environment'; +import { Key, oneOf } from '#tui/key'; +import { ask, PromptValidationError } from '#tui/prompt'; +import { renderChoices } from '#tui/theme'; +import { findChoice, firstEnabledIndex, nextEnabledIndex } from '#tui/concerns/choices'; +import { lastEnabledChoiceIndex, nextChoiceKeys, parseChoiceIndex, previousChoiceKeys } from '#tui/prompts/select/navigation'; +import { renderMultipleChoices } from '#tui/prompts/select/render'; +import type { Choice, MultiSelectPromptOptions } from '#tui/types'; + +const choicesFromCommaSeparated = (choices: Array>, answer: string): T[] => { + const parts = answer + .split(',') + .map((part) => part.trim()) + .filter((part) => part.length > 0); + + const selected = parts.map((part) => findChoice(choices, part)).filter((choice): choice is Choice => choice !== undefined && !choice.disabled); + + if (selected.length !== parts.length) { + throw new PromptValidationError('Please select valid options.'); + } + + return selected.map((choice) => choice.value); +}; + +export const readMultipleChoices = async ( + message: string, + choices: Array>, + defaults: T[] = [], + hint?: string, + scroll?: number, + info?: MultiSelectPromptOptions['info'], +): Promise => { + const environment = promptEnvironment(); + const selectedValues = new Set(defaults); + + if (!environment.input.readKey) { + const rendered = renderChoices(choices); + + const answer = await ask(`${message}\n${rendered}\n`, hint); + + return answer.trim() === '' ? defaults : choicesFromCommaSeparated(choices, answer); + } + + let selected = firstEnabledIndex(choices); + + const marked = new Set(choices.flatMap((choice, index) => (selectedValues.has(choice.value) ? [index] : []))); + + renderMultipleChoices(message, choices, selected, marked, scroll, info); + + while (true) { + const key = await environment.input.readKey(); + + if (key === null) { + return [...marked].map((index) => choices[index]?.value).filter((value): value is T => value !== undefined); + } + + if (key.includes(',')) { + return choicesFromCommaSeparated(choices, key); + } + + const numeric = parseChoiceIndex(key); + + if (!Number.isNaN(numeric) && choices[numeric - 1] && !choices[numeric - 1]?.disabled) { + const index = numeric - 1; + + if (marked.has(index)) { + marked.delete(index); + } else { + marked.add(index); + } + + renderMultipleChoices(message, choices, selected, marked, scroll, info); + continue; + } + + if (nextChoiceKeys(key)) { + selected = nextEnabledIndex(choices, selected, 1); + renderMultipleChoices(message, choices, selected, marked, scroll, info); + continue; + } + + if (previousChoiceKeys(key)) { + selected = nextEnabledIndex(choices, selected, -1); + renderMultipleChoices(message, choices, selected, marked, scroll, info); + continue; + } + + if (oneOf([Key.home], key)) { + selected = firstEnabledIndex(choices); + renderMultipleChoices(message, choices, selected, marked, scroll, info); + continue; + } + + if (oneOf([Key.end], key)) { + selected = lastEnabledChoiceIndex(choices); + renderMultipleChoices(message, choices, selected, marked, scroll, info); + continue; + } + + if (key === Key.ctrlA) { + if (marked.size === choices.filter((choice) => !choice.disabled).length) { + marked.clear(); + } else { + marked.clear(); + + for (const [index, choice] of choices.entries()) { + if (!choice.disabled) { + marked.add(index); + } + } + } + + renderMultipleChoices(message, choices, selected, marked, scroll, info); + continue; + } + + if (key === Key.space) { + if (marked.has(selected)) { + marked.delete(selected); + } else if (!choices[selected]?.disabled) { + marked.add(selected); + } + + renderMultipleChoices(message, choices, selected, marked, scroll, info); + continue; + } + + if (key === Key.enter) { + return [...marked].map((index) => choices[index]?.value).filter((value): value is T => value !== undefined); + } + } +}; diff --git a/packages/tui/src/prompts/select/read-selected.ts b/packages/tui/src/prompts/select/read-selected.ts new file mode 100644 index 00000000..10b0048e --- /dev/null +++ b/packages/tui/src/prompts/select/read-selected.ts @@ -0,0 +1,78 @@ +import { promptEnvironment } from '#tui/environment'; +import { Key, oneOf } from '#tui/key'; +import { ask, PromptValidationError } from '#tui/prompt'; +import { renderChoices } from '#tui/theme'; +import { findChoice, firstEnabledIndex, nextEnabledIndex } from '#tui/concerns/choices'; +import { lastEnabledChoiceIndex, nextChoiceKeys, parseChoiceIndex, previousChoiceKeys } from '#tui/prompts/select/navigation'; +import { renderSelectedChoice } from '#tui/prompts/select/render'; +import type { Choice, SelectPromptOptions } from '#tui/types'; + +export const readSelectedChoice = async (message: string, choices: Array>, hint?: string, scroll?: number, info?: SelectPromptOptions['info']): Promise => { + const environment = promptEnvironment(); + + if (!environment.input.readKey) { + const rendered = renderChoices(choices); + + const answer = await ask(`${message}\n${rendered}\n`, hint); + + const choice = findChoice(choices, answer); + + if (!choice || choice.disabled) { + throw new PromptValidationError('Please select a valid option.'); + } + + return choice.value; + } + + let selected = firstEnabledIndex(choices); + + renderSelectedChoice(message, choices, selected, scroll, info); + + while (true) { + const key = await environment.input.readKey(); + + if (key === null) { + throw new PromptValidationError('Please select a valid option.'); + } + + const numeric = parseChoiceIndex(key); + + if (!Number.isNaN(numeric) && choices[numeric - 1] && !choices[numeric - 1]?.disabled) { + return choices[numeric - 1].value; + } + + if (nextChoiceKeys(key)) { + selected = nextEnabledIndex(choices, selected, 1); + renderSelectedChoice(message, choices, selected, scroll, info); + continue; + } + + if (previousChoiceKeys(key)) { + selected = nextEnabledIndex(choices, selected, -1); + renderSelectedChoice(message, choices, selected, scroll, info); + continue; + } + + if (oneOf([Key.home, Key.ctrlA], key)) { + selected = firstEnabledIndex(choices); + renderSelectedChoice(message, choices, selected, scroll, info); + continue; + } + + if (oneOf([Key.end, Key.ctrlE], key)) { + selected = lastEnabledChoiceIndex(choices); + renderSelectedChoice(message, choices, selected, scroll, info); + continue; + } + + if (key === Key.enter) { + const choice = choices[selected]; + + if (!choice || choice.disabled) { + throw new PromptValidationError('Please select a valid option.'); + } + + return choice.value; + } + } +}; diff --git a/packages/tui/src/prompts/select/render.ts b/packages/tui/src/prompts/select/render.ts new file mode 100644 index 00000000..da1be7a4 --- /dev/null +++ b/packages/tui/src/prompts/select/render.ts @@ -0,0 +1,33 @@ +import { promptEnvironment } from '#tui/environment'; +import { renderInteractiveChoices } from '#tui/concerns/choices'; +import { resolveInfo } from '#tui/concerns/info'; +import type { Choice, MultiSelectPromptOptions, SelectPromptOptions } from '#tui/types'; + +export const renderSelectedChoice = (message: string, choices: Array>, selected: number, scroll: number | undefined, info: SelectPromptOptions['info']): void => { + renderInteractiveChoices(message, choices, selected, new Set(), scroll); + + const text = resolveInfo(info, choices[selected]?.value ?? null); + + if (text.length > 0) { + promptEnvironment().output.write(`${text}\n`); + } +}; + +export const renderMultipleChoices = (message: string, choices: Array>, selected: number, marked: Set, scroll?: number, info?: MultiSelectPromptOptions['info']): void => { + renderInteractiveChoices(message, choices, selected, marked, scroll); + + const text = resolveInfo(info, choices[selected]?.value ?? null); + + if (text.length > 0) { + promptEnvironment().output.write(`${text}\n`); + } + + const labels = [...marked] + .sort((left, right) => left - right) + .map((index) => choices[index]?.label) + .filter((label): label is string => label !== undefined); + + if (labels.length > 0) { + promptEnvironment().output.write(`Selected: ${labels.join(', ')}\n`); + } +}; diff --git a/packages/tui/src/prompts/suggest.ts b/packages/tui/src/prompts/suggest.ts deleted file mode 100644 index bbafe7be..00000000 --- a/packages/tui/src/prompts/suggest.ts +++ /dev/null @@ -1,216 +0,0 @@ -import { promptEnvironment } from '#tui/environment'; -import { Key, oneOf } from '#tui/key'; -import { ask, promptUntilValid } from '#tui/prompt'; -import { applyTypedKey } from '#tui/typed-value'; -import { renderInteractiveChoices } from '#tui/concerns/choices'; -import { resolveInfo } from '#tui/concerns/info'; -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); -}; - -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, -): SuggestOptions => { - if (typeof message === 'string') { - return { message, label: message, options, placeholder, default: defaultValue, scroll, required, validate, hint, transform }; - } - - return { ...message, default: message.default ?? '' }; -}; - -const resolveSuggestions = async (source: SuggestOptions['options'], query: string): Promise => { - const options = typeof source === 'function' ? await source(query) : source; - - if (typeof source === 'function') { - return [...options]; - } - - return options.filter((option: string) => option.toLowerCase().startsWith(query.toLowerCase())); -}; - -const renderSuggestions = (message: string, value: string, matches: string[], highlighted: number | null, scroll?: number, info?: SuggestOptions['info']): void => { - renderInteractiveChoices( - value.length > 0 ? `${message} ${value}` : message, - matches.map((match) => ({ label: match, value: match })), - highlighted ?? 0, - new Set(), - scroll, - ); - - const text = resolveInfo(info, highlighted === null ? null : (matches[highlighted] ?? null)); - - if (text.length > 0) { - promptEnvironment().output.write(`${text}\n`); - } -}; - -const readSuggestionValue = async (options: SuggestOptions): Promise => { - const environment = promptEnvironment(); - - if (!environment.input.readKey) { - return ask(options.message, options.hint); - } - - let state = { - cursor: options.default?.length ?? 0, - value: options.default ?? '', - }; - let highlighted: number | null = null; - - let matches: string[] = await resolveSuggestions(options.options, state.value); - - renderSuggestions(options.message, state.value, matches, highlighted, options.scroll, options.info); - - while (true) { - const key = await environment.input.readKey(); - - if (key === null) { - return state.value; - } - - if (key === Key.tab) { - matches = await resolveSuggestions(options.options, state.value); - - const match = matches[highlighted ?? 0]; - - if (match !== undefined) { - state = { cursor: match.length, value: match }; - } - - matches = await resolveSuggestions(options.options, state.value); - - highlighted = null; - renderSuggestions(options.message, state.value, matches, highlighted, options.scroll, options.info); - continue; - } - - if (key === Key.down || key === Key.downArrow || key === Key.ctrlN || key === Key.shiftTab) { - matches = await resolveSuggestions(options.options, state.value); - - highlighted = matches.length === 0 ? null : ((highlighted ?? -1) + 1) % matches.length; - renderSuggestions(options.message, state.value, matches, highlighted, options.scroll, options.info); - continue; - } - - if (key === Key.up || key === Key.upArrow || key === Key.ctrlP) { - matches = await resolveSuggestions(options.options, state.value); - - highlighted = matches.length === 0 ? null : ((highlighted ?? matches.length) - 1 + matches.length) % matches.length; - renderSuggestions(options.message, state.value, matches, highlighted, options.scroll, options.info); - continue; - } - - if (oneOf([Key.home, Key.ctrlA], key) && highlighted !== null) { - highlighted = 0; - renderSuggestions(options.message, state.value, matches, highlighted, options.scroll, options.info); - continue; - } - - if (oneOf([Key.end, Key.ctrlE], key) && highlighted !== null) { - highlighted = Math.max(0, matches.length - 1); - renderSuggestions(options.message, state.value, matches, highlighted, options.scroll, options.info); - continue; - } - - if (key === Key.enter) { - if (highlighted !== null && matches[highlighted] !== undefined) { - return matches[highlighted]; - } - - return state.value; - } - - const next = applyTypedKey(state, key); - - if (next.submitted) { - return state.value; - } - - if (next.cancelled) { - environment.error.write('Cancelled.\n'); - - return state.value; - } - - state = { cursor: next.cursor, value: next.value }; - highlighted = null; - - matches = await resolveSuggestions(options.options, state.value); - - renderSuggestions(options.message, state.value, matches, highlighted, options.scroll, options.info); - } -}; - -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'], -): 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, -): Promise { - const options = suggestOptions(message, source, placeholder, defaultValue, scroll, required, validate, hint, transform); - - return promptUntilValid(options, async () => { - const answer = await readSuggestionValue(options); - - const value = answer === '' && options.default !== undefined ? options.default : answer; - - return options.transform ? options.transform(value) : value; - }); -} - -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'], -): 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, -): Promise { - return suggest(typeof message === 'string' ? { message, label: message, options: source, placeholder, default: defaultValue, required, validate, hint, transform } : message); -} diff --git a/packages/tui/src/prompts/suggest/index.ts b/packages/tui/src/prompts/suggest/index.ts new file mode 100644 index 00000000..3d6966c6 --- /dev/null +++ b/packages/tui/src/prompts/suggest/index.ts @@ -0,0 +1,69 @@ +import { promptUntilValid } from '#tui/prompt'; +import { readSuggestionValue } from '#tui/prompts/suggest/read'; +import { suggestOptions } from '#tui/prompts/suggest/options'; +import type { SuggestOptions } from '#tui/prompts/suggest/options'; +import type { MaybePromise, TextPromptOptions } from '#tui/types'; + +export type { SuggestOptions } from '#tui/prompts/suggest/options'; + +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'], +): 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, +): Promise { + const options = suggestOptions(message, source, placeholder, defaultValue, scroll, required, validate, hint, transform); + + return promptUntilValid(options, async () => { + const answer = await readSuggestionValue(options); + + const value = answer === '' && options.default !== undefined ? options.default : answer; + + return options.transform ? options.transform(value) : value; + }); +} + +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'], +): 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, +): Promise { + return suggest(typeof message === 'string' ? { message, label: message, options: source, placeholder, default: defaultValue, required, validate, hint, transform } : message); +} diff --git a/packages/tui/src/prompts/suggest/options.ts b/packages/tui/src/prompts/suggest/options.ts new file mode 100644 index 00000000..0bbe45f4 --- /dev/null +++ b/packages/tui/src/prompts/suggest/options.ts @@ -0,0 +1,25 @@ +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, +): SuggestOptions => { + if (typeof message === 'string') { + return { message, label: message, options, placeholder, default: defaultValue, scroll, required, validate, hint, transform }; + } + + return { ...message, default: message.default ?? '' }; +}; diff --git a/packages/tui/src/prompts/suggest/read.ts b/packages/tui/src/prompts/suggest/read.ts new file mode 100644 index 00000000..bb70e2f2 --- /dev/null +++ b/packages/tui/src/prompts/suggest/read.ts @@ -0,0 +1,104 @@ +import { promptEnvironment } from '#tui/environment'; +import { Key, oneOf } from '#tui/key'; +import { ask } from '#tui/prompt'; +import { applyTypedKey } from '#tui/typed-value'; +import { renderSuggestions } from '#tui/prompts/suggest/render'; +import { resolveSuggestions } from '#tui/prompts/suggest/resolve'; +import type { SuggestOptions } from '#tui/prompts/suggest/options'; + +export const readSuggestionValue = async (options: SuggestOptions): Promise => { + const environment = promptEnvironment(); + + if (!environment.input.readKey) { + return ask(options.message, options.hint); + } + + let state = { + cursor: options.default?.length ?? 0, + value: options.default ?? '', + }; + let highlighted: number | null = null; + + let matches: string[] = await resolveSuggestions(options.options, state.value); + + renderSuggestions(options.message, state.value, matches, highlighted, options.scroll, options.info); + + while (true) { + const key = await environment.input.readKey(); + + if (key === null) { + return state.value; + } + + if (key === Key.tab) { + matches = await resolveSuggestions(options.options, state.value); + + const match = matches[highlighted ?? 0]; + + if (match !== undefined) { + state = { cursor: match.length, value: match }; + } + + matches = await resolveSuggestions(options.options, state.value); + + highlighted = null; + renderSuggestions(options.message, state.value, matches, highlighted, options.scroll, options.info); + continue; + } + + if (key === Key.down || key === Key.downArrow || key === Key.ctrlN || key === Key.shiftTab) { + matches = await resolveSuggestions(options.options, state.value); + + highlighted = matches.length === 0 ? null : ((highlighted ?? -1) + 1) % matches.length; + renderSuggestions(options.message, state.value, matches, highlighted, options.scroll, options.info); + continue; + } + + if (key === Key.up || key === Key.upArrow || key === Key.ctrlP) { + matches = await resolveSuggestions(options.options, state.value); + + highlighted = matches.length === 0 ? null : ((highlighted ?? matches.length) - 1 + matches.length) % matches.length; + renderSuggestions(options.message, state.value, matches, highlighted, options.scroll, options.info); + continue; + } + + if (oneOf([Key.home, Key.ctrlA], key) && highlighted !== null) { + highlighted = 0; + renderSuggestions(options.message, state.value, matches, highlighted, options.scroll, options.info); + continue; + } + + if (oneOf([Key.end, Key.ctrlE], key) && highlighted !== null) { + highlighted = Math.max(0, matches.length - 1); + renderSuggestions(options.message, state.value, matches, highlighted, options.scroll, options.info); + continue; + } + + if (key === Key.enter) { + if (highlighted !== null && matches[highlighted] !== undefined) { + return matches[highlighted]; + } + + return state.value; + } + + const next = applyTypedKey(state, key); + + if (next.submitted) { + return state.value; + } + + if (next.cancelled) { + environment.error.write('Cancelled.\n'); + + return state.value; + } + + state = { cursor: next.cursor, value: next.value }; + highlighted = null; + + matches = await resolveSuggestions(options.options, state.value); + + renderSuggestions(options.message, state.value, matches, highlighted, options.scroll, options.info); + } +}; diff --git a/packages/tui/src/prompts/suggest/render.ts b/packages/tui/src/prompts/suggest/render.ts new file mode 100644 index 00000000..10e44fdf --- /dev/null +++ b/packages/tui/src/prompts/suggest/render.ts @@ -0,0 +1,20 @@ +import { promptEnvironment } from '#tui/environment'; +import { renderInteractiveChoices } from '#tui/concerns/choices'; +import { resolveInfo } from '#tui/concerns/info'; +import type { SuggestOptions } from '#tui/prompts/suggest/options'; + +export const renderSuggestions = (message: string, value: string, matches: string[], highlighted: number | null, scroll?: number, info?: SuggestOptions['info']): void => { + renderInteractiveChoices( + value.length > 0 ? `${message} ${value}` : message, + matches.map((match) => ({ label: match, value: match })), + highlighted ?? 0, + new Set(), + scroll, + ); + + const text = resolveInfo(info, highlighted === null ? null : (matches[highlighted] ?? null)); + + if (text.length > 0) { + promptEnvironment().output.write(`${text}\n`); + } +}; diff --git a/packages/tui/src/prompts/suggest/resolve.ts b/packages/tui/src/prompts/suggest/resolve.ts new file mode 100644 index 00000000..826956b9 --- /dev/null +++ b/packages/tui/src/prompts/suggest/resolve.ts @@ -0,0 +1,11 @@ +import type { SuggestOptions } from '#tui/prompts/suggest/options'; + +export const resolveSuggestions = async (source: SuggestOptions['options'], query: string): Promise => { + const options = typeof source === 'function' ? await source(query) : source; + + if (typeof source === 'function') { + return [...options]; + } + + return options.filter((option: string) => option.toLowerCase().startsWith(query.toLowerCase())); +}; From 9279fd107934e9d1e599053a9ae1055205c37307 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 14:56:21 +0800 Subject: [PATCH 032/675] Expose package for library consumption --- packages/tui/package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/tui/package.json b/packages/tui/package.json index 93fbc6f8..84518727 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,7 +1,6 @@ { "name": "@ollin/tui", "version": "0.1.0", - "private": true, "type": "module", "license": "MIT", "exports": { From a1cf69fec1a853180e3875979730a3d3a65c2abf Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 15:02:10 +0800 Subject: [PATCH 033/675] Split form builder method concerns --- UPSTREAM.md | 8 +- packages/tui/src/form/builder/index.ts | 262 +++-------------------- packages/tui/src/form/builder/output.ts | 78 +++++++ packages/tui/src/form/builder/prompts.ts | 167 +++++++++++++++ packages/tui/src/form/builder/status.ts | 31 +++ 5 files changed, 309 insertions(+), 237 deletions(-) create mode 100644 packages/tui/src/form/builder/output.ts create mode 100644 packages/tui/src/form/builder/prompts.ts create mode 100644 packages/tui/src/form/builder/status.ts diff --git a/UPSTREAM.md b/UPSTREAM.md index a9d34f3d..a8fe3dfd 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -19,12 +19,12 @@ TypeScript and OpenTUI internals. | Textarea input | `packages/tui/src/prompts/textarea.ts`, `packages/tui/src/typed-value.ts` | | Password input | `packages/tui/src/prompts/password.ts`, `packages/tui/src/typed-value.ts` | | Number input | `packages/tui/src/prompts/number.ts`, `packages/tui/src/typed-value.ts` | -| Confirm, select, multiselect | `packages/tui/src/prompts/select.ts` | -| Suggest, autocomplete | `packages/tui/src/prompts/suggest.ts` | -| Search, multisearch | `packages/tui/src/prompts/search.ts` | +| Confirm, select, multiselect | `packages/tui/src/prompts/select/*` | +| Suggest, autocomplete | `packages/tui/src/prompts/suggest/*` | +| Search, multisearch | `packages/tui/src/prompts/search/*` | | Pause | `packages/tui/src/prompts/pause.ts` | | Choice normalization, matching, navigation, and list rendering concerns | `packages/tui/src/concerns/choices.ts` | -| Form builder | `packages/tui/src/form/*`, `packages/tui/src/form.ts` | +| Form builder | `packages/tui/src/form.ts`, `packages/tui/src/form/builder/*` | | Notes and alert-style output | `packages/tui/src/output/notes.ts` | | Native notifications | `packages/tui/src/output/notify.ts` | | Tables, data tables, and grids | `packages/tui/src/output/table.ts`, `packages/tui/src/output/data-table.ts` | diff --git a/packages/tui/src/form/builder/index.ts b/packages/tui/src/form/builder/index.ts index beb86fcb..63e90c75 100644 --- a/packages/tui/src/form/builder/index.ts +++ b/packages/tui/src/form/builder/index.ts @@ -1,17 +1,37 @@ -import { alert, datatable, error, info, intro, note, outro, table, warning } from '#tui/output'; -import { autocomplete, confirm, multiselect, multisearch, pause, search, select, suggest } from '#tui/prompts/choices'; -import { number, password, text, textarea } from '#tui/prompts/basic'; -import { progress, spin, stream, task } from '#tui/status'; -import { previousArray, previousNumber, previousString } from '#tui/form/builder/previous'; -import { sideEffectStep } from '#tui/form/builder/step'; +import { outputBuilderMethods } from '#tui/form/builder/output'; +import { promptBuilderMethods } from '#tui/form/builder/prompts'; +import { statusBuilderMethods } from '#tui/form/builder/status'; import type { FormResponses, FormStep } from '#tui/form/types'; -import type { ChoiceInput, DataTablePromptOptions, MaybePromise, MultiSearchPromptOptions, SearchPromptOptions, TableOptions, TextPromptOptions } from '#tui/types'; -import type { SuggestOptions } from '#tui/prompts/choices'; -import type { Logger, Progress } from '#tui/status'; +import type { MaybePromise } from '#tui/types'; export class FormBuilder { readonly #steps: FormStep[] = []; readonly #responses: FormResponses = [] as unknown as FormResponses; + readonly alert = outputBuilderMethods.alert; + readonly autocomplete = promptBuilderMethods.autocomplete; + readonly confirm = promptBuilderMethods.confirm; + readonly datatable = outputBuilderMethods.datatable; + readonly error = outputBuilderMethods.error; + readonly info = outputBuilderMethods.info; + readonly intro = outputBuilderMethods.intro; + readonly multisearch = promptBuilderMethods.multisearch; + readonly multiselect = promptBuilderMethods.multiselect; + readonly note = outputBuilderMethods.note; + readonly number = promptBuilderMethods.number; + readonly outro = outputBuilderMethods.outro; + readonly password = promptBuilderMethods.password; + readonly pause = promptBuilderMethods.pause; + readonly progress = statusBuilderMethods.progress; + readonly search = promptBuilderMethods.search; + readonly select = promptBuilderMethods.select; + readonly spin = statusBuilderMethods.spin; + readonly stream = statusBuilderMethods.stream; + readonly suggest = promptBuilderMethods.suggest; + readonly table = outputBuilderMethods.table; + readonly task = statusBuilderMethods.task; + readonly text = promptBuilderMethods.text; + readonly textarea = promptBuilderMethods.textarea; + readonly warning = outputBuilderMethods.warning; add(step: (responses: FormResponses, previous: unknown) => MaybePromise, name?: string, ignoreWhenReverting = false): this { this.#steps.push({ @@ -55,228 +75,4 @@ export class FormBuilder { return this.#responses; } - - text( - label: string, - placeholder = '', - defaultValue = '', - required: boolean | string = false, - validate: TextPromptOptions['validate'] = undefined, - hint = '', - name?: string, - transform?: TextPromptOptions['transform'], - ): this { - return this.add((_, previous) => text(label, placeholder, previousString(previous, defaultValue), required, validate, hint, transform), name); - } - - textarea( - label: string, - placeholder = '', - defaultValue = '', - required: boolean | string = false, - validate: TextPromptOptions['validate'] = undefined, - hint = '', - rows = 5, - name?: string, - transform?: TextPromptOptions['transform'], - ): this { - return this.add((_, previous) => textarea(label, placeholder, previousString(previous, defaultValue), required, validate, hint, rows, transform), name); - } - - password( - label: string, - placeholder = '', - required: boolean | string = false, - validate: TextPromptOptions['validate'] = undefined, - hint = '', - name?: string, - transform?: TextPromptOptions['transform'], - ): this { - return this.add(() => password(label, placeholder, required, validate, hint, transform), name); - } - - number( - label: string, - placeholder = '', - defaultValue = 0, - required: boolean | string = false, - validate: ((value: number) => MaybePromise) | undefined = undefined, - hint = '', - min?: number, - max?: number, - step?: number, - name?: string, - ): this { - return this.add((_, previous) => number(label, placeholder, previousNumber(previous, defaultValue), required, validate, hint, min, max, step), name); - } - - confirm( - label: string, - defaultValue = true, - yes = 'Yes', - no = 'No', - required: boolean | string = false, - validate: ((value: boolean) => MaybePromise) | undefined = undefined, - hint = '', - name?: string, - ): this { - return this.add(() => confirm(label, defaultValue, yes, no, required, validate, hint), name); - } - - select( - label: string, - options: Array>, - defaultValue?: T, - scroll = 5, - validate: ((value: T) => MaybePromise) | undefined = undefined, - hint = '', - required: boolean | string = true, - name?: string, - ): this { - return this.add((_, previous) => select({ message: label, options, default: previous === undefined ? defaultValue : (previous as T), scroll, validate, hint, required }), name); - } - - multiselect( - label: string, - options: Array>, - defaultValue: T[] = [], - scroll = 5, - required: boolean | string = false, - validate: ((value: T[]) => MaybePromise) | undefined = undefined, - hint = 'Use the space bar to select options.', - name?: string, - ): this { - return this.add((_, previous) => multiselect({ message: label, options, default: previousArray(previous, defaultValue), scroll, required, validate, hint }), name); - } - - suggest( - label: string, - options: SuggestOptions['options'], - defaultValue = '', - scroll = 5, - required: boolean | string = false, - validate: TextPromptOptions['validate'] = undefined, - hint = '', - name?: string, - transform?: TextPromptOptions['transform'], - ): this { - return this.add((_, previous) => suggest({ message: label, label, options, default: previousString(previous, defaultValue), scroll, required, validate, hint, transform }), name); - } - - autocomplete( - label: string, - options: SuggestOptions['options'], - defaultValue = '', - required: boolean | string = false, - validate: TextPromptOptions['validate'] = undefined, - hint = '', - name?: string, - transform?: TextPromptOptions['transform'], - ): this { - return this.add((_, previous) => autocomplete({ message: label, label, options, default: previousString(previous, defaultValue), required, validate, hint, transform }), name); - } - - search(options: SearchPromptOptions, name?: string): this { - return this.add(() => search(options), name); - } - - multisearch(options: MultiSearchPromptOptions, name?: string): this { - return this.add(() => multisearch(options), name); - } - - spin(callback: () => MaybePromise, message = '', name?: string): this { - return this.add(() => spin(callback, { message }), name, true); - } - - task(label: string, callback: (logger: Logger) => MaybePromise, limit = 10, keepSummary = false, subLabel = '', name?: string): this { - return this.add(() => task(label, callback, limit, keepSummary, subLabel), name); - } - - pause(message = 'Press enter to continue', name?: string): this { - return this.add( - sideEffectStep(() => pause(message)), - name, - true, - ); - } - - stream(source: AsyncIterable | Iterable, name?: string): this { - return this.add( - sideEffectStep(() => stream(source)), - name, - true, - ); - } - - note(message: string, type: string | null = null, name?: string): this { - return this.add( - sideEffectStep(() => note(message, type)), - name, - true, - ); - } - - error(message: string, name?: string): this { - return this.add( - sideEffectStep(() => error(message)), - name, - true, - ); - } - - warning(message: string, name?: string): this { - return this.add( - sideEffectStep(() => warning(message)), - name, - true, - ); - } - - alert(message: string, name?: string): this { - return this.add( - sideEffectStep(() => alert(message)), - name, - true, - ); - } - - info(message: string, name?: string): this { - return this.add( - sideEffectStep(() => info(message)), - name, - true, - ); - } - - intro(message: string, name?: string): this { - return this.add( - sideEffectStep(() => intro(message)), - name, - true, - ); - } - - outro(message: string, name?: string): this { - return this.add( - sideEffectStep(() => outro(message)), - name, - true, - ); - } - - table(headersOrOptions: TableOptions | string[] = [], rows: TableOptions['rows'] | null = null, name?: string): this { - return this.add( - sideEffectStep(() => table(headersOrOptions, rows)), - name, - true, - ); - } - - datatable(options: DataTablePromptOptions, name?: string): this { - return this.add(() => datatable(options), name); - } - - progress(label: string, steps: Iterable | number, callback?: (step: T | number, bar: Progress) => MaybePromise, hint = '', name?: string): this { - return this.add(() => progress(label, steps, callback, hint), name, true); - } } diff --git a/packages/tui/src/form/builder/output.ts b/packages/tui/src/form/builder/output.ts new file mode 100644 index 00000000..c3da1cfc --- /dev/null +++ b/packages/tui/src/form/builder/output.ts @@ -0,0 +1,78 @@ +import { alert, datatable, error, info, intro, note, outro, table, warning } from '#tui/output'; +import { sideEffectStep } from '#tui/form/builder/step'; +import type { FormBuilder } from '#tui/form/builder/index'; +import type { DataTablePromptOptions, TableOptions } from '#tui/types'; + +export type OutputBuilderMethods = { + alert(this: FormBuilder, message: string, name?: string): FormBuilder; + datatable(this: FormBuilder, options: DataTablePromptOptions, name?: string): FormBuilder; + error(this: FormBuilder, message: string, name?: string): FormBuilder; + info(this: FormBuilder, message: string, name?: string): FormBuilder; + intro(this: FormBuilder, message: string, name?: string): FormBuilder; + note(this: FormBuilder, message: string, type?: string | null, name?: string): FormBuilder; + outro(this: FormBuilder, message: string, name?: string): FormBuilder; + table(this: FormBuilder, headersOrOptions?: TableOptions | string[], rows?: TableOptions['rows'] | null, name?: string): FormBuilder; + warning(this: FormBuilder, message: string, name?: string): FormBuilder; +}; + +export const outputBuilderMethods: OutputBuilderMethods & ThisType = { + alert(message, name) { + return this.add( + sideEffectStep(() => alert(message)), + name, + true, + ); + }, + datatable(options: DataTablePromptOptions, name?: string) { + return this.add(() => datatable(options), name); + }, + error(message, name) { + return this.add( + sideEffectStep(() => error(message)), + name, + true, + ); + }, + info(message, name) { + return this.add( + sideEffectStep(() => info(message)), + name, + true, + ); + }, + intro(message, name) { + return this.add( + sideEffectStep(() => intro(message)), + name, + true, + ); + }, + note(message, type = null, name) { + return this.add( + sideEffectStep(() => note(message, type)), + name, + true, + ); + }, + outro(message, name) { + return this.add( + sideEffectStep(() => outro(message)), + name, + true, + ); + }, + table(headersOrOptions = [], rows = null, name) { + return this.add( + sideEffectStep(() => table(headersOrOptions, rows)), + name, + true, + ); + }, + warning(message, name) { + return this.add( + sideEffectStep(() => warning(message)), + name, + true, + ); + }, +}; diff --git a/packages/tui/src/form/builder/prompts.ts b/packages/tui/src/form/builder/prompts.ts new file mode 100644 index 00000000..09012fa1 --- /dev/null +++ b/packages/tui/src/form/builder/prompts.ts @@ -0,0 +1,167 @@ +import { autocomplete, confirm, multiselect, multisearch, pause, search, select, suggest } from '#tui/prompts/choices'; +import { number, password, text, textarea } from '#tui/prompts/basic'; +import { previousArray, previousNumber, previousString } from '#tui/form/builder/previous'; +import { sideEffectStep } from '#tui/form/builder/step'; +import type { FormBuilder } from '#tui/form/builder/index'; +import type { ChoiceInput, MaybePromise, MultiSearchPromptOptions, SearchPromptOptions, TextPromptOptions } from '#tui/types'; +import type { SuggestOptions } from '#tui/prompts/choices'; + +export type PromptBuilderMethods = { + autocomplete( + this: FormBuilder, + label: string, + options: SuggestOptions['options'], + defaultValue?: string, + required?: boolean | string, + validate?: TextPromptOptions['validate'], + hint?: string, + name?: string, + transform?: TextPromptOptions['transform'], + ): FormBuilder; + confirm( + this: FormBuilder, + label: string, + defaultValue?: boolean, + yes?: string, + no?: string, + required?: boolean | string, + validate?: (value: boolean) => MaybePromise, + hint?: string, + name?: string, + ): FormBuilder; + multisearch(this: FormBuilder, options: MultiSearchPromptOptions, name?: string): FormBuilder; + multiselect( + this: FormBuilder, + label: string, + options: Array>, + defaultValue?: T[], + scroll?: number, + required?: boolean | string, + validate?: (value: T[]) => MaybePromise, + hint?: string, + name?: string, + ): FormBuilder; + number( + this: FormBuilder, + label: string, + placeholder?: string, + defaultValue?: number, + required?: boolean | string, + validate?: (value: number) => MaybePromise, + hint?: string, + min?: number, + max?: number, + step?: number, + name?: string, + ): FormBuilder; + password( + this: FormBuilder, + label: string, + placeholder?: string, + required?: boolean | string, + validate?: TextPromptOptions['validate'], + hint?: string, + name?: string, + transform?: TextPromptOptions['transform'], + ): FormBuilder; + pause(this: FormBuilder, message?: string, name?: string): FormBuilder; + search(this: FormBuilder, options: SearchPromptOptions, name?: string): FormBuilder; + select( + this: FormBuilder, + label: string, + options: Array>, + defaultValue?: T, + scroll?: number, + validate?: (value: T) => MaybePromise, + hint?: string, + required?: boolean | string, + name?: string, + ): FormBuilder; + suggest( + this: FormBuilder, + label: string, + options: SuggestOptions['options'], + defaultValue?: string, + scroll?: number, + required?: boolean | string, + validate?: TextPromptOptions['validate'], + hint?: string, + name?: string, + transform?: TextPromptOptions['transform'], + ): 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, + label: string, + placeholder?: string, + defaultValue?: string, + required?: boolean | string, + validate?: TextPromptOptions['validate'], + hint?: string, + rows?: number, + name?: string, + transform?: TextPromptOptions['transform'], + ): FormBuilder; +}; + +export const promptBuilderMethods: PromptBuilderMethods & ThisType = { + autocomplete(label, options, defaultValue = '', required = false, validate = undefined, hint = '', name, transform = undefined) { + return this.add((_, previous) => autocomplete({ message: label, label, options, default: previousString(previous, defaultValue), required, validate, hint, transform }), name); + }, + confirm(label, defaultValue = true, yes = 'Yes', no = 'No', required = false, validate = undefined, hint = '', name) { + return this.add(() => confirm(label, defaultValue, yes, no, required, validate, hint), name); + }, + multisearch(options: MultiSearchPromptOptions, name?: string) { + return this.add(() => multisearch(options), name); + }, + multiselect( + label: string, + options: Array>, + defaultValue: T[] = [], + scroll = 5, + required: boolean | string = false, + validate = undefined, + hint = 'Use the space bar to select options.', + name?: string, + ) { + return this.add((_, previous) => multiselect({ message: label, options, default: previousArray(previous, defaultValue), scroll, required, validate, hint }), name); + }, + number(label, placeholder = '', defaultValue = 0, required = false, validate = undefined, hint = '', min, max, step, name) { + return this.add((_, previous) => number(label, placeholder, previousNumber(previous, defaultValue), required, validate, hint, min, max, step), name); + }, + password(label, placeholder = '', required = false, validate = undefined, hint = '', name, transform = undefined) { + return this.add(() => password(label, placeholder, required, validate, hint, transform), name); + }, + pause(message = 'Press enter to continue', name?: string) { + return this.add( + sideEffectStep(() => pause(message)), + name, + true, + ); + }, + search(options: SearchPromptOptions, name?: string) { + return this.add(() => search(options), name); + }, + select(label: string, options: Array>, defaultValue?: T, scroll = 5, validate = undefined, hint = '', required: boolean | string = true, name?: string) { + return this.add((_, previous) => select({ message: label, options, default: previous === undefined ? defaultValue : (previous as T), scroll, validate, hint, required }), name); + }, + suggest(label, options, defaultValue = '', scroll = 5, required = false, validate = undefined, hint = '', name, transform = undefined) { + return this.add((_, previous) => suggest({ message: label, label, options, default: previousString(previous, defaultValue), scroll, required, validate, hint, transform }), name); + }, + text(label, placeholder = '', defaultValue = '', required = false, validate = undefined, hint = '', name, transform = undefined) { + return this.add((_, previous) => text(label, placeholder, previousString(previous, defaultValue), required, validate, hint, transform), name); + }, + textarea(label, placeholder = '', defaultValue = '', required = false, validate = undefined, hint = '', rows = 5, name, transform = undefined) { + return this.add((_, previous) => textarea(label, placeholder, previousString(previous, defaultValue), required, validate, hint, rows, transform), name); + }, +}; diff --git a/packages/tui/src/form/builder/status.ts b/packages/tui/src/form/builder/status.ts new file mode 100644 index 00000000..a976c1fb --- /dev/null +++ b/packages/tui/src/form/builder/status.ts @@ -0,0 +1,31 @@ +import { progress, spin, stream, task } from '#tui/status'; +import { sideEffectStep } from '#tui/form/builder/step'; +import type { FormBuilder } from '#tui/form/builder/index'; +import type { MaybePromise } from '#tui/types'; +import type { Logger, Progress } from '#tui/status'; + +export type StatusBuilderMethods = { + 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; + 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(label: string, steps: Iterable | number, callback?: (step: T | number, bar: Progress) => MaybePromise, hint = '', name?: string) { + return this.add(() => progress(label, steps, callback, hint), name, true); + }, + spin(callback: () => MaybePromise, message = '', name?: string) { + return this.add(() => spin(callback, { message }), name, true); + }, + stream(source: AsyncIterable | Iterable, name?: string) { + return this.add( + sideEffectStep(() => stream(source)), + name, + true, + ); + }, + task(label: string, callback: (logger: Logger) => MaybePromise, limit = 10, keepSummary = false, subLabel = '', name?: string) { + return this.add(() => task(label, callback, limit, keepSummary, subLabel), name); + }, +}; From 95f00fd3de65f2d3931468c6aa63854f2dbbddca Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 15:06:31 +0800 Subject: [PATCH 034/675] Add number prompt arrow stepping --- packages/tui/src/prompts/number.ts | 14 ++-- packages/tui/src/prompts/number/input.ts | 95 ++++++++++++++++++++++++ packages/tui/tests/number.test.ts | 43 +++++++++++ 3 files changed, 146 insertions(+), 6 deletions(-) create mode 100644 packages/tui/src/prompts/number/input.ts diff --git a/packages/tui/src/prompts/number.ts b/packages/tui/src/prompts/number.ts index d4c6519c..1b7557b4 100644 --- a/packages/tui/src/prompts/number.ts +++ b/packages/tui/src/prompts/number.ts @@ -1,5 +1,5 @@ import { promptUntilValid, PromptValidationError } from '#tui/prompt'; -import { readTypedValue } from '#tui/typed-value'; +import { readNumberValue } from '#tui/prompts/number/input'; import type { NumberPromptOptions } from '#tui/types'; const integerPattern = /^[+-]?\d+$/u; @@ -22,7 +22,7 @@ export function number( export async function number( message: string | NumberPromptOptions, _placeholder = '', - defaultValue = 0, + defaultValue: number | undefined = undefined, required: boolean | string = false, validate: NumberPromptOptions['validate'] = undefined, hint = '', @@ -30,13 +30,15 @@ export async function number( max: number | undefined = undefined, step: number | undefined = undefined, ): Promise { - const options: NumberPromptOptions = - typeof message === 'string' ? { message, label: message, default: defaultValue, required, validate, hint, min, max, step } : { ...message, default: message.default ?? defaultValue }; + const options: NumberPromptOptions = typeof message === 'string' ? { message, label: message, default: defaultValue, required, validate, hint, min, max, step } : { ...message }; return promptUntilValid(options, async () => { - const answer = await readTypedValue(options.message, { - default: options.default === undefined ? undefined : String(options.default), + const answer = await readNumberValue(options.message, { + default: options.default, hint: options.hint, + max: options.max, + min: options.min, + step: options.step, }); if (answer === '' && options.default !== undefined) { diff --git a/packages/tui/src/prompts/number/input.ts b/packages/tui/src/prompts/number/input.ts new file mode 100644 index 00000000..5bdb09ff --- /dev/null +++ b/packages/tui/src/prompts/number/input.ts @@ -0,0 +1,95 @@ +import { promptEnvironment } from '#tui/environment'; +import { Key } from '#tui/key'; +import { PromptValidationError } from '#tui/prompt'; +import { renderQuestion } from '#tui/theme'; +import { applyTypedKey } from '#tui/typed-value'; + +type NumberInputOptions = { + default?: number; + hint?: string; + max?: number; + min?: number; + step?: number; +}; + +const numeric = (value: string): boolean => value.trim() !== '' && Number.isFinite(Number(value)); + +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); +}; + +const steppedValue = (value: string, direction: 1 | -1, options: NumberInputOptions): string => { + const step = Math.max(1, options.step ?? 1); + + if (value === '') { + return String(direction === 1 ? (options.min ?? 1) : (options.max ?? 0)); + } + + if (!numeric(value)) { + return value; + } + + return String(clamp(Math.trunc(Number(value)) + step * direction, options.min, options.max)); +}; + +export const readNumberValue = async (message: string, options: NumberInputOptions = {}): Promise => { + const environment = promptEnvironment(); + + if (!environment.input.readKey) { + 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 answer === '' && options.default !== undefined ? String(options.default) : answer; + } + + environment.output.write(renderQuestion(message, options.hint)); + + let state = { + cursor: options.default === undefined ? 0 : String(options.default).length, + value: options.default === undefined ? '' : String(options.default), + }; + + while (true) { + const key = await environment.input.readKey(); + + if (key === null) { + return state.value; + } + + if (key === Key.up || key === Key.upArrow) { + state.value = steppedValue(state.value, 1, options); + state.cursor = state.value.length; + continue; + } + + if (key === Key.down || key === Key.downArrow) { + state.value = steppedValue(state.value, -1, options); + state.cursor = state.value.length; + continue; + } + + const next = applyTypedKey(state, key); + + if (next.cancelled) { + environment.error.write('Cancelled.\n'); + + return state.value; + } + + state = { + cursor: next.cursor, + value: next.value, + }; + + if (next.submitted) { + environment.output.write('\n'); + + return state.value; + } + } +}; diff --git a/packages/tui/tests/number.test.ts b/packages/tui/tests/number.test.ts index 1b27f05a..2f6fb568 100644 --- a/packages/tui/tests/number.test.ts +++ b/packages/tui/tests/number.test.ts @@ -35,4 +35,47 @@ describe('number prompt', () => { expect(result).toBe(2); expect(output.text()).toContain('Please enter a valid number.'); }); + + it('increments and decrements with arrow keys', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.up, Key.up, Key.down, Key.enter]), + output, + error: output, + interactive: true, + }, + () => number({ message: 'Count', step: 2 }), + ); + + expect(result).toBe(1); + }); + + it('clamps arrow key changes to min and max', async () => { + const output = createMemoryOutput(); + + const increased = await withPromptEnvironment( + { + input: createScriptedInput([Key.up, Key.up, Key.enter]), + output, + error: output, + interactive: true, + }, + () => number({ message: 'Count', max: 3, min: 2, step: 2 }), + ); + + const decreased = await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.down, Key.enter]), + output, + error: output, + interactive: true, + }, + () => number({ message: 'Count', max: 3, min: 2, step: 2 }), + ); + + expect(increased).toBe(3); + expect(decreased).toBe(2); + }); }); From a1ffb5b843bc47404ea912d706a633c1727a7d8c Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 15:09:10 +0800 Subject: [PATCH 035/675] Add key-driven confirm prompt --- packages/tui/src/prompts/select/confirm.ts | 15 +--- .../tui/src/prompts/select/read-confirm.ts | 70 +++++++++++++++++++ packages/tui/tests/choices.test.ts | 47 ++++++++++++- 3 files changed, 119 insertions(+), 13 deletions(-) create mode 100644 packages/tui/src/prompts/select/read-confirm.ts diff --git a/packages/tui/src/prompts/select/confirm.ts b/packages/tui/src/prompts/select/confirm.ts index 2334818b..4d44e720 100644 --- a/packages/tui/src/prompts/select/confirm.ts +++ b/packages/tui/src/prompts/select/confirm.ts @@ -1,4 +1,5 @@ -import { ask, promptUntilValid } from '#tui/prompt'; +import { promptUntilValid } from '#tui/prompt'; +import { readConfirm } from '#tui/prompts/select/read-confirm'; import type { ConfirmPromptOptions } from '#tui/types'; export function confirm(options: ConfirmPromptOptions): Promise; @@ -17,15 +18,5 @@ export async function confirm( const options: ConfirmPromptOptions = typeof message === 'string' ? { message, label: message, default: defaultValue, yes, no, required, validate, hint } : { ...message, default: message.default ?? true }; - return promptUntilValid(options, async () => { - const suffix = options.default === false ? ' [y/N]' : ' [Y/n]'; - - const answer = (await ask(`${options.message}${suffix}`, options.hint)).trim().toLowerCase(); - - if (answer === '' && options.default !== undefined) { - return options.default; - } - - return ['y', 'yes', options.yes?.toLowerCase()].includes(answer); - }); + return promptUntilValid(options, async () => readConfirm(options)); } diff --git a/packages/tui/src/prompts/select/read-confirm.ts b/packages/tui/src/prompts/select/read-confirm.ts new file mode 100644 index 00000000..1319b46d --- /dev/null +++ b/packages/tui/src/prompts/select/read-confirm.ts @@ -0,0 +1,70 @@ +import { promptEnvironment } from '#tui/environment'; +import { Key } from '#tui/key'; +import { ask } from '#tui/prompt'; +import { renderQuestion } from '#tui/theme'; +import type { ConfirmPromptOptions } from '#tui/types'; + +const toggleKeys = 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']); + +const renderConfirm = (options: ConfirmPromptOptions, confirmed: boolean): void => { + const yes = confirmed ? `[${options.yes ?? 'Yes'}]` : ` ${options.yes ?? 'Yes'} `; + const no = confirmed ? ` ${options.no ?? 'No'} ` : `[${options.no ?? 'No'}]`; + + promptEnvironment().output.write(`${renderQuestion(options.message, options.hint)}${yes} / ${no}\n`); +}; + +export const readConfirm = async (options: ConfirmPromptOptions): Promise => { + const environment = promptEnvironment(); + + if (!environment.input.readKey) { + const suffix = options.default === false ? ' [y/N]' : ' [Y/n]'; + + const answer = (await ask(`${options.message}${suffix}`, options.hint)).trim().toLowerCase(); + + if (answer === '' && options.default !== undefined) { + return options.default; + } + + return ['y', 'yes', options.yes?.toLowerCase()].includes(answer); + } + + let confirmed = options.default ?? true; + + renderConfirm(options, confirmed); + + while (true) { + const key = await environment.input.readKey(); + + if (key === null) { + return confirmed; + } + + if (key === 'y') { + confirmed = true; + renderConfirm(options, confirmed); + continue; + } + + if (key === 'n') { + confirmed = false; + renderConfirm(options, confirmed); + continue; + } + + if (toggleKeys.has(key)) { + confirmed = !confirmed; + renderConfirm(options, confirmed); + continue; + } + + if (key === Key.enter) { + return confirmed; + } + + if (key === Key.ctrlC) { + environment.error.write('Cancelled.\n'); + + return confirmed; + } + } +}; diff --git a/packages/tui/tests/choices.test.ts b/packages/tui/tests/choices.test.ts index e3eaca3b..666e258f 100644 --- a/packages/tui/tests/choices.test.ts +++ b/packages/tui/tests/choices.test.ts @@ -1,7 +1,52 @@ import { describe, expect, it } from 'vitest'; -import { createMemoryOutput, createScriptedInput, Key, multiselect, select, withPromptEnvironment } from '#tui/index'; +import { confirm, createMemoryOutput, createScriptedInput, Key, multiselect, select, withPromptEnvironment } from '#tui/index'; describe('choice prompts', () => { + it('confirms with direct y and n keys', async () => { + const output = createMemoryOutput(); + + const accepted = await withPromptEnvironment( + { + input: createScriptedInput(['y', Key.enter]), + output, + error: output, + interactive: true, + }, + () => confirm('Continue?', false), + ); + + const declined = await withPromptEnvironment( + { + input: createScriptedInput(['n', Key.enter]), + output, + error: output, + interactive: true, + }, + () => confirm('Continue?'), + ); + + expect(accepted).toBe(true); + expect(declined).toBe(false); + }); + + it('toggles confirm values with navigation keys', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.left, 'l', Key.enter]), + output, + error: output, + interactive: true, + }, + () => confirm('Continue?'), + ); + + expect(result).toBe(true); + expect(output.text()).toContain('[Yes]'); + expect(output.text()).toContain('[No]'); + }); + it('selects with arrow keys and enter', async () => { const output = createMemoryOutput(); From 564a7820b1d056fd37e58fc5798ffc61c16a28ea Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 15:10:54 +0800 Subject: [PATCH 036/675] Add textarea line cursor movement --- packages/tui/src/typed-value.ts | 57 ++++++++++++++++++++++++++ packages/tui/tests/typed-value.test.ts | 29 +++++++++++++ 2 files changed, 86 insertions(+) diff --git a/packages/tui/src/typed-value.ts b/packages/tui/src/typed-value.ts index c0b72e00..31f27c2e 100644 --- a/packages/tui/src/typed-value.ts +++ b/packages/tui/src/typed-value.ts @@ -11,6 +11,11 @@ export type TypedValueState = { const characters = (value: string): string[] => [...value]; const fromCharacters = (value: string[]): string => value.join(''); +type LineRange = { + end: number; + start: number; +}; + const isPrintable = (key: string): boolean => { return [...key].every((character) => { const code = character.codePointAt(0) ?? 0; @@ -19,6 +24,50 @@ const isPrintable = (key: string): boolean => { }); }; +const lineRanges = (value: string[]): LineRange[] => { + const ranges: LineRange[] = []; + + let start = 0; + + for (const [index, character] of value.entries()) { + if (character === '\n') { + ranges.push({ end: index, start }); + start = index + 1; + } + } + + ranges.push({ end: value.length, start }); + + return ranges; +}; + +const currentLine = (ranges: LineRange[], cursor: number): number => { + const index = ranges.findIndex((range) => cursor <= range.end); + + return index === -1 ? ranges.length - 1 : index; +}; + +const moveLine = (value: string[], cursor: number, direction: 1 | -1): number => { + const ranges = lineRanges(value); + 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 applyTypedKey = (state: TypedValueState, key: string, allowNewLine = false): TypedValueState & { submitted: boolean; cancelled: boolean } => { const value = characters(state.value); @@ -51,6 +100,14 @@ export const applyTypedKey = (state: TypedValueState, key: string, allowNewLine return { cursor: Math.min(value.length, cursor + 1), value: fromCharacters(value), submitted: false, cancelled: false }; } + if (allowNewLine && (key === Key.up || key === Key.upArrow || key === Key.ctrlP)) { + return { cursor: moveLine(value, cursor, -1), value: fromCharacters(value), submitted: false, cancelled: false }; + } + + if (allowNewLine && (key === Key.down || key === Key.downArrow || key === Key.ctrlN)) { + return { cursor: moveLine(value, cursor, 1), value: fromCharacters(value), submitted: false, cancelled: false }; + } + if (oneOf([Key.home, Key.ctrlA], key)) { return { cursor: 0, value: fromCharacters(value), submitted: false, cancelled: false }; } diff --git a/packages/tui/tests/typed-value.test.ts b/packages/tui/tests/typed-value.test.ts index 9e2bc26d..c321e472 100644 --- a/packages/tui/tests/typed-value.test.ts +++ b/packages/tui/tests/typed-value.test.ts @@ -27,6 +27,18 @@ const applyMultiline = (keys: string[]): { submitted: boolean; value: string } = return { submitted, value: state.value }; }; +const applyMultilineState = (value: string, cursor: number, keys: string[]): { cursor: number; value: string } => { + let state = { cursor, value }; + + for (const key of keys) { + const next = applyTypedKey(state, key, true); + + state = { cursor: next.cursor, value: next.value }; + } + + return state; +}; + describe('typed value editing', () => { it('inserts buffered characters', () => { expect(apply(['Je', 'ss'])).toBe('Jess'); @@ -51,4 +63,21 @@ describe('typed value editing', () => { value: 'A\nB', }); }); + + it('moves the cursor between textarea lines', () => { + expect(applyMultilineState('abc\nde\nfghi', 6, [Key.up])).toEqual({ + cursor: 2, + value: 'abc\nde\nfghi', + }); + + expect(applyMultilineState('abc\nde\nfghi', 2, [Key.down])).toEqual({ + cursor: 6, + value: 'abc\nde\nfghi', + }); + + expect(applyMultilineState('abc\nde\nfghi', 6, [Key.down])).toEqual({ + cursor: 9, + value: 'abc\nde\nfghi', + }); + }); }); From 582ace90029afc3afd44d2c7ce7c3a90e61b06fa Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 15:12:54 +0800 Subject: [PATCH 037/675] Start select prompts on default value --- packages/tui/src/prompts/select/index.ts | 2 +- .../tui/src/prompts/select/read-selected.ts | 14 ++++++++++++-- packages/tui/tests/choices.test.ts | 17 +++++++++++++++++ 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/packages/tui/src/prompts/select/index.ts b/packages/tui/src/prompts/select/index.ts index 687bf355..47684b1f 100644 --- a/packages/tui/src/prompts/select/index.ts +++ b/packages/tui/src/prompts/select/index.ts @@ -10,7 +10,7 @@ export const select = async (options: SelectPromptOptions): Promise => const choices = normalizeChoices(options.options); return promptUntilValid(options, async () => { - return readSelectedChoice(options.message, choices, options.hint, options.scroll, options.info).catch((error: unknown) => { + return readSelectedChoice(options.message, choices, options.default, options.hint, options.scroll, options.info).catch((error: unknown) => { if (options.default !== undefined && error instanceof PromptValidationError) { return options.default; } diff --git a/packages/tui/src/prompts/select/read-selected.ts b/packages/tui/src/prompts/select/read-selected.ts index 10b0048e..d20eaf58 100644 --- a/packages/tui/src/prompts/select/read-selected.ts +++ b/packages/tui/src/prompts/select/read-selected.ts @@ -7,7 +7,17 @@ import { lastEnabledChoiceIndex, nextChoiceKeys, parseChoiceIndex, previousChoic import { renderSelectedChoice } from '#tui/prompts/select/render'; import type { Choice, SelectPromptOptions } from '#tui/types'; -export const readSelectedChoice = async (message: string, choices: Array>, hint?: string, scroll?: number, info?: SelectPromptOptions['info']): Promise => { +const defaultChoiceIndex = (choices: Array>, defaultValue: T | undefined): number => { + if (defaultValue === undefined) { + return firstEnabledIndex(choices); + } + + const index = choices.findIndex((choice) => !choice.disabled && Object.is(choice.value, defaultValue)); + + return index === -1 ? firstEnabledIndex(choices) : index; +}; + +export const readSelectedChoice = async (message: string, choices: Array>, defaultValue?: T, hint?: string, scroll?: number, info?: SelectPromptOptions['info']): Promise => { const environment = promptEnvironment(); if (!environment.input.readKey) { @@ -24,7 +34,7 @@ export const readSelectedChoice = async (message: string, choices: Array { expect(output.text()).toContain('Pick one'); }); + it('starts select prompts on the default value', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.enter]), + output, + error: output, + interactive: true, + }, + () => select({ message: 'Pick one', options: ['first', 'second', 'third'], default: 'third' }), + ); + + expect(result).toBe('third'); + expect(output.text()).toContain('› third'); + }); + it('renders a scrolling select window around the highlighted choice', async () => { const output = createMemoryOutput(); From 56f55ad053f699db66b2a03e69c5582c216402c6 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 15:15:01 +0800 Subject: [PATCH 038/675] Keep selected multisearch values visible --- packages/tui/src/prompts/search/read-multi.ts | 32 ++++++++++++++----- packages/tui/tests/search.test.ts | 30 +++++++++++++++++ 2 files changed, 54 insertions(+), 8 deletions(-) diff --git a/packages/tui/src/prompts/search/read-multi.ts b/packages/tui/src/prompts/search/read-multi.ts index 7f77d89d..b5bec938 100644 --- a/packages/tui/src/prompts/search/read-multi.ts +++ b/packages/tui/src/prompts/search/read-multi.ts @@ -40,12 +40,24 @@ export const readMultiSearchChoices = async (options: MultiSearchPromptOption let highlighted: number | null = null; const selected = new Map(); - const selectableChoices = (): Array> => choices.filter((choice) => !choice.disabled); + + const displayedChoices = (): Array> => { + if (state.value.trim() !== '') { + return choices; + } + + const selectedChoices = [...selected.entries()].filter(([value]) => !choices.some((choice) => Object.is(choice.value, value))).map(([value, label]) => ({ label, value })); + + return [...selectedChoices, ...choices]; + }; + + const selectableChoices = (): Array> => displayedChoices().filter((choice) => !choice.disabled); const render = (): void => { - const marked = new Set(choices.flatMap((choice, index) => (selected.has(choice.value) ? [index] : []))); + const currentChoices = displayedChoices(); + const marked = new Set(currentChoices.flatMap((choice, index) => (selected.has(choice.value) ? [index] : []))); - renderSearchChoices(options.message, state.value, choices, highlighted, marked, [...selected.values()], options.scroll, options.info); + renderSearchChoices(options.message, state.value, currentChoices, highlighted, marked, [...selected.values()], options.scroll, options.info); }; render(); @@ -60,7 +72,9 @@ export const readMultiSearchChoices = async (options: MultiSearchPromptOption if (key === Key.down || key === Key.downArrow || key === Key.ctrlN || key === Key.tab) { choices = await resolveSearchChoices(options.options, state.value); - highlighted = choices.length === 0 ? null : highlighted === null ? firstEnabledIndex(choices) : nextEnabledIndex(choices, highlighted, 1); + const currentChoices = displayedChoices(); + + highlighted = currentChoices.length === 0 ? null : highlighted === null ? firstEnabledIndex(currentChoices) : nextEnabledIndex(currentChoices, highlighted, 1); render(); continue; } @@ -68,19 +82,21 @@ export const readMultiSearchChoices = async (options: MultiSearchPromptOption if (key === Key.up || key === Key.upArrow || key === Key.ctrlP || key === Key.shiftTab) { choices = await resolveSearchChoices(options.options, state.value); - highlighted = choices.length === 0 ? null : highlighted === null ? lastEnabledIndex(choices) : nextEnabledIndex(choices, highlighted, -1); + const currentChoices = displayedChoices(); + + highlighted = currentChoices.length === 0 ? null : highlighted === null ? lastEnabledIndex(currentChoices) : nextEnabledIndex(currentChoices, highlighted, -1); render(); continue; } if (oneOf([Key.home], key) && highlighted !== null) { - highlighted = firstEnabledIndex(choices); + highlighted = firstEnabledIndex(displayedChoices()); render(); continue; } if (oneOf([Key.end], key) && highlighted !== null) { - highlighted = lastEnabledIndex(choices); + highlighted = lastEnabledIndex(displayedChoices()); render(); continue; } @@ -109,7 +125,7 @@ export const readMultiSearchChoices = async (options: MultiSearchPromptOption } if (key === Key.space && highlighted !== null) { - const choice = choices[highlighted]; + const choice = displayedChoices()[highlighted]; if (choice && !choice.disabled) { if (selected.has(choice.value as T)) { diff --git a/packages/tui/tests/search.test.ts b/packages/tui/tests/search.test.ts index 4723e0eb..7abdc707 100644 --- a/packages/tui/tests/search.test.ts +++ b/packages/tui/tests/search.test.ts @@ -195,6 +195,36 @@ describe('multisearch prompt', () => { expect(output.text()).toContain('Selected: Red, Green, Blue'); }); + it('keeps selected multisearch values visible after clearing the query', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['V', Key.down, Key.space, Key.backspace, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + multisearch({ + message: 'Favorite colors?', + options: (value) => { + if (value === '') { + return { green: 'Green' }; + } + + return { violet: 'Violet' }; + }, + }), + ); + + const latestFrame = output.text().split('Favorite colors?\n').at(-1) ?? ''; + + expect(result).toEqual(['violet']); + expect(latestFrame).toContain('Violet'); + expect(latestFrame).toContain('Green'); + }); + it('supports home and end multisearch navigation keys', async () => { const output = createMemoryOutput(); From 25f6942b645684582fcba0fd1222b9a6ae25a298 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 15:17:27 +0800 Subject: [PATCH 039/675] Split suggest and autocomplete key behavior --- packages/tui/src/prompts/suggest/index.ts | 12 ++- .../src/prompts/suggest/read-autocomplete.ts | 85 +++++++++++++++++++ packages/tui/src/prompts/suggest/read.ts | 10 +-- packages/tui/tests/suggest.test.ts | 42 ++++++++- 4 files changed, 136 insertions(+), 13 deletions(-) create mode 100644 packages/tui/src/prompts/suggest/read-autocomplete.ts diff --git a/packages/tui/src/prompts/suggest/index.ts b/packages/tui/src/prompts/suggest/index.ts index 3d6966c6..b5798677 100644 --- a/packages/tui/src/prompts/suggest/index.ts +++ b/packages/tui/src/prompts/suggest/index.ts @@ -1,4 +1,5 @@ import { promptUntilValid } from '#tui/prompt'; +import { readAutocompleteValue } from '#tui/prompts/suggest/read-autocomplete'; import { readSuggestionValue } from '#tui/prompts/suggest/read'; import { suggestOptions } from '#tui/prompts/suggest/options'; import type { SuggestOptions } from '#tui/prompts/suggest/options'; @@ -65,5 +66,14 @@ export async function autocomplete( hint = '', transform: TextPromptOptions['transform'] = undefined, ): Promise { - return suggest(typeof message === 'string' ? { message, label: message, options: source, placeholder, default: defaultValue, required, validate, hint, transform } : message); + const options = + typeof message === 'string' ? suggestOptions({ message, label: message, options: source, placeholder, default: defaultValue, required, validate, hint, transform }) : suggestOptions(message); + + return promptUntilValid(options, async () => { + const answer = await readAutocompleteValue(options); + + const value = answer === '' && options.default !== undefined ? options.default : answer; + + return options.transform ? options.transform(value) : value; + }); } diff --git a/packages/tui/src/prompts/suggest/read-autocomplete.ts b/packages/tui/src/prompts/suggest/read-autocomplete.ts new file mode 100644 index 00000000..f4b71be2 --- /dev/null +++ b/packages/tui/src/prompts/suggest/read-autocomplete.ts @@ -0,0 +1,85 @@ +import { promptEnvironment } from '#tui/environment'; +import { Key } from '#tui/key'; +import { ask } from '#tui/prompt'; +import { applyTypedKey } from '#tui/typed-value'; +import { renderSuggestions } from '#tui/prompts/suggest/render'; +import { resolveSuggestions } from '#tui/prompts/suggest/resolve'; +import type { SuggestOptions } from '#tui/prompts/suggest/options'; + +export const readAutocompleteValue = async (options: SuggestOptions): Promise => { + const environment = promptEnvironment(); + + if (!environment.input.readKey) { + return ask(options.message, options.hint); + } + + let state = { + cursor: options.default?.length ?? 0, + value: options.default ?? '', + }; + let highlighted = 0; + + let matches = await resolveSuggestions(options.options, state.value); + + renderSuggestions(options.message, state.value, matches, matches.length > 0 ? highlighted : null, options.scroll, options.info); + + while (true) { + const key = await environment.input.readKey(); + + if (key === null) { + return state.value; + } + + if (key === Key.up || key === Key.upArrow) { + matches = await resolveSuggestions(options.options, state.value); + + highlighted = matches.length === 0 ? 0 : (highlighted - 1 + matches.length) % matches.length; + renderSuggestions(options.message, state.value, matches, matches.length > 0 ? highlighted : null, options.scroll, options.info); + continue; + } + + if (key === Key.down || key === Key.downArrow) { + matches = await resolveSuggestions(options.options, state.value); + + highlighted = matches.length === 0 ? 0 : (highlighted + 1) % matches.length; + renderSuggestions(options.message, state.value, matches, matches.length > 0 ? highlighted : null, options.scroll, options.info); + continue; + } + + if ((key === Key.tab || key === Key.right || key === Key.rightArrow) && state.cursor >= [...state.value].length) { + matches = await resolveSuggestions(options.options, state.value); + + const match = matches[highlighted]; + + if (match !== undefined && match.length > state.value.length) { + state = { cursor: match.length, value: match }; + + matches = await resolveSuggestions(options.options, state.value); + } else { + highlighted = 0; + } + + renderSuggestions(options.message, state.value, matches, matches.length > 0 ? highlighted : null, options.scroll, options.info); + continue; + } + + const next = applyTypedKey(state, key); + + if (next.submitted) { + return state.value; + } + + if (next.cancelled) { + environment.error.write('Cancelled.\n'); + + return state.value; + } + + state = { cursor: next.cursor, value: next.value }; + highlighted = 0; + + matches = await resolveSuggestions(options.options, state.value); + + renderSuggestions(options.message, state.value, matches, matches.length > 0 ? highlighted : null, options.scroll, options.info); + } +}; diff --git a/packages/tui/src/prompts/suggest/read.ts b/packages/tui/src/prompts/suggest/read.ts index bb70e2f2..1e657d55 100644 --- a/packages/tui/src/prompts/suggest/read.ts +++ b/packages/tui/src/prompts/suggest/read.ts @@ -33,15 +33,7 @@ export const readSuggestionValue = async (options: SuggestOptions): Promise string[])): Promise<{ output: string; result: string }> => { const output = createMemoryOutput(); @@ -64,10 +64,11 @@ describe('suggest prompt', () => { expect(output).toContain('Blue'); }); - it('renders completed values after tab completion', async () => { + it('highlights suggestions on tab without replacing typed input', async () => { const output = await outputFor(['b', Key.tab, Key.enter], ['Red', 'Green', 'Blue']); - expect(output).toContain('Favorite color? Blue'); + expect(output).toContain('Favorite color? b'); + expect(output).not.toContain('Favorite color? Blue'); }); it('renders suggest info for the highlighted result', async () => { @@ -86,3 +87,38 @@ describe('suggest prompt', () => { expect(output.text()).toContain('About Blue'); }); }); + +describe('autocomplete prompt', () => { + it('accepts ghost completion with tab', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['b', Key.tab, Key.enter]), + output, + error: output, + interactive: true, + }, + () => autocomplete('Favorite color?', ['Red', 'Green', 'Blue']), + ); + + expect(result).toBe('Blue'); + expect(output.text()).toContain('Favorite color? Blue'); + }); + + it('cycles autocomplete matches before accepting completion', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['b', Key.down, Key.tab, Key.enter]), + output, + error: output, + interactive: true, + }, + () => autocomplete('Favorite color?', ['Blue', 'Black', 'Blurple']), + ); + + expect(result).toBe('Black'); + }); +}); From 6e13902f900faf5057fff69ba87bee77f7884405 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 15:18:58 +0800 Subject: [PATCH 040/675] Add data table search mode --- packages/tui/src/output/data-table.ts | 71 +++++++++++++++++++++++---- packages/tui/tests/table.test.ts | 50 ++++++++++++++++++- 2 files changed, 109 insertions(+), 12 deletions(-) diff --git a/packages/tui/src/output/data-table.ts b/packages/tui/src/output/data-table.ts index 9b531a62..d6e9aa56 100644 --- a/packages/tui/src/output/data-table.ts +++ b/packages/tui/src/output/data-table.ts @@ -1,5 +1,5 @@ import { promptEnvironment } from '#tui/environment'; -import { Key } from '#tui/key'; +import { Key, oneOf } from '#tui/key'; import { promptUntilValid, PromptValidationError } from '#tui/prompt'; import { renderTable } from '#tui/theme'; import { isDataObjectRow } from '#tui/output/validators/data-table'; @@ -94,6 +94,7 @@ export const datatable = async (options: DataTablePromptOptions) const environment = promptEnvironment(); let selected = 0; + let mode: 'browse' | 'search' = 'browse'; let query = ''; const visibleRows = (): Array> => { @@ -113,7 +114,7 @@ export const datatable = async (options: DataTablePromptOptions) return [index === selected ? '›' : ' ', ...rowCells(headers, row)]; }); - const querySuffix = query.length > 0 ? ` ${query}` : ''; + const querySuffix = mode === 'search' || query.length > 0 ? ` ${query}` : ''; environment.output.write(`${options.message}${querySuffix}\n`); environment.output.write(`${renderTable(['', ...headers], renderedRows)}\n`); @@ -141,6 +142,45 @@ export const datatable = async (options: DataTablePromptOptions) const rows = visibleRows(); + if (mode === 'search') { + if (key === Key.enter) { + mode = 'browse'; + selected = 0; + render(); + continue; + } + + if (key === Key.escape) { + mode = 'browse'; + query = ''; + selected = 0; + render(); + continue; + } + + if (key === Key.backspace || key === Key.ctrlH) { + query = query.slice(0, -1); + selected = 0; + render(); + continue; + } + + if (isPrintable(key)) { + query += key; + selected = 0; + render(); + continue; + } + } + + if (key === '/') { + mode = 'search'; + query = ''; + selected = 0; + render(); + continue; + } + if (key === Key.down || key === Key.downArrow || key === Key.ctrlN) { selected = rows.length === 0 ? 0 : (selected + 1) % rows.length; render(); @@ -153,13 +193,30 @@ export const datatable = async (options: DataTablePromptOptions) continue; } - if (key === Key.backspace || key === Key.ctrlH) { - query = query.slice(0, -1); + if (key === Key.pageDown) { + selected = rows.length === 0 ? 0 : Math.min(rows.length - 1, selected + (options.scroll ?? 10)); + render(); + continue; + } + + if (key === Key.pageUp) { + selected = Math.max(0, selected - (options.scroll ?? 10)); + render(); + continue; + } + + if (oneOf([Key.home, Key.ctrlA], key)) { selected = 0; render(); continue; } + if (oneOf([Key.end, Key.ctrlE], key)) { + selected = Math.max(0, rows.length - 1); + render(); + continue; + } + if (key === Key.enter) { const selectedRow = rows[selected]; @@ -169,12 +226,6 @@ export const datatable = async (options: DataTablePromptOptions) return rowValue(selectedRow.row, selectedRow.index); } - - if (isPrintable(key)) { - query += key; - selected = 0; - render(); - } } }); }; diff --git a/packages/tui/tests/table.test.ts b/packages/tui/tests/table.test.ts index 268db1c4..3a0c7e79 100644 --- a/packages/tui/tests/table.test.ts +++ b/packages/tui/tests/table.test.ts @@ -42,12 +42,12 @@ describe('data table prompt', () => { expect(output.text()).toContain('›'); }); - it('filters rows from typed input', async () => { + it('filters rows in explicit search mode', async () => { const output = createMemoryOutput(); const result = await withPromptEnvironment( { - input: createScriptedInput(['B', Key.enter]), + input: createScriptedInput(['/', 'B', Key.enter, Key.enter]), output, error: output, interactive: true, @@ -66,6 +66,52 @@ describe('data table prompt', () => { expect(output.text()).toContain('Pick project B'); }); + it('does not filter rows from printable keys in browse mode', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['B', Key.enter]), + output, + error: output, + interactive: true, + }, + () => + datatable({ + message: 'Pick project', + rows: [ + { Name: 'Alpha', value: 'alpha' }, + { Name: 'Beta', value: 'beta' }, + ], + }), + ); + + expect(result).toBe('alpha'); + }); + + it('cancels data table search with escape', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['/', 'B', Key.escape, Key.down, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + datatable({ + message: 'Pick project', + rows: [ + { Name: 'Alpha', value: 'alpha' }, + { Name: 'Beta', value: 'beta' }, + ], + }), + ); + + expect(result).toBe('beta'); + }); + it('falls back to row indexes when rows have no explicit value', async () => { const output = createMemoryOutput(); From 21b69e7c16a40bb93d56216c00966438334bebf6 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 15:21:36 +0800 Subject: [PATCH 041/675] Pass names to form step callbacks --- packages/tui/src/form/builder/index.ts | 6 +++--- packages/tui/src/form/types.ts | 2 +- packages/tui/tests/form.test.ts | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/tui/src/form/builder/index.ts b/packages/tui/src/form/builder/index.ts index 63e90c75..60041049 100644 --- a/packages/tui/src/form/builder/index.ts +++ b/packages/tui/src/form/builder/index.ts @@ -33,7 +33,7 @@ export class FormBuilder { readonly textarea = promptBuilderMethods.textarea; readonly warning = outputBuilderMethods.warning; - add(step: (responses: FormResponses, previous: unknown) => MaybePromise, name?: string, ignoreWhenReverting = false): this { + add(step: (responses: FormResponses, previous: unknown, name?: string) => MaybePromise, name?: string, ignoreWhenReverting = false): this { this.#steps.push({ condition: true, ignoreWhenReverting, @@ -46,7 +46,7 @@ export class FormBuilder { addIf( condition: boolean | ((responses: FormResponses) => boolean), - step: (responses: FormResponses, previous: unknown) => MaybePromise, + step: (responses: FormResponses, previous: unknown, name?: string) => MaybePromise, name?: string, ignoreWhenReverting = false, ): this { @@ -70,7 +70,7 @@ export class FormBuilder { continue; } - this.#responses[key] = await step.run(this.#responses, this.#responses[key]); + this.#responses[key] = await step.run(this.#responses, this.#responses[key], step.name); } return this.#responses; diff --git a/packages/tui/src/form/types.ts b/packages/tui/src/form/types.ts index 01e04ddb..c7c2720a 100644 --- a/packages/tui/src/form/types.ts +++ b/packages/tui/src/form/types.ts @@ -6,5 +6,5 @@ export type FormStep = { condition: boolean | ((responses: FormResponses) => boolean); ignoreWhenReverting: boolean; name?: string; - run: (responses: FormResponses, previous: unknown) => MaybePromise; + run: (responses: FormResponses, previous: unknown, name?: string) => MaybePromise; }; diff --git a/packages/tui/tests/form.test.ts b/packages/tui/tests/form.test.ts index 4991cbef..f4d09c10 100644 --- a/packages/tui/tests/form.test.ts +++ b/packages/tui/tests/form.test.ts @@ -20,7 +20,7 @@ describe('form builder', () => { expect(responses[2]).toBe(true); }); - it('keys named responses and passes prior responses to custom steps', async () => { + it('keys named responses and passes prior responses and names to custom steps', async () => { const output = createMemoryOutput(); const responses = await withPromptEnvironment( @@ -33,12 +33,12 @@ describe('form builder', () => { () => form() .text('Name', '', '', false, undefined, '', 'name') - .add((values) => `Hello ${values.name}`, 'greeting') + .add((values, previous, name) => `${name ?? 'missing'}:${previous ?? 'new'}:Hello ${values.name}`, 'greeting') .submit(), ); expect(responses.name).toBe('Ada'); - expect(responses.greeting).toBe('Hello Ada'); + expect(responses.greeting).toBe('greeting:new:Hello Ada'); }); it('stores null for skipped conditional steps', async () => { From a87b07e0b2de134fb5e868604881b3660ec7b756 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 15:23:09 +0800 Subject: [PATCH 042/675] Return pause prompt interactivity state --- packages/tui/src/form/builder/prompts.ts | 9 ++------- packages/tui/src/prompts/pause.ts | 6 +++--- packages/tui/tests/form.test.ts | 1 + packages/tui/tests/pause.test.ts | 20 ++++++++++++++++++-- 4 files changed, 24 insertions(+), 12 deletions(-) diff --git a/packages/tui/src/form/builder/prompts.ts b/packages/tui/src/form/builder/prompts.ts index 09012fa1..e405e894 100644 --- a/packages/tui/src/form/builder/prompts.ts +++ b/packages/tui/src/form/builder/prompts.ts @@ -1,7 +1,6 @@ import { autocomplete, confirm, multiselect, multisearch, pause, search, select, suggest } from '#tui/prompts/choices'; import { number, password, text, textarea } from '#tui/prompts/basic'; import { previousArray, previousNumber, previousString } from '#tui/form/builder/previous'; -import { sideEffectStep } from '#tui/form/builder/step'; import type { FormBuilder } from '#tui/form/builder/index'; import type { ChoiceInput, MaybePromise, MultiSearchPromptOptions, SearchPromptOptions, TextPromptOptions } from '#tui/types'; import type { SuggestOptions } from '#tui/prompts/choices'; @@ -142,12 +141,8 @@ export const promptBuilderMethods: PromptBuilderMethods & ThisType password(label, placeholder = '', required = false, validate = undefined, hint = '', name, transform = undefined) { return this.add(() => password(label, placeholder, required, validate, hint, transform), name); }, - pause(message = 'Press enter to continue', name?: string) { - return this.add( - sideEffectStep(() => pause(message)), - name, - true, - ); + pause(message = 'Press enter to continue...', name?: string) { + return this.add(() => pause(message), name); }, search(options: SearchPromptOptions, name?: string) { return this.add(() => search(options), name); diff --git a/packages/tui/src/prompts/pause.ts b/packages/tui/src/prompts/pause.ts index 2d33611f..e05c1692 100644 --- a/packages/tui/src/prompts/pause.ts +++ b/packages/tui/src/prompts/pause.ts @@ -3,13 +3,13 @@ import { Key } from '#tui/key'; import { ask } from '#tui/prompt'; import { renderQuestion } from '#tui/theme'; -export const pause = async (message = 'Press enter to continue'): Promise => { +export const pause = async (message = 'Press enter to continue...'): Promise => { const environment = promptEnvironment(); if (!environment.input.readKey) { await ask(message); - return; + return environment.interactive; } environment.output.write(renderQuestion(message)); @@ -20,7 +20,7 @@ export const pause = async (message = 'Press enter to continue'): Promise if (key === null || key === Key.enter) { environment.output.write('\n'); - return; + return environment.interactive; } } }; diff --git a/packages/tui/tests/form.test.ts b/packages/tui/tests/form.test.ts index f4d09c10..cf3d421f 100644 --- a/packages/tui/tests/form.test.ts +++ b/packages/tui/tests/form.test.ts @@ -137,6 +137,7 @@ describe('form builder', () => { ); expect(responses.task).toBe(1); + expect(responses[1]).toBe(true); expect(responses.streamed).toBeNull(); expect(output.text()).toContain('Build'); expect(output.text()).toContain('line one'); diff --git a/packages/tui/tests/pause.test.ts b/packages/tui/tests/pause.test.ts index 669795d0..88f8ccab 100644 --- a/packages/tui/tests/pause.test.ts +++ b/packages/tui/tests/pause.test.ts @@ -5,7 +5,7 @@ describe('pause prompt', () => { it('waits for enter from key-driven input', async () => { const output = createMemoryOutput(); - await withPromptEnvironment( + const result = await withPromptEnvironment( { input: createScriptedInput(['x', Key.enter]), output, @@ -15,6 +15,7 @@ describe('pause prompt', () => { () => pause('Continue'), ); + expect(result).toBe(true); expect(output.text()).toContain('Continue'); expect(output.text().endsWith('\n')).toBe(true); }); @@ -24,7 +25,7 @@ describe('pause prompt', () => { let asked = ''; - await withPromptEnvironment( + const result = await withPromptEnvironment( { input: { async readLine(message: string): Promise { @@ -40,6 +41,21 @@ describe('pause prompt', () => { () => pause('Continue'), ); + expect(result).toBe(true); expect(asked).toContain('Continue'); }); + + it('returns false when the configured environment is non-interactive', async () => { + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.enter]), + output: createMemoryOutput(), + error: createMemoryOutput(), + interactive: false, + }, + () => pause('Continue'), + ); + + expect(result).toBe(false); + }); }); From 1a06180fa1a1bbddc4b8714b77acc21f2a439d0d Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 15:24:57 +0800 Subject: [PATCH 043/675] Cover data table navigation keys --- packages/tui/tests/table.test.ts | 39 ++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/packages/tui/tests/table.test.ts b/packages/tui/tests/table.test.ts index 3a0c7e79..7b9ac6fe 100644 --- a/packages/tui/tests/table.test.ts +++ b/packages/tui/tests/table.test.ts @@ -161,6 +161,45 @@ describe('data table prompt', () => { expect(latestFrame).toContain('Fourth'); }); + it('supports page, home, and end data table navigation keys', async () => { + const output = createMemoryOutput(); + + const pageDownResult = await withPromptEnvironment( + { + input: createScriptedInput([Key.pageDown, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + datatable({ + message: 'Pick row', + headers: ['Name'], + rows: [['First'], ['Second'], ['Third'], ['Fourth']], + scroll: 2, + }), + ); + + const endHomeResult = await withPromptEnvironment( + { + input: createScriptedInput([Key.end[0], Key.home[0], Key.enter]), + output, + error: output, + interactive: true, + }, + () => + datatable({ + message: 'Pick row', + headers: ['Name'], + rows: [['First'], ['Second'], ['Third'], ['Fourth']], + scroll: 2, + }), + ); + + expect(pageDownResult).toBe(2); + expect(endHomeResult).toBe(0); + }); + it('works from form builders', async () => { const output = createMemoryOutput(); From c3f5f544cb5c2474f865132ce6517799052fc25c Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 15:26:49 +0800 Subject: [PATCH 044/675] Return success from terminal output helpers --- packages/tui/src/output/terminal.ts | 8 ++++++-- packages/tui/tests/terminal.test.ts | 4 ++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/tui/src/output/terminal.ts b/packages/tui/src/output/terminal.ts index 4c1444d5..ae0a2460 100644 --- a/packages/tui/src/output/terminal.ts +++ b/packages/tui/src/output/terminal.ts @@ -1,9 +1,13 @@ import { clearTerminal, setTerminalTitle } from '#tui/terminal'; -export const title = (value: string): void => { +export const title = (value: string): boolean => { setTerminalTitle(value); + + return true; }; -export const clear = (): void => { +export const clear = (): boolean => { clearTerminal(); + + return true; }; diff --git a/packages/tui/tests/terminal.test.ts b/packages/tui/tests/terminal.test.ts index 244f6e60..38b0ee87 100644 --- a/packages/tui/tests/terminal.test.ts +++ b/packages/tui/tests/terminal.test.ts @@ -7,7 +7,7 @@ describe('terminal helpers', () => { await withPromptEnvironment({ output, error: output }, async () => { clearTerminal(); - clear(); + expect(clear()).toBe(true); }); expect(output.text()).toBe('\u001Bc\u001Bc'); @@ -18,7 +18,7 @@ describe('terminal helpers', () => { await withPromptEnvironment({ output, error: output }, async () => { setTerminalTitle('Demo'); - title('Prompt'); + expect(title('Prompt')).toBe(true); }); expect(output.text()).toBe('\u001B]0;Demo\u0007\u001B]0;Prompt\u0007'); From 2ea6c9a6ef33aa8067eff47546f871527c52d774 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 15:30:39 +0800 Subject: [PATCH 045/675] Return success from output helpers --- packages/tui/src/form/builder/output.ts | 49 ++++--------------------- packages/tui/src/output/notes.ts | 16 ++++---- packages/tui/src/output/table.ts | 8 +++- packages/tui/tests/form.test.ts | 19 ++++++++++ packages/tui/tests/output.test.ts | 33 +++++++++++++++++ 5 files changed, 75 insertions(+), 50 deletions(-) create mode 100644 packages/tui/tests/output.test.ts diff --git a/packages/tui/src/form/builder/output.ts b/packages/tui/src/form/builder/output.ts index c3da1cfc..8e301814 100644 --- a/packages/tui/src/form/builder/output.ts +++ b/packages/tui/src/form/builder/output.ts @@ -1,5 +1,4 @@ import { alert, datatable, error, info, intro, note, outro, table, warning } from '#tui/output'; -import { sideEffectStep } from '#tui/form/builder/step'; import type { FormBuilder } from '#tui/form/builder/index'; import type { DataTablePromptOptions, TableOptions } from '#tui/types'; @@ -17,62 +16,30 @@ export type OutputBuilderMethods = { export const outputBuilderMethods: OutputBuilderMethods & ThisType = { alert(message, name) { - return this.add( - sideEffectStep(() => alert(message)), - name, - true, - ); + return this.add(() => alert(message), name, true); }, datatable(options: DataTablePromptOptions, name?: string) { return this.add(() => datatable(options), name); }, error(message, name) { - return this.add( - sideEffectStep(() => error(message)), - name, - true, - ); + return this.add(() => error(message), name, true); }, info(message, name) { - return this.add( - sideEffectStep(() => info(message)), - name, - true, - ); + return this.add(() => info(message), name, true); }, intro(message, name) { - return this.add( - sideEffectStep(() => intro(message)), - name, - true, - ); + return this.add(() => intro(message), name, true); }, note(message, type = null, name) { - return this.add( - sideEffectStep(() => note(message, type)), - name, - true, - ); + return this.add(() => note(message, type), name, true); }, outro(message, name) { - return this.add( - sideEffectStep(() => outro(message)), - name, - true, - ); + return this.add(() => outro(message), name, true); }, table(headersOrOptions = [], rows = null, name) { - return this.add( - sideEffectStep(() => table(headersOrOptions, rows)), - name, - true, - ); + return this.add(() => table(headersOrOptions, rows), name, true); }, warning(message, name) { - return this.add( - sideEffectStep(() => warning(message)), - name, - true, - ); + return this.add(() => warning(message), name, true); }, }; diff --git a/packages/tui/src/output/notes.ts b/packages/tui/src/output/notes.ts index 82bda5e0..30387510 100644 --- a/packages/tui/src/output/notes.ts +++ b/packages/tui/src/output/notes.ts @@ -26,20 +26,22 @@ const notePrefix = (type?: NoteType | null): string => { } }; -export const note = (message: string, type?: NoteType | null): void => { +export const note = (message: string, type?: NoteType | null): boolean => { const prefix = notePrefix(type); promptEnvironment().output.write(`${prefix} ${message}\n`); + + return true; }; -export const error = (message: string): void => note(message, 'error'); +export const error = (message: string): boolean => note(message, 'error'); -export const warning = (message: string): void => note(message, 'warning'); +export const warning = (message: string): boolean => note(message, 'warning'); -export const alert = (message: string): void => note(message, 'alert'); +export const alert = (message: string): boolean => note(message, 'alert'); -export const info = (message: string): void => note(message, 'info'); +export const info = (message: string): boolean => note(message, 'info'); -export const intro = (message: string): void => note(message, 'intro'); +export const intro = (message: string): boolean => note(message, 'intro'); -export const outro = (message: string): void => note(message, 'outro'); +export const outro = (message: string): boolean => note(message, 'outro'); diff --git a/packages/tui/src/output/table.ts b/packages/tui/src/output/table.ts index 97f4a9a1..a81b3de4 100644 --- a/packages/tui/src/output/table.ts +++ b/packages/tui/src/output/table.ts @@ -19,7 +19,7 @@ const normalizeTableOptions = (headersOrOptions: TableOptions | string[] = [], r }; }; -export const table = (headersOrOptions: TableOptions | string[] = [], rows: TableOptions['rows'] | null = null): void => { +export const table = (headersOrOptions: TableOptions | string[] = [], rows: TableOptions['rows'] | null = null): boolean => { const options = normalizeTableOptions(headersOrOptions, rows); const normalizedRows = options.rows.map((row) => { @@ -31,9 +31,11 @@ export const table = (headersOrOptions: TableOptions | string[] = [], rows: Tabl }); promptEnvironment().output.write(`${renderTable(options.headers, normalizedRows)}\n`); + + return true; }; -export const grid = (items: Array = [], maxWidth?: number): void => { +export const grid = (items: Array = [], maxWidth?: number): boolean => { const width = maxWidth ?? 4; const rows: string[][] = []; @@ -43,6 +45,8 @@ export const grid = (items: Array = [], maxWidth?: nu promptEnvironment().output.write(rows.map((row) => row.join(' ')).join('\n')); promptEnvironment().output.write(items.length > 0 ? '\n' : ''); + + return true; }; export const dataTable = table; diff --git a/packages/tui/tests/form.test.ts b/packages/tui/tests/form.test.ts index cf3d421f..a86cca40 100644 --- a/packages/tui/tests/form.test.ts +++ b/packages/tui/tests/form.test.ts @@ -142,4 +142,23 @@ describe('form builder', () => { expect(output.text()).toContain('Build'); expect(output.text()).toContain('line one'); }); + + it('stores output helper success responses', async () => { + const output = createMemoryOutput(); + + const responses = await withPromptEnvironment( + { + output, + error: output, + }, + () => + form() + .note('Saved', null, 'note') + .table(['Name'], [['Ollin']], 'table') + .submit(), + ); + + expect(responses.note).toBe(true); + expect(responses.table).toBe(true); + }); }); diff --git a/packages/tui/tests/output.test.ts b/packages/tui/tests/output.test.ts new file mode 100644 index 00000000..631e485d --- /dev/null +++ b/packages/tui/tests/output.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; +import { alert, createMemoryOutput, error, grid, info, intro, note, outro, table, warning, withPromptEnvironment } from '#tui/index'; + +describe('output helpers', () => { + it('returns success from note-style helpers', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + expect(note('Hello')).toBe(true); + expect(error('Nope')).toBe(true); + expect(warning('Careful')).toBe(true); + expect(alert('Heads up')).toBe(true); + expect(info('Facts')).toBe(true); + expect(intro('Start')).toBe(true); + expect(outro('Done')).toBe(true); + }); + + expect(output.text()).toContain('Hello'); + expect(output.text()).toContain('Done'); + }); + + it('returns success from table and grid helpers', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + expect(table(['Name'], [['Ollin']])).toBe(true); + expect(grid(['A', 'B'], 2)).toBe(true); + }); + + expect(output.text()).toContain('| Name'); + expect(output.text()).toContain('A B'); + }); +}); From cb2567d8b55fb9664da152797c1897e4b139073e Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 15:33:52 +0800 Subject: [PATCH 046/675] Improve multisearch selected state rendering --- packages/tui/src/prompts/search/read-multi.ts | 16 +++++++- packages/tui/src/prompts/search/render.ts | 13 +++++- packages/tui/tests/search.test.ts | 40 +++++++++++++++++++ 3 files changed, 65 insertions(+), 4 deletions(-) diff --git a/packages/tui/src/prompts/search/read-multi.ts b/packages/tui/src/prompts/search/read-multi.ts index b5bec938..84284cd1 100644 --- a/packages/tui/src/prompts/search/read-multi.ts +++ b/packages/tui/src/prompts/search/read-multi.ts @@ -39,7 +39,7 @@ export const readMultiSearchChoices = async (options: MultiSearchPromptOption let highlighted: number | null = null; - const selected = new Map(); + const selected = initialSelectedValues(choices, options.default); const displayedChoices = (): Array> => { if (state.value.trim() !== '') { @@ -57,7 +57,7 @@ export const readMultiSearchChoices = async (options: MultiSearchPromptOption const currentChoices = displayedChoices(); const marked = new Set(currentChoices.flatMap((choice, index) => (selected.has(choice.value) ? [index] : []))); - renderSearchChoices(options.message, state.value, currentChoices, highlighted, marked, [...selected.values()], options.scroll, options.info); + renderSearchChoices(options.message, state.value, currentChoices, highlighted, marked, [...selected.values()], options.scroll, options.info, true); }; render(); @@ -155,3 +155,15 @@ export const readMultiSearchChoices = async (options: MultiSearchPromptOption render(); } }; + +const initialSelectedValues = (choices: Array>, defaults: T[] = []): Map => { + const selected = new Map(); + + for (const value of defaults) { + const choice = choices.find((candidate) => Object.is(candidate.value, value)); + + selected.set(value, choice?.label ?? String(value)); + } + + return selected; +}; diff --git a/packages/tui/src/prompts/search/render.ts b/packages/tui/src/prompts/search/render.ts index 8799c0e7..1d5e0b10 100644 --- a/packages/tui/src/prompts/search/render.ts +++ b/packages/tui/src/prompts/search/render.ts @@ -13,16 +13,25 @@ export const renderSearchChoices = ( selectedLabels: string[] = [], scroll?: number, info?: SearchPromptOptions['info'] | MultiSearchPromptOptions['info'], + showSelectedSummary = false, ): void => { renderInteractiveChoices(searchMessage(message, query), choices, highlighted ?? 0, marked, scroll); const text = resolveInfo(info, highlighted === null ? null : (choices[highlighted]?.value ?? null)); + const summary = showSelectedSummary ? selectedSummary(selectedLabels.length, selectedLabels.length - marked.size) : ''; + const details = [text, summary].filter((part) => part.length > 0).join(' · '); - if (text.length > 0) { - promptEnvironment().output.write(`${text}\n`); + if (details.length > 0) { + promptEnvironment().output.write(`${details}\n`); } if (selectedLabels.length > 0) { promptEnvironment().output.write(`Selected: ${selectedLabels.join(', ')}\n`); } }; + +const selectedSummary = (selectedCount: number, hiddenCount: number): string => { + const hidden = hiddenCount > 0 ? ` (${hiddenCount} hidden)` : ''; + + return `${selectedCount} selected${hidden}`; +}; diff --git a/packages/tui/tests/search.test.ts b/packages/tui/tests/search.test.ts index 7abdc707..687aca5f 100644 --- a/packages/tui/tests/search.test.ts +++ b/packages/tui/tests/search.test.ts @@ -162,6 +162,29 @@ describe('multisearch prompt', () => { expect(output.text()).toContain('Selected: Violet, Green'); }); + it('starts multisearch prompts with default selected values', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.enter]), + output, + error: output, + interactive: true, + }, + () => + multisearch({ + message: 'Favorite colors?', + options: colors, + default: ['green'], + }), + ); + + expect(result).toEqual(['green']); + expect(output.text()).toContain('1 selected'); + expect(output.text()).toContain('Selected: Green'); + }); + it('renders multisearch info for the highlighted result', async () => { const output = createMemoryOutput(); @@ -176,6 +199,23 @@ describe('multisearch prompt', () => { ); expect(output.text()).toContain('About red'); + expect(output.text()).toContain('0 selected'); + }); + + it('renders hidden selected counts when multisearch results are filtered', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.space, 'B', Key.enter]), + output, + error: output, + interactive: true, + }, + () => multisearch({ message: 'Favorite colors?', options: colors }), + ); + + expect(output.text()).toContain('1 selected (1 hidden)'); }); it('toggles all current multisearch results with ctrl-a', async () => { From 7c461f55517a6e2b12aef2e78152ff0b7ad4215b Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 15:35:36 +0800 Subject: [PATCH 047/675] Render multiselect selected counts --- packages/tui/src/prompts/select/render.ts | 6 ++- packages/tui/tests/choices.test.ts | 50 +++++++++++++++++++++++ 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/packages/tui/src/prompts/select/render.ts b/packages/tui/src/prompts/select/render.ts index da1be7a4..27fe18f4 100644 --- a/packages/tui/src/prompts/select/render.ts +++ b/packages/tui/src/prompts/select/render.ts @@ -17,9 +17,11 @@ export const renderMultipleChoices = (message: string, choices: Array scroll ? `${marked.size} selected` : ''; + const details = [text, summary].filter((part) => part.length > 0).join(' · '); - if (text.length > 0) { - promptEnvironment().output.write(`${text}\n`); + if (details.length > 0) { + promptEnvironment().output.write(`${details}\n`); } const labels = [...marked] diff --git a/packages/tui/tests/choices.test.ts b/packages/tui/tests/choices.test.ts index 5ef329aa..a8d6410b 100644 --- a/packages/tui/tests/choices.test.ts +++ b/packages/tui/tests/choices.test.ts @@ -170,6 +170,23 @@ describe('choice prompts', () => { expect(output.text()).toContain('Selected: first, second'); }); + it('starts multiselect prompts with default selected values', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.enter]), + output, + error: output, + interactive: true, + }, + () => multiselect({ message: 'Pick many', options: ['first', 'second'], default: ['second'] }), + ); + + expect(result).toEqual(['second']); + expect(output.text()).toContain('Selected: second'); + }); + it('requires multiselect choices when configured', async () => { const output = createMemoryOutput(); @@ -220,4 +237,37 @@ describe('choice prompts', () => { expect(output.text()).toContain('About first'); expect(output.text()).toContain('About second'); }); + + it('renders multiselect selected counts for scrollable option lists', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment( + { + input: createScriptedInput([Key.space, Key.enter]), + output, + error: output, + interactive: true, + }, + () => multiselect({ message: 'Pick many', options: ['first', 'second', 'third'], scroll: 2 }), + ); + + expect(output.text()).toContain('0 selected'); + expect(output.text()).toContain('1 selected'); + }); + + it('combines multiselect info with selected counts', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment( + { + input: createScriptedInput([Key.enter]), + output, + error: output, + interactive: true, + }, + () => multiselect({ message: 'Pick many', options: ['first', 'second', 'third'], scroll: 2, info: (value) => `About ${value ?? 'none'}` }), + ); + + expect(output.text()).toContain('About first · 0 selected'); + }); }); From e75b8d909361d881c92a3e9e619930bc7a1d3a65 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 15:37:25 +0800 Subject: [PATCH 048/675] Fix suggest reverse tab navigation --- packages/tui/src/prompts/suggest/read.ts | 4 ++-- packages/tui/tests/suggest.test.ts | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/tui/src/prompts/suggest/read.ts b/packages/tui/src/prompts/suggest/read.ts index 1e657d55..b78a7394 100644 --- a/packages/tui/src/prompts/suggest/read.ts +++ b/packages/tui/src/prompts/suggest/read.ts @@ -38,7 +38,7 @@ export const readSuggestionValue = async (options: SuggestOptions): Promise { await expectSuggestion(['b', Key.ctrlN, Key.ctrlN, Key.ctrlN, Key.ctrlP, Key.enter], ['Red', 'Blue', 'Black', 'Blurple'], 'Black'); }); + it('navigates suggestions backwards with shift tab', async () => { + await expectSuggestion(['b', Key.shiftTab, Key.enter], ['Red', 'Blue', 'Black', 'Blurple'], 'Blurple'); + }); + it('supports callback options', async () => { await expectSuggestion(['e', 'e', Key.down, Key.enter], (value) => ['Red', 'Green', 'Blue'].filter((option) => option.toLowerCase().includes(value.toLowerCase())), 'Green'); }); From 6b0e2c1f47b136b9ac6bab36ab23e09bae04d621 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 15:40:01 +0800 Subject: [PATCH 049/675] Validate search required option --- packages/tui/src/prompts/search/index.ts | 3 +++ packages/tui/src/prompts/search/validators/options.ts | 10 ++++++++++ packages/tui/src/types.ts | 3 ++- packages/tui/tests/search.test.ts | 11 +++++++++++ 4 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 packages/tui/src/prompts/search/validators/options.ts diff --git a/packages/tui/src/prompts/search/index.ts b/packages/tui/src/prompts/search/index.ts index 19cd73bc..223f6859 100644 --- a/packages/tui/src/prompts/search/index.ts +++ b/packages/tui/src/prompts/search/index.ts @@ -1,9 +1,12 @@ import { promptUntilValid, PromptValidationError } from '#tui/prompt'; import { readMultiSearchChoices } from '#tui/prompts/search/read-multi'; import { readSearchChoice } from '#tui/prompts/search/read-single'; +import { assertSearchOptions } from '#tui/prompts/search/validators/options'; import type { MultiSearchPromptOptions, SearchPromptOptions } from '#tui/types'; export const search = async (options: SearchPromptOptions): Promise => { + assertSearchOptions(options); + return promptUntilValid(options, async (attempt) => { const selected = await readSearchChoice(options, attempt); diff --git a/packages/tui/src/prompts/search/validators/options.ts b/packages/tui/src/prompts/search/validators/options.ts new file mode 100644 index 00000000..046e1ff3 --- /dev/null +++ b/packages/tui/src/prompts/search/validators/options.ts @@ -0,0 +1,10 @@ +import { z } from 'zod'; +import type { SearchPromptOptions } from '#tui/types'; + +const disallowedRequiredSchema = z.literal(false); + +export const assertSearchOptions = (options: SearchPromptOptions): void => { + if (disallowedRequiredSchema.safeParse(options.required).success) { + throw new TypeError('Argument [required] must be true or a string.'); + } +}; diff --git a/packages/tui/src/types.ts b/packages/tui/src/types.ts index 557b7788..703efe20 100644 --- a/packages/tui/src/types.ts +++ b/packages/tui/src/types.ts @@ -77,10 +77,11 @@ export type MultiSelectPromptOptions = BasePromptOptions & { info?: PromptInfo; }; -export type SearchPromptOptions = BasePromptOptions & { +export type SearchPromptOptions = Omit, 'required'> & { label?: string; options: Array> | Record | ((query: string) => MaybePromise> | Record>); placeholder?: string; + required?: true | string; scroll?: number; info?: PromptInfo; }; diff --git a/packages/tui/tests/search.test.ts b/packages/tui/tests/search.test.ts index 687aca5f..e46b1ee9 100644 --- a/packages/tui/tests/search.test.ts +++ b/packages/tui/tests/search.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import { createMemoryOutput, createScriptedInput, Key, multisearch, search, withPromptEnvironment } from '#tui/index'; +import type { SearchPromptOptions } from '#tui/index'; const colors = (value: string): Record => { const options = { @@ -133,6 +134,16 @@ describe('search prompt', () => { expect(result).toBe('green'); }); + + it('rejects optional single search prompts', async () => { + const options = { + message: 'Favorite color?', + options: colors, + required: false, + } as unknown as SearchPromptOptions; + + await expect(search(options)).rejects.toThrow('Argument [required] must be true or a string.'); + }); }); describe('multisearch prompt', () => { From 9a4f248b8840906020c51dc56d917aa496d7c064 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 15:42:37 +0800 Subject: [PATCH 050/675] Align search highlight clearing --- .../tui/src/prompts/search/read-single.ts | 8 ++++- packages/tui/tests/search.test.ts | 32 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/packages/tui/src/prompts/search/read-single.ts b/packages/tui/src/prompts/search/read-single.ts index 1584b932..511349dd 100644 --- a/packages/tui/src/prompts/search/read-single.ts +++ b/packages/tui/src/prompts/search/read-single.ts @@ -70,6 +70,12 @@ export const readSearchChoice = async (options: SearchPromptOptions, attem continue; } + if (oneOf([Key.left, Key.leftArrow, Key.right, Key.rightArrow, Key.ctrlB, Key.ctrlF], key) && highlighted !== null) { + highlighted = null; + renderSearchChoices(options.message, state.value, choices, highlighted, new Set(), [], options.scroll, options.info); + continue; + } + if (key === Key.enter) { if (highlighted !== null) { const choice = choices[highlighted]; @@ -79,7 +85,7 @@ export const readSearchChoice = async (options: SearchPromptOptions, attem choices = await resolveSearchChoices(options.options, state.value); - highlighted = choices.length > 0 ? firstEnabledIndex(choices) : null; + highlighted = null; renderSearchChoices(options.message, state.value, choices, highlighted, new Set(), [], options.scroll, options.info); continue; } diff --git a/packages/tui/tests/search.test.ts b/packages/tui/tests/search.test.ts index e46b1ee9..c6eb0f79 100644 --- a/packages/tui/tests/search.test.ts +++ b/packages/tui/tests/search.test.ts @@ -135,6 +135,38 @@ describe('search prompt', () => { expect(result).toBe('green'); }); + it('keeps search highlights empty when submitting without a highlighted result', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.enter, Key.down, Key.enter]), + output, + error: output, + interactive: true, + }, + () => search({ message: 'Favorite color?', options: colors }), + ); + + expect(result).toBe('red'); + }); + + it('clears search highlights with horizontal navigation keys', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.left, Key.enter, Key.down, Key.enter]), + output, + error: output, + interactive: true, + }, + () => search({ message: 'Favorite color?', options: colors }), + ); + + expect(result).toBe('red'); + }); + it('rejects optional single search prompts', async () => { const options = { message: 'Favorite color?', From 3c978559b63a8c667dd1536937aafc8d32ca1a1f Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 15:45:19 +0800 Subject: [PATCH 051/675] Render autocomplete ghost text inline --- .../src/prompts/suggest/read-autocomplete.ts | 12 ++++++------ .../src/prompts/suggest/render-autocomplete.ts | 17 +++++++++++++++++ packages/tui/tests/suggest.test.ts | 17 +++++++++++++++++ 3 files changed, 40 insertions(+), 6 deletions(-) create mode 100644 packages/tui/src/prompts/suggest/render-autocomplete.ts diff --git a/packages/tui/src/prompts/suggest/read-autocomplete.ts b/packages/tui/src/prompts/suggest/read-autocomplete.ts index f4b71be2..1266db4f 100644 --- a/packages/tui/src/prompts/suggest/read-autocomplete.ts +++ b/packages/tui/src/prompts/suggest/read-autocomplete.ts @@ -2,7 +2,7 @@ import { promptEnvironment } from '#tui/environment'; import { Key } from '#tui/key'; import { ask } from '#tui/prompt'; import { applyTypedKey } from '#tui/typed-value'; -import { renderSuggestions } from '#tui/prompts/suggest/render'; +import { renderAutocomplete } from '#tui/prompts/suggest/render-autocomplete'; import { resolveSuggestions } from '#tui/prompts/suggest/resolve'; import type { SuggestOptions } from '#tui/prompts/suggest/options'; @@ -21,7 +21,7 @@ export const readAutocompleteValue = async (options: SuggestOptions): Promise 0 ? highlighted : null, options.scroll, options.info); + renderAutocomplete(options.message, state.value, matches, highlighted); while (true) { const key = await environment.input.readKey(); @@ -34,7 +34,7 @@ export const readAutocompleteValue = async (options: SuggestOptions): Promise 0 ? highlighted : null, options.scroll, options.info); + renderAutocomplete(options.message, state.value, matches, highlighted); continue; } @@ -42,7 +42,7 @@ export const readAutocompleteValue = async (options: SuggestOptions): Promise 0 ? highlighted : null, options.scroll, options.info); + renderAutocomplete(options.message, state.value, matches, highlighted); continue; } @@ -59,7 +59,7 @@ export const readAutocompleteValue = async (options: SuggestOptions): Promise 0 ? highlighted : null, options.scroll, options.info); + renderAutocomplete(options.message, state.value, matches, highlighted); continue; } @@ -80,6 +80,6 @@ export const readAutocompleteValue = async (options: SuggestOptions): Promise 0 ? highlighted : null, options.scroll, options.info); + renderAutocomplete(options.message, state.value, matches, highlighted); } }; diff --git a/packages/tui/src/prompts/suggest/render-autocomplete.ts b/packages/tui/src/prompts/suggest/render-autocomplete.ts new file mode 100644 index 00000000..b3c7680f --- /dev/null +++ b/packages/tui/src/prompts/suggest/render-autocomplete.ts @@ -0,0 +1,17 @@ +import { promptEnvironment } from '#tui/environment'; + +const startsWithInput = (match: string, value: string): boolean => { + return match.toLowerCase().startsWith(value.toLowerCase()); +}; + +const completionValue = (value: string, match: string | undefined): string => { + if (match === undefined || value.length === 0 || !startsWithInput(match, value) || match.length <= value.length) { + return value; + } + + return value + match.slice(value.length); +}; + +export const renderAutocomplete = (message: string, value: string, matches: string[], highlighted: number): void => { + promptEnvironment().output.write(`${message} ${completionValue(value, matches[highlighted])}\n`); +}; diff --git a/packages/tui/tests/suggest.test.ts b/packages/tui/tests/suggest.test.ts index 15e7e6c9..3869c509 100644 --- a/packages/tui/tests/suggest.test.ts +++ b/packages/tui/tests/suggest.test.ts @@ -93,6 +93,23 @@ describe('suggest prompt', () => { }); describe('autocomplete prompt', () => { + it('renders autocomplete ghost text without a suggestion list', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment( + { + input: createScriptedInput(['b', Key.enter]), + output, + error: output, + interactive: true, + }, + () => autocomplete('Favorite color?', ['Red', 'Green', 'Blue']), + ); + + expect(output.text()).toContain('Favorite color? blue'); + expect(output.text()).not.toContain('›'); + }); + it('accepts ghost completion with tab', async () => { const output = createMemoryOutput(); From 70ce56c780f0781a251cb2f7412d3ca1670bed63 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 15:46:59 +0800 Subject: [PATCH 052/675] Accept autocomplete matches with right arrow --- .../tui/src/prompts/suggest/read-autocomplete.ts | 15 ++++++++++++++- packages/tui/tests/suggest.test.ts | 16 ++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/packages/tui/src/prompts/suggest/read-autocomplete.ts b/packages/tui/src/prompts/suggest/read-autocomplete.ts index 1266db4f..e9cbb65c 100644 --- a/packages/tui/src/prompts/suggest/read-autocomplete.ts +++ b/packages/tui/src/prompts/suggest/read-autocomplete.ts @@ -46,7 +46,7 @@ export const readAutocompleteValue = async (options: SuggestOptions): Promise= [...state.value].length) { + if (key === Key.tab && state.cursor >= [...state.value].length) { matches = await resolveSuggestions(options.options, state.value); const match = matches[highlighted]; @@ -63,6 +63,19 @@ export const readAutocompleteValue = async (options: SuggestOptions): Promise= [...state.value].length) { + matches = await resolveSuggestions(options.options, state.value); + + const match = matches[highlighted]; + + if (match !== undefined) { + state = { cursor: match.length, value: match }; + } + + renderAutocomplete(options.message, state.value, matches, highlighted); + continue; + } + const next = applyTypedKey(state, key); if (next.submitted) { diff --git a/packages/tui/tests/suggest.test.ts b/packages/tui/tests/suggest.test.ts index 3869c509..f592ad81 100644 --- a/packages/tui/tests/suggest.test.ts +++ b/packages/tui/tests/suggest.test.ts @@ -127,6 +127,22 @@ describe('autocomplete prompt', () => { expect(output.text()).toContain('Favorite color? Blue'); }); + it('accepts same-length autocomplete matches with right arrow', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['b', 'l', 'u', 'e', Key.right, Key.enter]), + output, + error: output, + interactive: true, + }, + () => autocomplete('Favorite color?', ['Blue']), + ); + + expect(result).toBe('Blue'); + }); + it('cycles autocomplete matches before accepting completion', async () => { const output = createMemoryOutput(); From 350fc715251569b2240a1c1af5947ebbded1b284 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 15:49:02 +0800 Subject: [PATCH 053/675] Render visible partial progress --- packages/tui/src/status/progress.ts | 2 +- packages/tui/tests/progress.test.ts | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/tui/src/status/progress.ts b/packages/tui/src/status/progress.ts index fb2ce2b4..c73faa0d 100644 --- a/packages/tui/src/status/progress.ts +++ b/packages/tui/src/status/progress.ts @@ -55,7 +55,7 @@ export class Progress { render(): void { const width = 20; - const filled = Math.round(width * this.percentage()); + const filled = Math.ceil(width * this.percentage()); const bar = `${'█'.repeat(filled)}${' '.repeat(width - filled)}`; const hint = this.#hint ? ` ${this.#hint}` : ''; diff --git a/packages/tui/tests/progress.test.ts b/packages/tui/tests/progress.test.ts index a1ebb798..fccc3cfc 100644 --- a/packages/tui/tests/progress.test.ts +++ b/packages/tui/tests/progress.test.ts @@ -30,4 +30,18 @@ describe('progress helper', () => { expect(output.text()).toContain('ALABAMA'); expect(output.text()).toContain('alaska'); }); + + it('renders visible progress for small non-zero percentages', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + const bar = progress('Adding States', 100); + + bar.start(); + bar.advance(); + }); + + expect(output.text()).toContain('█'); + expect(output.text()).toContain('1 / 100'); + }); }); From e2f46a448ea8390b153fec965da673c92a1aed39 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 15:50:45 +0800 Subject: [PATCH 054/675] Align progress value result --- packages/tui/src/status/progress.ts | 6 +++++- packages/tui/tests/progress.test.ts | 14 ++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/tui/src/status/progress.ts b/packages/tui/src/status/progress.ts index c73faa0d..b7c9ee11 100644 --- a/packages/tui/src/status/progress.ts +++ b/packages/tui/src/status/progress.ts @@ -49,10 +49,14 @@ export class Progress { return this.#current / this.total; } - value(): number { + current(): number { return this.#current; } + value(): boolean { + return true; + } + render(): void { const width = 20; const filled = Math.ceil(width * this.percentage()); diff --git a/packages/tui/tests/progress.test.ts b/packages/tui/tests/progress.test.ts index fccc3cfc..a5e01346 100644 --- a/packages/tui/tests/progress.test.ts +++ b/packages/tui/tests/progress.test.ts @@ -44,4 +44,18 @@ describe('progress helper', () => { expect(output.text()).toContain('█'); expect(output.text()).toContain('1 / 100'); }); + + it('returns a completion value separately from the current count', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + const bar = progress('Adding States', 2); + + bar.start(); + bar.advance(); + + expect(bar.current()).toBe(1); + expect(bar.value()).toBe(true); + }); + }); }); From 508063eac28fb5a685594ea81b2fa7f822d34e0b Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 15:52:58 +0800 Subject: [PATCH 055/675] Support task stable summaries --- packages/tui/src/status/task.ts | 34 ++++++++++++++++++++++++------- packages/tui/tests/task.test.ts | 36 +++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 7 deletions(-) diff --git a/packages/tui/src/status/task.ts b/packages/tui/src/status/task.ts index d2bdfa7c..3bcd3bfa 100644 --- a/packages/tui/src/status/task.ts +++ b/packages/tui/src/status/task.ts @@ -5,17 +5,25 @@ const cursorReset = `${String.fromCharCode(27)}[1G`; const eraseLine = `${String.fromCharCode(27)}[2K`; export type TaskDefinition = { + keepSummary?: boolean; + subLabel?: string; title: string; limit?: number; task: (logger: Logger) => MaybePromise; }; +type StableMessage = { + message: string; + type: 'error' | 'success' | 'warning'; +}; + export class Logger { readonly lines: string[] = []; + readonly stableMessages: StableMessage[] = []; constructor(private readonly limit: number) {} - log(message: string): void { + line(message: string): void { for (const line of message.split(/\r?\n/u).filter((value) => value.length > 0)) { this.lines.push(line.replaceAll(cursorReset, '').replaceAll(eraseLine, '')); } @@ -25,20 +33,24 @@ export class Logger { } } + log(message: string): void { + this.line(message); + } + info(message: string): void { - this.log(message); + this.line(message); } success(message: string): void { - this.log(message); + this.stableMessages.push({ message, type: 'success' }); } warning(message: string): void { - this.log(message); + this.stableMessages.push({ message, type: 'warning' }); } error(message: string): void { - this.log(message); + this.stableMessages.push({ message, type: 'error' }); } } @@ -46,9 +58,11 @@ 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 { +export async function task(definitionOrLabel: TaskDefinition | string, callback?: (logger: Logger) => MaybePromise, limit = 10, keepSummary = false, subLabel?: string): Promise { const title = typeof definitionOrLabel === 'string' ? definitionOrLabel : definitionOrLabel.title; const run = typeof definitionOrLabel === 'string' ? callback : definitionOrLabel.task; + const summary = typeof definitionOrLabel === 'string' ? keepSummary : (definitionOrLabel.keepSummary ?? keepSummary); + const subtitle = typeof definitionOrLabel === 'string' ? subLabel : (definitionOrLabel.subLabel ?? subLabel); if (!run) { throw new Error('A task callback is required.'); @@ -56,7 +70,7 @@ export async function task(definitionOrLabel: TaskDefinition | string, cal const logger = new Logger(typeof definitionOrLabel === 'string' ? limit : (definitionOrLabel.limit ?? limit)); - promptEnvironment().output.write(`${title}${subLabel ? ` ${subLabel}` : ''}\n`); + promptEnvironment().output.write(`${title}${subtitle ? ` ${subtitle}` : ''}\n`); const result = await run(logger); @@ -64,6 +78,12 @@ export async function task(definitionOrLabel: TaskDefinition | string, cal promptEnvironment().output.write(`${line}\n`); } + if (summary) { + for (const message of logger.stableMessages) { + promptEnvironment().output.write(`${message.type}: ${message.message}\n`); + } + } + promptEnvironment().output.write(`Done: ${title}\n`); return result; diff --git a/packages/tui/tests/task.test.ts b/packages/tui/tests/task.test.ts index e16b5dd2..11ec17a4 100644 --- a/packages/tui/tests/task.test.ts +++ b/packages/tui/tests/task.test.ts @@ -37,4 +37,40 @@ describe('task helper', () => { expect(output.text()).toContain('beforeafter'); }); + + it('keeps stable task summaries when requested', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + await task( + 'Running...', + (logger) => { + logger.line('line one'); + logger.success('created'); + logger.warning('check this'); + logger.error('failed optional step'); + }, + 10, + true, + ); + }); + + expect(output.text()).toContain('line one'); + expect(output.text()).toContain('success: created'); + expect(output.text()).toContain('warning: check this'); + expect(output.text()).toContain('error: failed optional step'); + }); + + it('omits stable task summaries by default', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + await task('Running...', (logger) => { + logger.success('created'); + }); + }); + + expect(output.text()).not.toContain('success: created'); + expect(output.text()).toContain('Done: Running...'); + }); }); From eee070bd2d4d7691a836f2ab84081a8361c73674 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 15:54:45 +0800 Subject: [PATCH 056/675] Support task partial logging --- packages/tui/src/status/task.ts | 19 +++++++++++++++++++ packages/tui/tests/task.test.ts | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/packages/tui/src/status/task.ts b/packages/tui/src/status/task.ts index 3bcd3bfa..0cc597ed 100644 --- a/packages/tui/src/status/task.ts +++ b/packages/tui/src/status/task.ts @@ -20,6 +20,8 @@ type StableMessage = { export class Logger { readonly lines: string[] = []; readonly stableMessages: StableMessage[] = []; + #partialBuffer = ''; + #partialStartIndex: number | null = null; constructor(private readonly limit: number) {} @@ -37,6 +39,23 @@ export class Logger { this.line(message); } + partial(chunk: string): void { + this.#partialBuffer += chunk; + + if (this.#partialStartIndex === null) { + this.#partialStartIndex = this.lines.length; + } + + this.lines.splice(this.#partialStartIndex); + this.line(this.#partialBuffer); + this.#partialStartIndex = Math.min(this.#partialStartIndex, this.lines.length); + } + + commitPartial(): void { + this.#partialBuffer = ''; + this.#partialStartIndex = null; + } + info(message: string): void { this.line(message); } diff --git a/packages/tui/tests/task.test.ts b/packages/tui/tests/task.test.ts index 11ec17a4..7497b6b9 100644 --- a/packages/tui/tests/task.test.ts +++ b/packages/tui/tests/task.test.ts @@ -73,4 +73,37 @@ describe('task helper', () => { expect(output.text()).not.toContain('success: created'); expect(output.text()).toContain('Done: Running...'); }); + + it('accumulates partial task output until committed', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + await task('Running...', (logger) => { + logger.partial('Downloading '); + logger.partial('states'); + logger.commitPartial(); + logger.line('Done'); + }); + }); + + expect(output.text()).toContain('Downloading states'); + expect(output.text()).toContain('Done'); + expect(output.text()).not.toContain('Downloading \n'); + }); + + it('starts a new partial line after committing the previous partial', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + await task('Running...', (logger) => { + logger.partial('one'); + logger.commitPartial(); + logger.partial('two'); + logger.commitPartial(); + }); + }); + + expect(output.text()).toContain('one'); + expect(output.text()).toContain('two'); + }); }); From 085c28c80ce624f23bf813e5c748836d351dea87 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 15:56:47 +0800 Subject: [PATCH 057/675] Track stream output value --- packages/tui/src/status/stream.ts | 23 +++++++++++++++++ packages/tui/tests/stream.test.ts | 41 +++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) create mode 100644 packages/tui/tests/stream.test.ts diff --git a/packages/tui/src/status/stream.ts b/packages/tui/src/status/stream.ts index 7f6c2c31..a604e7f4 100644 --- a/packages/tui/src/status/stream.ts +++ b/packages/tui/src/status/stream.ts @@ -1,17 +1,40 @@ import { promptEnvironment } from '#tui/environment'; export class Stream { + #value = ''; + write(content: string): this { + this.#value += content; promptEnvironment().output.write(content); return this; } + append(content: string): this { + return this.write(content); + } + + close(): void { + // + } + + lines(): string[] { + return this.#value.split(/\r?\n/u); + } + async pipe(source: AsyncIterable | Iterable): Promise { for await (const chunk of source) { this.write(chunk); } } + + prompt(): never { + throw new Error('Stream cannot be prompted'); + } + + value(): string { + return this.#value; + } } export function stream(): Stream; diff --git a/packages/tui/tests/stream.test.ts b/packages/tui/tests/stream.test.ts new file mode 100644 index 00000000..a20c1100 --- /dev/null +++ b/packages/tui/tests/stream.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; +import { createMemoryOutput, stream, withPromptEnvironment } from '#tui/index'; + +describe('stream helper', () => { + it('appends streamed content and returns the accumulated value', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + const outputStream = stream(); + + outputStream.append('hello'); + outputStream.write(' world'); + outputStream.close(); + + expect(outputStream.value()).toBe('hello world'); + expect(outputStream.lines()).toEqual(['hello world']); + }); + + expect(output.text()).toBe('hello world'); + }); + + it('pipes iterable content through a stateful stream', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + await stream(['one', '\n', 'two']); + }); + + expect(output.text()).toBe('one\ntwo'); + }); + + it('cannot be prompted for input', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + const outputStream = stream(); + + expect(() => outputStream.prompt()).toThrow('Stream cannot be prompted'); + }); + }); +}); From 7dbb02c840a52689d7109b634049b2d1a6c94fc8 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 15:58:41 +0800 Subject: [PATCH 058/675] Support task logger labels --- packages/tui/src/status/task.ts | 23 +++++++++++++++++++--- packages/tui/tests/task.test.ts | 35 +++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/packages/tui/src/status/task.ts b/packages/tui/src/status/task.ts index 0cc597ed..f7777f59 100644 --- a/packages/tui/src/status/task.ts +++ b/packages/tui/src/status/task.ts @@ -18,12 +18,21 @@ type StableMessage = { }; export class Logger { + labelValue: string; readonly lines: string[] = []; readonly stableMessages: StableMessage[] = []; + subLabelValue: string; #partialBuffer = ''; #partialStartIndex: number | null = null; - constructor(private readonly limit: number) {} + constructor( + private readonly limit: number, + label: string, + subLabel = '', + ) { + this.labelValue = label; + this.subLabelValue = subLabel; + } line(message: string): void { for (const line of message.split(/\r?\n/u).filter((value) => value.length > 0)) { @@ -39,6 +48,14 @@ export class Logger { this.line(message); } + label(message: string): void { + this.labelValue = message; + } + + subLabel(message: string): void { + this.subLabelValue = message; + } + partial(chunk: string): void { this.#partialBuffer += chunk; @@ -87,7 +104,7 @@ export async function task(definitionOrLabel: TaskDefinition | string, cal throw new Error('A task callback is required.'); } - const logger = new Logger(typeof definitionOrLabel === 'string' ? limit : (definitionOrLabel.limit ?? limit)); + const logger = new Logger(typeof definitionOrLabel === 'string' ? limit : (definitionOrLabel.limit ?? limit), title, subtitle ?? ''); promptEnvironment().output.write(`${title}${subtitle ? ` ${subtitle}` : ''}\n`); @@ -103,7 +120,7 @@ export async function task(definitionOrLabel: TaskDefinition | string, cal } } - promptEnvironment().output.write(`Done: ${title}\n`); + promptEnvironment().output.write(`Done: ${logger.labelValue}${logger.subLabelValue ? ` ${logger.subLabelValue}` : ''}\n`); return result; } diff --git a/packages/tui/tests/task.test.ts b/packages/tui/tests/task.test.ts index 7497b6b9..59fe8603 100644 --- a/packages/tui/tests/task.test.ts +++ b/packages/tui/tests/task.test.ts @@ -106,4 +106,39 @@ describe('task helper', () => { expect(output.text()).toContain('one'); expect(output.text()).toContain('two'); }); + + it('updates task labels through the logger', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + await task('Running...', (logger) => { + logger.label('Building'); + logger.subLabel('assets'); + }); + }); + + expect(output.text()).toContain('Running...'); + expect(output.text()).toContain('Done: Building assets'); + }); + + it('clears task sub-labels through the logger', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + await task( + 'Running...', + (logger) => { + logger.label('Building'); + logger.subLabel(''); + }, + 10, + false, + 'assets', + ); + }); + + expect(output.text()).toContain('Running... assets'); + expect(output.text()).toContain('Done: Building'); + expect(output.text()).not.toContain('Done: Building assets'); + }); }); From c4f84e9450b623c26736a79e8b574f07b8b575a7 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 16:01:06 +0800 Subject: [PATCH 059/675] Map modifier key events --- packages/tui/src/key.ts | 25 +++++++++++++++++++++++++ packages/tui/tests/key.test.ts | 10 ++++++++++ 2 files changed, 35 insertions(+) diff --git a/packages/tui/src/key.ts b/packages/tui/src/key.ts index caec13b2..39b9f695 100644 --- a/packages/tui/src/key.ts +++ b/packages/tui/src/key.ts @@ -45,7 +45,32 @@ export type KeyboardEventLike = { shift?: boolean; }; +const ctrlKeyMap: Record = { + a: Key.ctrlA, + b: Key.ctrlB, + c: Key.ctrlC, + d: Key.ctrlD, + e: Key.ctrlE, + f: Key.ctrlF, + h: Key.ctrlH, + n: Key.ctrlN, + p: Key.ctrlP, + u: Key.ctrlU, +}; + export const keyFromEvent = (event: KeyboardEventLike): KeyName | string => { + if (event.ctrl && event.name) { + const key = ctrlKeyMap[event.name.toLowerCase()]; + + if (key !== undefined) { + return key; + } + } + + if (event.meta && event.name === 'backspace') { + return Key.optionBackspace; + } + if (event.name && event.name in Key) { const key = Key[event.name as keyof typeof Key]; diff --git a/packages/tui/tests/key.test.ts b/packages/tui/tests/key.test.ts index 3a2d3c27..66f6c958 100644 --- a/packages/tui/tests/key.test.ts +++ b/packages/tui/tests/key.test.ts @@ -10,4 +10,14 @@ describe('keyboard mapping', () => { expect(keyFromEvent({ sequence: ' ' })).toBe(Key.space); expect(keyFromEvent({ sequence: '\n' })).toBe(Key.enter); }); + + it('maps control-modified key names', () => { + expect(keyFromEvent({ ctrl: true, name: 'c' })).toBe(Key.ctrlC); + expect(keyFromEvent({ ctrl: true, name: 'n' })).toBe(Key.ctrlN); + expect(keyFromEvent({ ctrl: true, name: 'U' })).toBe(Key.ctrlU); + }); + + it('maps option backspace events', () => { + expect(keyFromEvent({ meta: true, name: 'backspace' })).toBe(Key.optionBackspace); + }); }); From b7a058ada1aaa05c1fec23cb0b32bf15229bd967 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 16:06:05 +0800 Subject: [PATCH 060/675] Align multisearch navigation keys --- packages/tui/src/prompts/search/read-multi.ts | 4 ++-- packages/tui/tests/search.test.ts | 17 +++++++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/tui/src/prompts/search/read-multi.ts b/packages/tui/src/prompts/search/read-multi.ts index 84284cd1..63b84ae4 100644 --- a/packages/tui/src/prompts/search/read-multi.ts +++ b/packages/tui/src/prompts/search/read-multi.ts @@ -69,7 +69,7 @@ export const readMultiSearchChoices = async (options: MultiSearchPromptOption return [...selected.keys()]; } - if (key === Key.down || key === Key.downArrow || key === Key.ctrlN || key === Key.tab) { + if (key === Key.down || key === Key.downArrow || key === Key.tab) { choices = await resolveSearchChoices(options.options, state.value); const currentChoices = displayedChoices(); @@ -79,7 +79,7 @@ export const readMultiSearchChoices = async (options: MultiSearchPromptOption continue; } - if (key === Key.up || key === Key.upArrow || key === Key.ctrlP || key === Key.shiftTab) { + if (key === Key.up || key === Key.upArrow || key === Key.shiftTab) { choices = await resolveSearchChoices(options.options, state.value); const currentChoices = displayedChoices(); diff --git a/packages/tui/tests/search.test.ts b/packages/tui/tests/search.test.ts index c6eb0f79..ad6705c3 100644 --- a/packages/tui/tests/search.test.ts +++ b/packages/tui/tests/search.test.ts @@ -278,6 +278,23 @@ describe('multisearch prompt', () => { expect(output.text()).toContain('Selected: Red, Green, Blue'); }); + it('does not navigate multisearch results with ctrl-n or ctrl-p', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.ctrlN, Key.space, Key.enter]), + output, + error: output, + interactive: true, + }, + () => multisearch({ message: 'Favorite colors?', options: colors }), + ); + + expect(result).toEqual([]); + expect(output.text()).not.toContain('Selected: Green'); + }); + it('keeps selected multisearch values visible after clearing the query', async () => { const output = createMemoryOutput(); From 87c630dafab1ef3743c51c245378ee6d65984e27 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 16:12:10 +0800 Subject: [PATCH 061/675] Clear suggest highlights on horizontal keys --- packages/tui/src/prompts/suggest/read.ts | 6 ++++++ packages/tui/tests/suggest.test.ts | 4 ++++ 2 files changed, 10 insertions(+) diff --git a/packages/tui/src/prompts/suggest/read.ts b/packages/tui/src/prompts/suggest/read.ts index b78a7394..806ca2c5 100644 --- a/packages/tui/src/prompts/suggest/read.ts +++ b/packages/tui/src/prompts/suggest/read.ts @@ -66,6 +66,12 @@ export const readSuggestionValue = async (options: SuggestOptions): Promise { expect(output).not.toContain('Favorite color? Blue'); }); + it('clears highlighted suggestions with horizontal navigation keys', async () => { + await expectSuggestion(['b', Key.down, Key.left, Key.enter], ['Red', 'Green', 'Blue'], 'b'); + }); + it('renders suggest info for the highlighted result', async () => { const output = createMemoryOutput(); From e2d12641caa78fdd5e4a3ac7e58cd82424434d2c Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 16:16:48 +0800 Subject: [PATCH 062/675] Align number prompt values --- packages/tui/src/form/builder/previous.ts | 4 +- packages/tui/src/form/builder/prompts.ts | 4 +- packages/tui/src/prompts/number.ts | 33 ++++-------- packages/tui/src/prompts/number/input.ts | 2 +- .../src/prompts/number/validators/value.ts | 39 ++++++++++++++ packages/tui/src/types.ts | 4 +- packages/tui/tests/number.test.ts | 54 ++++++++++++++++++- 7 files changed, 108 insertions(+), 32 deletions(-) create mode 100644 packages/tui/src/prompts/number/validators/value.ts diff --git a/packages/tui/src/form/builder/previous.ts b/packages/tui/src/form/builder/previous.ts index c2449320..fff4ecf8 100644 --- a/packages/tui/src/form/builder/previous.ts +++ b/packages/tui/src/form/builder/previous.ts @@ -2,8 +2,8 @@ export const previousString = (previous: unknown, defaultValue: string): string return previous === undefined || previous === null ? defaultValue : String(previous); }; -export const previousNumber = (previous: unknown, defaultValue: number): number => { - return typeof previous === 'number' ? previous : defaultValue; +export const previousNumber = (previous: unknown, defaultValue: number | string): number | string => { + return typeof previous === 'number' || typeof previous === 'string' ? previous : defaultValue; }; export const previousArray = (previous: unknown, defaultValue: T[]): T[] => { diff --git a/packages/tui/src/form/builder/prompts.ts b/packages/tui/src/form/builder/prompts.ts index e405e894..4907168f 100644 --- a/packages/tui/src/form/builder/prompts.ts +++ b/packages/tui/src/form/builder/prompts.ts @@ -44,9 +44,9 @@ export type PromptBuilderMethods = { this: FormBuilder, label: string, placeholder?: string, - defaultValue?: number, + defaultValue?: number | string, required?: boolean | string, - validate?: (value: number) => MaybePromise, + validate?: (value: number | string) => MaybePromise, hint?: string, min?: number, max?: number, diff --git a/packages/tui/src/prompts/number.ts b/packages/tui/src/prompts/number.ts index 1b7557b4..c5d49520 100644 --- a/packages/tui/src/prompts/number.ts +++ b/packages/tui/src/prompts/number.ts @@ -1,35 +1,33 @@ import { promptUntilValid, PromptValidationError } from '#tui/prompt'; import { readNumberValue } from '#tui/prompts/number/input'; +import { parseNumberInput } from '#tui/prompts/number/validators/value'; import type { NumberPromptOptions } from '#tui/types'; -const integerPattern = /^[+-]?\d+$/u; -const numberPattern = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?$/iu; - -export function number(options: NumberPromptOptions): Promise; +export function number(options: NumberPromptOptions): Promise; export function number( label: string, placeholder?: string, - defaultValue?: number, + defaultValue?: number | string, required?: boolean | string, validate?: NumberPromptOptions['validate'], hint?: string, min?: number, max?: number, step?: number, -): Promise; +): Promise; export async function number( message: string | NumberPromptOptions, _placeholder = '', - defaultValue: number | undefined = undefined, + defaultValue: number | string | undefined = undefined, required: boolean | string = false, validate: NumberPromptOptions['validate'] = undefined, hint = '', min: number | undefined = undefined, max: number | undefined = undefined, step: number | undefined = undefined, -): Promise { +): Promise { const options: NumberPromptOptions = typeof message === 'string' ? { message, label: message, default: defaultValue, required, validate, hint, min, max, step } : { ...message }; return promptUntilValid(options, async () => { @@ -45,23 +43,12 @@ export async function number( return options.default; } - const normalized = answer.trim(); - const valid = options.integer ? integerPattern.test(normalized) : numberPattern.test(normalized); - - if (!valid) { - throw new PromptValidationError('Please enter a valid number.'); - } - - const parsed = options.integer ? Number.parseInt(normalized, 10) : Number(normalized); - - if (options.min !== undefined && parsed < options.min) { - throw new PromptValidationError(`Please enter a value greater than or equal to ${options.min}.`); - } + const result = parseNumberInput(answer, options); - if (options.max !== undefined && parsed > options.max) { - throw new PromptValidationError(`Please enter a value less than or equal to ${options.max}.`); + if (result.error !== undefined) { + throw new PromptValidationError(result.error); } - return options.transform ? options.transform(parsed) : parsed; + return options.transform ? options.transform(result.value ?? '') : (result.value ?? ''); }); } diff --git a/packages/tui/src/prompts/number/input.ts b/packages/tui/src/prompts/number/input.ts index 5bdb09ff..3a9b91f3 100644 --- a/packages/tui/src/prompts/number/input.ts +++ b/packages/tui/src/prompts/number/input.ts @@ -5,7 +5,7 @@ import { renderQuestion } from '#tui/theme'; import { applyTypedKey } from '#tui/typed-value'; type NumberInputOptions = { - default?: number; + default?: number | string; hint?: string; max?: number; min?: number; diff --git a/packages/tui/src/prompts/number/validators/value.ts b/packages/tui/src/prompts/number/validators/value.ts new file mode 100644 index 00000000..8665b44d --- /dev/null +++ b/packages/tui/src/prompts/number/validators/value.ts @@ -0,0 +1,39 @@ +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 integerPattern = /^[+-]?\d+$/u; + +const isNumeric = (value: string): boolean => value.trim() !== '' && Number.isFinite(Number(value)); + +export const parseNumberInput = (input: unknown, options: Pick = {}): NumberValidationResult => { + const raw = numericInputSchema.parse(input); + const normalized = raw.trim(); + + if (normalized === '') { + return { value: '' }; + } + + if (!isNumeric(normalized) || (options.integer === true && !integerPattern.test(normalized))) { + return { error: 'Must be a number' }; + } + + const numeric = Number(normalized); + + 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: Math.trunc(numeric) }; +}; diff --git a/packages/tui/src/types.ts b/packages/tui/src/types.ts index 703efe20..7057213d 100644 --- a/packages/tui/src/types.ts +++ b/packages/tui/src/types.ts @@ -48,13 +48,13 @@ export type TextPromptOptions = BasePromptOptions & { transform?: (value: string) => MaybePromise; }; -export type NumberPromptOptions = BasePromptOptions & { +export type NumberPromptOptions = BasePromptOptions & { label?: string; min?: number; max?: number; integer?: boolean; step?: number; - transform?: (value: number) => MaybePromise; + transform?: (value: number | string) => MaybePromise; }; export type ConfirmPromptOptions = BasePromptOptions & { diff --git a/packages/tui/tests/number.test.ts b/packages/tui/tests/number.test.ts index 2f6fb568..79913cf0 100644 --- a/packages/tui/tests/number.test.ts +++ b/packages/tui/tests/number.test.ts @@ -16,7 +16,7 @@ describe('number prompt', () => { ); expect(result).toBe(42); - expect(output.text()).toContain('Please enter a valid number.'); + expect(output.text()).toContain('Must be a number'); }); it('rejects decimal values for integer prompts', async () => { @@ -33,7 +33,57 @@ describe('number prompt', () => { ); expect(result).toBe(2); - expect(output.text()).toContain('Please enter a valid number.'); + expect(output.text()).toContain('Must be a number'); + }); + + it('returns an empty string for optional empty input', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.enter]), + output, + error: output, + interactive: true, + }, + () => number({ message: 'Count' }), + ); + + expect(result).toBe(''); + }); + + it('coerces decimal input to an integer value', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['1', '.', '9', Key.enter]), + output, + error: output, + interactive: true, + }, + () => number({ message: 'Count' }), + ); + + expect(result).toBe(1); + }); + + it('validates typed values against min and max bounds', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['1', Key.enter, '5', Key.enter, '3', Key.enter]), + output, + error: output, + interactive: true, + }, + () => number({ message: 'Count', min: 2, max: 4 }), + ); + + expect(result).toBe(3); + expect(output.text()).toContain('Must be at least 2'); + expect(output.text()).toContain('Must be less than 4'); }); it('increments and decrements with arrow keys', async () => { From 89f20f603eb1da6dd609c4efb9c9dbcf612e10ef Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 16:19:09 +0800 Subject: [PATCH 063/675] Align required prompt validation --- packages/tui/src/prompt.ts | 9 ++------- packages/tui/src/validators/required.ts | 17 +++++++++++++++++ packages/tui/tests/choices.test.ts | 19 ++++++++++++++++++- packages/tui/tests/number.test.ts | 17 +++++++++++++++++ packages/tui/tests/prompt.test.ts | 21 +++++++++++++++++++++ 5 files changed, 75 insertions(+), 8 deletions(-) create mode 100644 packages/tui/src/validators/required.ts create mode 100644 packages/tui/tests/prompt.test.ts diff --git a/packages/tui/src/prompt.ts b/packages/tui/src/prompt.ts index 0a9d259f..a9bf4c8a 100644 --- a/packages/tui/src/prompt.ts +++ b/packages/tui/src/prompt.ts @@ -1,5 +1,6 @@ import { promptEnvironment } from '#tui/environment'; import { renderError, renderQuestion } from '#tui/theme'; +import { requiredMessage } from '#tui/validators/required'; import type { BasePromptOptions, Validator } from '#tui/types'; export class PromptValidationError extends Error { @@ -24,13 +25,7 @@ export const validationMessage = async (value: T, validator?: Validator): }; export const ensureRequired = (value: T, required?: boolean | string): string | undefined => { - const empty = value === undefined || value === null || value === '' || (Array.isArray(value) && value.length === 0); - - if (!empty || !required) { - return undefined; - } - - return typeof required === 'string' ? required : 'A value is required.'; + return requiredMessage(value, required); }; export const promptUntilValid = async (options: BasePromptOptions, read: (attempt: number) => Promise): Promise => { diff --git a/packages/tui/src/validators/required.ts b/packages/tui/src/validators/required.ts new file mode 100644 index 00000000..b9d643a1 --- /dev/null +++ b/packages/tui/src/validators/required.ts @@ -0,0 +1,17 @@ +import { z } from 'zod'; + +const requiredOptionSchema = z.union([z.boolean(), z.string()]).optional(); + +const isInvalidRequiredValue = (value: unknown): boolean => { + return value === '' || value === false || value === null || value === undefined || (Array.isArray(value) && value.length === 0); +}; + +export const requiredMessage = (value: unknown, required?: boolean | string): string | undefined => { + const parsed = requiredOptionSchema.parse(required); + + if (parsed === false || parsed === undefined || !isInvalidRequiredValue(value)) { + return undefined; + } + + return typeof parsed === 'string' && parsed.length > 0 ? parsed : 'Required.'; +}; diff --git a/packages/tui/tests/choices.test.ts b/packages/tui/tests/choices.test.ts index a8d6410b..d3e8cc0a 100644 --- a/packages/tui/tests/choices.test.ts +++ b/packages/tui/tests/choices.test.ts @@ -47,6 +47,23 @@ describe('choice prompts', () => { expect(output.text()).toContain('[No]'); }); + it('treats false confirm answers as invalid when required', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['n', Key.enter, 'y', Key.enter]), + output, + error: output, + interactive: true, + }, + () => confirm('Continue?', true, 'Yes', 'No', true), + ); + + expect(result).toBe(true); + expect(output.text()).toContain('Required.'); + }); + it('selects with arrow keys and enter', async () => { const output = createMemoryOutput(); @@ -201,7 +218,7 @@ describe('choice prompts', () => { ); expect(result).toEqual(['first']); - expect(output.text()).toContain('A value is required.'); + expect(output.text()).toContain('Required.'); }); it('toggles all multiselect choices with ctrl-a', async () => { diff --git a/packages/tui/tests/number.test.ts b/packages/tui/tests/number.test.ts index 79913cf0..5e717bf1 100644 --- a/packages/tui/tests/number.test.ts +++ b/packages/tui/tests/number.test.ts @@ -52,6 +52,23 @@ describe('number prompt', () => { expect(result).toBe(''); }); + it('requires number input when configured', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.enter, '4', Key.enter]), + output, + error: output, + interactive: true, + }, + () => number({ message: 'Count', required: true }), + ); + + expect(result).toBe(4); + expect(output.text()).toContain('Required.'); + }); + it('coerces decimal input to an integer value', async () => { const output = createMemoryOutput(); diff --git a/packages/tui/tests/prompt.test.ts b/packages/tui/tests/prompt.test.ts new file mode 100644 index 00000000..310671ed --- /dev/null +++ b/packages/tui/tests/prompt.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; +import { ensureRequired } from '#tui/prompt'; + +describe('prompt validation', () => { + it('uses the default required message for empty values', () => { + expect(ensureRequired('', true)).toBe('Required.'); + expect(ensureRequired([], true)).toBe('Required.'); + expect(ensureRequired(false, true)).toBe('Required.'); + expect(ensureRequired(null, true)).toBe('Required.'); + }); + + it('uses custom required messages and treats empty strings as required configuration', () => { + expect(ensureRequired('', 'Choose something.')).toBe('Choose something.'); + expect(ensureRequired('', '')).toBe('Required.'); + }); + + it('ignores required validation when explicitly disabled', () => { + expect(ensureRequired('', false)).toBeUndefined(); + expect(ensureRequired('', undefined)).toBeUndefined(); + }); +}); From dc60909cd79cc1f3bbdee6b8c3a910897cb7dcd7 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 16:21:19 +0800 Subject: [PATCH 064/675] Align validator result handling --- packages/tui/src/form/builder/prompts.ts | 8 ++++---- packages/tui/src/prompt.ts | 9 +++------ packages/tui/src/types.ts | 2 +- packages/tui/src/validators/result.ts | 14 ++++++++++++++ packages/tui/tests/prompt.test.ts | 14 +++++++++++++- 5 files changed, 35 insertions(+), 12 deletions(-) create mode 100644 packages/tui/src/validators/result.ts diff --git a/packages/tui/src/form/builder/prompts.ts b/packages/tui/src/form/builder/prompts.ts index 4907168f..62d86712 100644 --- a/packages/tui/src/form/builder/prompts.ts +++ b/packages/tui/src/form/builder/prompts.ts @@ -24,7 +24,7 @@ export type PromptBuilderMethods = { yes?: string, no?: string, required?: boolean | string, - validate?: (value: boolean) => MaybePromise, + validate?: (value: boolean) => MaybePromise, hint?: string, name?: string, ): FormBuilder; @@ -36,7 +36,7 @@ export type PromptBuilderMethods = { defaultValue?: T[], scroll?: number, required?: boolean | string, - validate?: (value: T[]) => MaybePromise, + validate?: (value: T[]) => MaybePromise, hint?: string, name?: string, ): FormBuilder; @@ -46,7 +46,7 @@ export type PromptBuilderMethods = { placeholder?: string, defaultValue?: number | string, required?: boolean | string, - validate?: (value: number | string) => MaybePromise, + validate?: (value: number | string) => MaybePromise, hint?: string, min?: number, max?: number, @@ -71,7 +71,7 @@ export type PromptBuilderMethods = { options: Array>, defaultValue?: T, scroll?: number, - validate?: (value: T) => MaybePromise, + validate?: (value: T) => MaybePromise, hint?: string, required?: boolean | string, name?: string, diff --git a/packages/tui/src/prompt.ts b/packages/tui/src/prompt.ts index a9bf4c8a..665e9d86 100644 --- a/packages/tui/src/prompt.ts +++ b/packages/tui/src/prompt.ts @@ -1,6 +1,7 @@ import { promptEnvironment } from '#tui/environment'; import { renderError, renderQuestion } from '#tui/theme'; import { requiredMessage } from '#tui/validators/required'; +import { parseValidationResult } from '#tui/validators/result'; import type { BasePromptOptions, Validator } from '#tui/types'; export class PromptValidationError extends Error { @@ -11,16 +12,12 @@ export class PromptValidationError extends Error { } export const validationMessage = async (value: T, validator?: Validator): Promise => { - const result = await validator?.(value); + const result = parseValidationResult(await validator?.(value)); - if (typeof result === 'string') { + if (result !== undefined && result !== null && result.length > 0) { return result; } - if (result === false) { - return 'The given value is invalid.'; - } - return undefined; }; diff --git a/packages/tui/src/types.ts b/packages/tui/src/types.ts index 7057213d..40e487fc 100644 --- a/packages/tui/src/types.ts +++ b/packages/tui/src/types.ts @@ -1,6 +1,6 @@ export type MaybePromise = T | Promise; -export type ValidationResult = string | false | null | undefined; +export type ValidationResult = string | null | undefined; export type Validator = (value: T) => MaybePromise; diff --git a/packages/tui/src/validators/result.ts b/packages/tui/src/validators/result.ts new file mode 100644 index 00000000..5fdd9ac9 --- /dev/null +++ b/packages/tui/src/validators/result.ts @@ -0,0 +1,14 @@ +import { z } from 'zod'; +import type { ValidationResult } from '#tui/types'; + +const validationResultSchema = z.union([z.string(), z.null(), z.undefined()]); + +export const parseValidationResult = (result: unknown): ValidationResult => { + const parsed = validationResultSchema.safeParse(result); + + if (!parsed.success) { + throw new Error('The validator must return a string or null.'); + } + + return parsed.data; +}; diff --git a/packages/tui/tests/prompt.test.ts b/packages/tui/tests/prompt.test.ts index 310671ed..be0cfaae 100644 --- a/packages/tui/tests/prompt.test.ts +++ b/packages/tui/tests/prompt.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { ensureRequired } from '#tui/prompt'; +import { ensureRequired, validationMessage } from '#tui/prompt'; describe('prompt validation', () => { it('uses the default required message for empty values', () => { @@ -18,4 +18,16 @@ describe('prompt validation', () => { expect(ensureRequired('', false)).toBeUndefined(); expect(ensureRequired('', undefined)).toBeUndefined(); }); + + it('accepts null, undefined, and empty string validator results', async () => { + await expect(validationMessage('value', () => null)).resolves.toBeUndefined(); + + await expect(validationMessage('value', () => undefined)).resolves.toBeUndefined(); + + await expect(validationMessage('value', () => '')).resolves.toBeUndefined(); + }); + + it('rejects invalid validator results', async () => { + await expect(validationMessage('value', () => false as never)).rejects.toThrow('The validator must return a string or null.'); + }); }); From d8e091939c22515749ae1a2f665bf06e85718c68 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 16:24:03 +0800 Subject: [PATCH 065/675] Add form step reversal --- packages/tui/src/form/builder/conditions.ts | 13 ++++++ packages/tui/src/form/builder/index.ts | 36 ++++++++++++++-- packages/tui/src/form/builder/revert.ts | 48 +++++++++++++++++++++ packages/tui/tests/form.test.ts | 34 +++++++++++++++ 4 files changed, 127 insertions(+), 4 deletions(-) create mode 100644 packages/tui/src/form/builder/conditions.ts create mode 100644 packages/tui/src/form/builder/revert.ts diff --git a/packages/tui/src/form/builder/conditions.ts b/packages/tui/src/form/builder/conditions.ts new file mode 100644 index 00000000..297014f1 --- /dev/null +++ b/packages/tui/src/form/builder/conditions.ts @@ -0,0 +1,13 @@ +import type { FormResponses, FormStep } from '#tui/form/types'; + +export const shouldRunStep = (step: FormStep, responses: FormResponses): boolean => { + return typeof step.condition === 'function' ? step.condition(responses) : step.condition; +}; + +export const shouldIgnoreStepWhenReverting = (step: FormStep, responses: FormResponses): boolean => { + if (!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 index 60041049..02507bbd 100644 --- a/packages/tui/src/form/builder/index.ts +++ b/packages/tui/src/form/builder/index.ts @@ -1,6 +1,8 @@ import { outputBuilderMethods } from '#tui/form/builder/output'; import { promptBuilderMethods } from '#tui/form/builder/prompts'; +import { FormRevertedError, runWithFormRevert } from '#tui/form/builder/revert'; import { statusBuilderMethods } from '#tui/form/builder/status'; +import { shouldIgnoreStepWhenReverting, shouldRunStep } from '#tui/form/builder/conditions'; import type { FormResponses, FormStep } from '#tui/form/types'; import type { MaybePromise } from '#tui/types'; @@ -61,16 +63,42 @@ export class FormBuilder { } async submit(): Promise { - for (const [index, step] of this.#steps.entries()) { + let index = 0; + let wasReverted = false; + + while (index < this.#steps.length) { + const step = this.#steps[index]; + + if (step === undefined) { + break; + } + + if (wasReverted && index > 0 && shouldIgnoreStepWhenReverting(step, this.#responses)) { + index -= 1; + continue; + } + + wasReverted = false; + const key = step.name ?? index; - const shouldRun = typeof step.condition === 'function' ? step.condition(this.#responses) : step.condition; - if (!shouldRun) { + if (!shouldRunStep(step, this.#responses)) { this.#responses[key] = null; + index += 1; continue; } - this.#responses[key] = await step.run(this.#responses, this.#responses[key], step.name); + try { + this.#responses[key] = await runWithFormRevert(index > 0, () => step.run(this.#responses, this.#responses[key], step.name)); + } catch (error) { + if (!(error instanceof FormRevertedError)) { + throw error; + } + + wasReverted = true; + } + + index += wasReverted ? -1 : 1; } return this.#responses; diff --git a/packages/tui/src/form/builder/revert.ts b/packages/tui/src/form/builder/revert.ts new file mode 100644 index 00000000..400a0b89 --- /dev/null +++ b/packages/tui/src/form/builder/revert.ts @@ -0,0 +1,48 @@ +import { configurePrompts, promptEnvironment } from '#tui/environment'; +import { Key } from '#tui/key'; +import { renderError } from '#tui/theme'; +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(); + } + + environment.error.write(renderError('This cannot be reverted.')); + } + }, + }; + + configurePrompts({ input }); + + try { + return await callback(); + } finally { + configurePrompts({ input: previousInput }); + } +}; diff --git a/packages/tui/tests/form.test.ts b/packages/tui/tests/form.test.ts index a86cca40..92c31aa4 100644 --- a/packages/tui/tests/form.test.ts +++ b/packages/tui/tests/form.test.ts @@ -64,6 +64,40 @@ describe('form builder', () => { expect(responses.details).toBeNull(); }); + it('reverts to the previous form step and reuses the prior response', async () => { + const output = createMemoryOutput(); + + const responses = await withPromptEnvironment( + { + input: createScriptedInput(['A', Key.enter, 'B', Key.ctrlU, Key.backspace, 'C', Key.enter, 'D', Key.enter]), + output, + error: output, + interactive: true, + }, + () => form().text('First').text('Second').submit(), + ); + + expect(responses[0]).toBe('C'); + expect(responses[1]).toBe('D'); + }); + + it('does not revert the first form step', async () => { + const output = createMemoryOutput(); + + const responses = await withPromptEnvironment( + { + input: createScriptedInput([Key.ctrlU, 'A', Key.enter]), + output, + error: output, + interactive: true, + }, + () => form().text('First').submit(), + ); + + expect(responses[0]).toBe('A'); + expect(output.text()).toContain('This cannot be reverted.'); + }); + it('runs suggest, search, and multisearch form steps', async () => { const output = createMemoryOutput(); From 1fe7d9011ef5751e47db03ac3f12f21570bc2d72 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 16:26:29 +0800 Subject: [PATCH 066/675] Align autocomplete prompt rendering --- .../tui/src/prompts/suggest/read-autocomplete.ts | 12 ++++++------ .../src/prompts/suggest/render-autocomplete.ts | 13 +++++++++---- packages/tui/tests/suggest.test.ts | 16 ++++++++++++++++ 3 files changed, 31 insertions(+), 10 deletions(-) diff --git a/packages/tui/src/prompts/suggest/read-autocomplete.ts b/packages/tui/src/prompts/suggest/read-autocomplete.ts index e9cbb65c..fda9aace 100644 --- a/packages/tui/src/prompts/suggest/read-autocomplete.ts +++ b/packages/tui/src/prompts/suggest/read-autocomplete.ts @@ -21,7 +21,7 @@ export const readAutocompleteValue = async (options: SuggestOptions): Promise { return match.toLowerCase().startsWith(value.toLowerCase()); }; -const completionValue = (value: string, match: string | undefined): string => { - if (match === undefined || value.length === 0 || !startsWithInput(match, value) || match.length <= value.length) { +const completionValue = (value: string, match: string | undefined, placeholder = ''): string => { + if (value.length === 0) { + return placeholder; + } + + if (match === undefined || !startsWithInput(match, value) || match.length <= value.length) { return value; } return value + match.slice(value.length); }; -export const renderAutocomplete = (message: string, value: string, matches: string[], highlighted: number): void => { - promptEnvironment().output.write(`${message} ${completionValue(value, matches[highlighted])}\n`); +export const renderAutocomplete = (message: string, value: string, matches: string[], highlighted: number, hint = '', placeholder = ''): void => { + promptEnvironment().output.write(`${renderQuestion(message, hint)}${completionValue(value, matches[highlighted], placeholder)}\n`); }; diff --git a/packages/tui/tests/suggest.test.ts b/packages/tui/tests/suggest.test.ts index 38e231ae..295bbd27 100644 --- a/packages/tui/tests/suggest.test.ts +++ b/packages/tui/tests/suggest.test.ts @@ -97,6 +97,22 @@ describe('suggest prompt', () => { }); describe('autocomplete prompt', () => { + it('renders autocomplete prompts with question formatting, hints, and placeholders', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment( + { + input: createScriptedInput([Key.enter]), + output, + error: output, + interactive: true, + }, + () => autocomplete('Favorite color?', ['Red', 'Green', 'Blue'], 'Type a color', '', false, undefined, 'Optional'), + ); + + expect(output.text()).toContain('? Favorite color? Optional Type a color'); + }); + it('renders autocomplete ghost text without a suggestion list', async () => { const output = createMemoryOutput(); From f666266297fcc787e20037d57e847c9cfa743c6c Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 16:29:33 +0800 Subject: [PATCH 067/675] Add choice prompt transforms --- packages/tui/src/form/builder/prompts.ts | 14 +++-- packages/tui/src/prompts/search/index.ts | 8 ++- packages/tui/src/prompts/select/confirm.ts | 20 ++++++-- packages/tui/src/prompts/select/index.ts | 10 +++- packages/tui/src/types.ts | 5 ++ packages/tui/tests/choices.test.ts | 60 ++++++++++++++++++++++ packages/tui/tests/search.test.ts | 44 ++++++++++++++++ 7 files changed, 149 insertions(+), 12 deletions(-) diff --git a/packages/tui/src/form/builder/prompts.ts b/packages/tui/src/form/builder/prompts.ts index 62d86712..9e8854ff 100644 --- a/packages/tui/src/form/builder/prompts.ts +++ b/packages/tui/src/form/builder/prompts.ts @@ -27,6 +27,7 @@ export type PromptBuilderMethods = { validate?: (value: boolean) => MaybePromise, hint?: string, name?: string, + transform?: (value: boolean) => MaybePromise, ): FormBuilder; multisearch(this: FormBuilder, options: MultiSearchPromptOptions, name?: string): FormBuilder; multiselect( @@ -39,6 +40,7 @@ export type PromptBuilderMethods = { validate?: (value: T[]) => MaybePromise, hint?: string, name?: string, + transform?: (value: T[]) => MaybePromise, ): FormBuilder; number( this: FormBuilder, @@ -75,6 +77,7 @@ export type PromptBuilderMethods = { hint?: string, required?: boolean | string, name?: string, + transform?: (value: T) => MaybePromise, ): FormBuilder; suggest( this: FormBuilder, @@ -117,8 +120,8 @@ export const promptBuilderMethods: PromptBuilderMethods & ThisType autocomplete(label, options, defaultValue = '', required = false, validate = undefined, hint = '', name, transform = undefined) { return this.add((_, previous) => autocomplete({ message: label, label, options, default: previousString(previous, defaultValue), required, validate, hint, transform }), name); }, - confirm(label, defaultValue = true, yes = 'Yes', no = 'No', required = false, validate = undefined, hint = '', name) { - return this.add(() => confirm(label, defaultValue, yes, no, required, validate, hint), name); + confirm(label, defaultValue = true, yes = 'Yes', no = 'No', required = false, validate = undefined, hint = '', name, transform = undefined) { + return this.add(() => confirm(label, defaultValue, yes, no, required, validate, hint, transform), name); }, multisearch(options: MultiSearchPromptOptions, name?: string) { return this.add(() => multisearch(options), name); @@ -132,8 +135,9 @@ export const promptBuilderMethods: PromptBuilderMethods & ThisType validate = undefined, hint = 'Use the space bar to select options.', name?: string, + transform = undefined, ) { - return this.add((_, previous) => multiselect({ message: label, options, default: previousArray(previous, defaultValue), scroll, required, validate, hint }), name); + return this.add((_, previous) => multiselect({ message: label, options, default: previousArray(previous, defaultValue), scroll, required, validate, hint, transform }), name); }, number(label, placeholder = '', defaultValue = 0, required = false, validate = undefined, hint = '', min, max, step, name) { return this.add((_, previous) => number(label, placeholder, previousNumber(previous, defaultValue), required, validate, hint, min, max, step), name); @@ -147,8 +151,8 @@ export const promptBuilderMethods: PromptBuilderMethods & ThisType search(options: SearchPromptOptions, name?: string) { return this.add(() => search(options), name); }, - select(label: string, options: Array>, defaultValue?: T, scroll = 5, validate = undefined, hint = '', required: boolean | string = true, name?: string) { - return this.add((_, previous) => select({ message: label, options, default: previous === undefined ? defaultValue : (previous as T), scroll, validate, hint, required }), name); + select(label: string, options: Array>, defaultValue?: T, scroll = 5, validate = undefined, hint = '', required: boolean | string = true, name?: string, transform = undefined) { + return this.add((_, previous) => select({ message: label, options, default: previous === undefined ? defaultValue : (previous as T), scroll, validate, hint, required, transform }), name); }, suggest(label, options, defaultValue = '', scroll = 5, required = false, validate = undefined, hint = '', name, transform = undefined) { return this.add((_, previous) => suggest({ message: label, label, options, default: previousString(previous, defaultValue), scroll, required, validate, hint, transform }), name); diff --git a/packages/tui/src/prompts/search/index.ts b/packages/tui/src/prompts/search/index.ts index 223f6859..4f52d7f5 100644 --- a/packages/tui/src/prompts/search/index.ts +++ b/packages/tui/src/prompts/search/index.ts @@ -14,10 +14,14 @@ export const search = async (options: SearchPromptOptions): Promise => throw new PromptValidationError('Please select a valid option.'); } - return selected; + return options.transform ? options.transform(selected) : selected; }); }; export const multisearch = async (options: MultiSearchPromptOptions): Promise => { - return promptUntilValid(options, async () => readMultiSearchChoices(options)); + return promptUntilValid(options, async () => { + const selected = await readMultiSearchChoices(options); + + return options.transform ? options.transform(selected) : selected; + }); }; diff --git a/packages/tui/src/prompts/select/confirm.ts b/packages/tui/src/prompts/select/confirm.ts index 4d44e720..2f984574 100644 --- a/packages/tui/src/prompts/select/confirm.ts +++ b/packages/tui/src/prompts/select/confirm.ts @@ -4,7 +4,16 @@ 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): 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, @@ -14,9 +23,14 @@ export async function confirm( required: boolean | string = false, validate: ConfirmPromptOptions['validate'] = undefined, hint = '', + transform: ConfirmPromptOptions['transform'] = undefined, ): Promise { const options: ConfirmPromptOptions = - typeof message === 'string' ? { message, label: message, default: defaultValue, yes, no, required, validate, hint } : { ...message, default: message.default ?? true }; + typeof message === 'string' ? { message, label: message, default: defaultValue, yes, no, required, validate, hint, transform } : { ...message, default: message.default ?? true }; - return promptUntilValid(options, async () => readConfirm(options)); + return promptUntilValid(options, async () => { + const value = await readConfirm(options); + + return options.transform ? options.transform(value) : value; + }); } diff --git a/packages/tui/src/prompts/select/index.ts b/packages/tui/src/prompts/select/index.ts index 47684b1f..52f9c3f9 100644 --- a/packages/tui/src/prompts/select/index.ts +++ b/packages/tui/src/prompts/select/index.ts @@ -10,18 +10,24 @@ export const select = async (options: SelectPromptOptions): Promise => const choices = normalizeChoices(options.options); return promptUntilValid(options, async () => { - return readSelectedChoice(options.message, choices, options.default, options.hint, options.scroll, options.info).catch((error: unknown) => { + const selected = await readSelectedChoice(options.message, choices, options.default, options.hint, options.scroll, options.info).catch((error: unknown) => { if (options.default !== undefined && error instanceof PromptValidationError) { return options.default; } throw error; }); + + return options.transform ? options.transform(selected) : selected; }); }; export const multiselect = async (options: MultiSelectPromptOptions): Promise => { const choices = normalizeChoices(options.options); - return promptUntilValid(options, async () => readMultipleChoices(options.message, choices, options.default, options.hint, options.scroll, options.info)); + return promptUntilValid(options, async () => { + const selected = await readMultipleChoices(options.message, choices, options.default, options.hint, options.scroll, options.info); + + return options.transform ? options.transform(selected) : selected; + }); }; diff --git a/packages/tui/src/types.ts b/packages/tui/src/types.ts index 40e487fc..940565e2 100644 --- a/packages/tui/src/types.ts +++ b/packages/tui/src/types.ts @@ -61,6 +61,7 @@ export type ConfirmPromptOptions = BasePromptOptions & { label?: string; yes?: string; no?: string; + transform?: (value: boolean) => MaybePromise; }; export type SelectPromptOptions = BasePromptOptions & { @@ -68,6 +69,7 @@ export type SelectPromptOptions = BasePromptOptions & { options: Array>; scroll?: number; info?: PromptInfo; + transform?: (value: T) => MaybePromise; }; export type MultiSelectPromptOptions = BasePromptOptions & { @@ -75,6 +77,7 @@ export type MultiSelectPromptOptions = BasePromptOptions & { options: Array>; scroll?: number; info?: PromptInfo; + transform?: (value: T[]) => MaybePromise; }; export type SearchPromptOptions = Omit, 'required'> & { @@ -84,6 +87,7 @@ export type SearchPromptOptions = Omit, 'required'> & { required?: true | string; scroll?: number; info?: PromptInfo; + transform?: (value: T) => MaybePromise; }; export type MultiSearchPromptOptions = BasePromptOptions & { @@ -92,6 +96,7 @@ export type MultiSearchPromptOptions = BasePromptOptions & { placeholder?: string; scroll?: number; info?: PromptInfo; + transform?: (value: T[]) => MaybePromise; }; export type TableCell = string | number | boolean | null | undefined; diff --git a/packages/tui/tests/choices.test.ts b/packages/tui/tests/choices.test.ts index d3e8cc0a..59d55c69 100644 --- a/packages/tui/tests/choices.test.ts +++ b/packages/tui/tests/choices.test.ts @@ -64,6 +64,22 @@ describe('choice prompts', () => { expect(output.text()).toContain('Required.'); }); + it('transforms confirm answers before returning', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['y', Key.enter]), + output, + error: output, + interactive: true, + }, + () => confirm('Continue?', true, 'Yes', 'No', false, undefined, '', (value) => !value), + ); + + expect(result).toBe(false); + }); + it('selects with arrow keys and enter', async () => { const output = createMemoryOutput(); @@ -169,6 +185,28 @@ describe('choice prompts', () => { expect(output.text()).toContain('About second'); }); + it('transforms selected values before validation and return', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.enter]), + output, + error: output, + interactive: true, + }, + () => + select({ + message: 'Pick one', + options: ['first', 'second'], + transform: (value) => value.toUpperCase(), + validate: (value) => (value === 'FIRST' ? null : 'Unexpected value.'), + }), + ); + + expect(result).toBe('FIRST'); + }); + it('toggles multiselect choices with the space bar', async () => { const output = createMemoryOutput(); @@ -287,4 +325,26 @@ describe('choice prompts', () => { expect(output.text()).toContain('About first · 0 selected'); }); + + it('transforms multiselect values before validation and return', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.space, Key.down, Key.space, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + multiselect({ + message: 'Pick many', + options: ['first', 'second'], + transform: (value) => value.toReversed(), + validate: (value) => (value[0] === 'second' ? null : 'Unexpected order.'), + }), + ); + + expect(result).toEqual(['second', 'first']); + }); }); diff --git a/packages/tui/tests/search.test.ts b/packages/tui/tests/search.test.ts index ad6705c3..b72036b5 100644 --- a/packages/tui/tests/search.test.ts +++ b/packages/tui/tests/search.test.ts @@ -176,6 +176,28 @@ describe('search prompt', () => { await expect(search(options)).rejects.toThrow('Argument [required] must be true or a string.'); }); + + it('transforms search values before validation and return', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + search({ + message: 'Favorite color?', + options: colors, + transform: (value) => value.toUpperCase(), + validate: (value) => (value === 'RED' ? null : 'Unexpected value.'), + }), + ); + + expect(result).toBe('RED'); + }); }); describe('multisearch prompt', () => { @@ -366,4 +388,26 @@ describe('multisearch prompt', () => { expect(result).toEqual(['green', 'blue']); expect(output.text()).toContain('Selected: Green, Blue'); }); + + it('transforms multisearch values before validation and return', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.ctrlA, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + multisearch({ + message: 'Favorite colors?', + options: colors, + transform: (value) => value.toReversed(), + validate: (value) => (value[0] === 'blue' ? null : 'Unexpected order.'), + }), + ); + + expect(result).toEqual(['blue', 'green', 'red']); + }); }); From b6d03075ef20ede91607d412d0b8cfa277a2befa Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 16:36:16 +0800 Subject: [PATCH 068/675] Add distributable package build --- .gitignore | 2 ++ Makefile | 4 ++-- package.json | 1 + packages/tui/package.json | 20 ++++++++++++++++---- packages/tui/tsconfig.build.json | 15 +++++++++++++++ packages/tui/vitest.config.ts | 11 +++++++++++ turbo.json | 4 ++++ 7 files changed, 51 insertions(+), 6 deletions(-) create mode 100644 packages/tui/tsconfig.build.json diff --git a/.gitignore b/.gitignore index 081e9e6d..584c0313 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ node_modules/ .turbo/ .DS_Store coverage/ +*.tsbuildinfo packages/artefacts/.cache/ packages/artefacts/.logs/ packages/artefacts/dist/ +packages/tui/dist/ diff --git a/Makefile b/Makefile index 56e266f0..556706e9 100644 --- a/Makefile +++ b/Makefile @@ -40,10 +40,10 @@ format-all: ## Format every non-ignored TS file in the repo via oxfmt/oxlint fresh: ## Clean generated state, reinstall dependencies, then run local gates $(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)/.turbo" "$(ROOT_PATH)/packages/artefacts/.cache" "$(ROOT_PATH)/packages/artefacts/.logs" "$(ROOT_PATH)/packages/artefacts/dist" + rm -rf "$(ROOT_PATH)/node_modules" "$(ROOT_PATH)"/packages/*/node_modules "$(ROOT_PATH)/.turbo" "$(ROOT_PATH)/packages/artefacts/.cache" "$(ROOT_PATH)/packages/artefacts/.logs" "$(ROOT_PATH)/packages/artefacts/dist" "$(ROOT_PATH)/packages/tui/dist" $(call step,Removing TypeScript build info files) find "$(ROOT_PATH)" -name '*.tsbuildinfo' -type f -delete $(call step,Installing dependencies) cd "$(ROOT_PATH)" && pnpm install $(call step,Running local gates) - cd "$(ROOT_PATH)" && pnpm typecheck && pnpm test + cd "$(ROOT_PATH)" && pnpm build && pnpm typecheck && pnpm test diff --git a/package.json b/package.json index 58b980d7..5c4cbe50 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "private": true, "type": "module", "scripts": { + "build": "turbo build", "test": "turbo test", "typecheck": "turbo typecheck" }, diff --git a/packages/tui/package.json b/packages/tui/package.json index 84518727..21d84b71 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -3,17 +3,29 @@ "version": "0.1.0", "type": "module", "license": "MIT", + "files": [ + "dist" + ], "exports": { ".": { - "types": "./src/index.ts", - "default": "./src/index.ts" + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" } }, "imports": { - "#tui/*": "./src/*.ts", - "#tui/prompts/*": "./src/prompts/*.ts" + "#tui/*": { + "types": "./dist/*.d.ts", + "default": "./dist/*.js" + }, + "#tui/prompts/*": { + "types": "./dist/prompts/*.d.ts", + "default": "./dist/prompts/*.js" + } }, "scripts": { + "build": "tsc -p tsconfig.build.json", + "prepack": "pnpm build", "test": "vitest run --config vitest.config.ts", "typecheck": "tsc -p tsconfig.json --noEmit" }, diff --git a/packages/tui/tsconfig.build.json b/packages/tui/tsconfig.build.json new file mode 100644 index 00000000..eddc8e65 --- /dev/null +++ b/packages/tui/tsconfig.build.json @@ -0,0 +1,15 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "emitDeclarationOnly": false, + "noEmit": false, + "outDir": "dist", + "rootDir": "src", + "sourceMap": true, + "tsBuildInfoFile": "tsconfig.build.tsbuildinfo" + }, + "exclude": ["dist", "tests", "vitest.config.ts"], + "include": ["src/**/*.ts"] +} diff --git a/packages/tui/vitest.config.ts b/packages/tui/vitest.config.ts index dfeeb7cb..7ca3cf0f 100644 --- a/packages/tui/vitest.config.ts +++ b/packages/tui/vitest.config.ts @@ -1,6 +1,17 @@ import { defineConfig } from 'vitest/config'; +import { fileURLToPath } from 'node:url'; + +const sourcePath = fileURLToPath(new URL('./src/$1', import.meta.url)); export default defineConfig({ + resolve: { + alias: [ + { + find: /^#tui\/(.+)$/u, + replacement: sourcePath, + }, + ], + }, test: { environment: 'node', globals: false, diff --git a/turbo.json b/turbo.json index 37ec6d9b..7e037ab3 100644 --- a/turbo.json +++ b/turbo.json @@ -1,6 +1,10 @@ { "$schema": "https://turbo.build/schema.json", "tasks": { + "build": { + "dependsOn": ["^build"], + "outputs": ["dist/**"] + }, "test": { "dependsOn": ["^test"], "outputs": [], From 9b1c4799abca058f36d527158e46ac3ade30b011 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 16:38:10 +0800 Subject: [PATCH 069/675] Validate package build in CI --- .github/workflows/ci.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 18897ac2..34f0103c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,4 @@ -name: Typecheck and test +name: Build, typecheck, and test on: pull_request: @@ -27,6 +27,9 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Build + run: pnpm build + - name: Typecheck run: pnpm typecheck From 4049d1f421dbbcda34b9393b9e9b0426c4b65dcf Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 16:41:04 +0800 Subject: [PATCH 070/675] Mask password prompt input --- packages/tui/src/prompts/password.ts | 12 +++- packages/tui/src/prompts/password/input.ts | 65 ++++++++++++++++++++++ packages/tui/tests/password.test.ts | 38 +++++++++++++ 3 files changed, 113 insertions(+), 2 deletions(-) create mode 100644 packages/tui/src/prompts/password/input.ts create mode 100644 packages/tui/tests/password.test.ts diff --git a/packages/tui/src/prompts/password.ts b/packages/tui/src/prompts/password.ts index 8f139466..761b04df 100644 --- a/packages/tui/src/prompts/password.ts +++ b/packages/tui/src/prompts/password.ts @@ -1,5 +1,6 @@ import { textOptions } from '#tui/concerns/text-options'; -import { text } from '#tui/prompts/text'; +import { promptUntilValid } from '#tui/prompt'; +import { readPasswordValue } from '#tui/prompts/password/input'; import type { TextPromptOptions } from '#tui/types'; export function password(options: TextPromptOptions): Promise; @@ -23,5 +24,12 @@ export async function password( ): Promise { const options = typeof message === 'string' ? textOptions({ message, label: message, placeholder, required, validate, hint, transform }) : textOptions(message); - return text(options); + return promptUntilValid(options, async () => { + const value = await readPasswordValue(options.message, { + hint: options.hint, + placeholder: options.placeholder, + }); + + return options.transform ? options.transform(value) : value; + }); } diff --git a/packages/tui/src/prompts/password/input.ts b/packages/tui/src/prompts/password/input.ts new file mode 100644 index 00000000..5bfd8f63 --- /dev/null +++ b/packages/tui/src/prompts/password/input.ts @@ -0,0 +1,65 @@ +import { promptEnvironment } from '#tui/environment'; +import { PromptValidationError } from '#tui/prompt'; +import { renderQuestion } from '#tui/theme'; +import { applyTypedKey } from '#tui/typed-value'; + +type PasswordInputOptions = { + hint?: string; + placeholder?: string; +}; + +const mask = (value: string): string => '•'.repeat([...value].length); + +const renderPasswordValue = (message: string, stateValue: string, options: PasswordInputOptions): void => { + const value = stateValue.length > 0 ? mask(stateValue) : (options.placeholder ?? ''); + + promptEnvironment().output.write(`${renderQuestion(message, options.hint)}${value}\n`); +}; + +export const readPasswordValue = async (message: string, options: PasswordInputOptions = {}): Promise => { + const environment = promptEnvironment(); + + if (!environment.input.readKey) { + if (!environment.input.readLine) { + throw new PromptValidationError('The configured prompt input cannot read input.'); + } + + return environment.input.readLine(renderQuestion(message, options.hint)); + } + + let state = { + cursor: 0, + value: '', + }; + + renderPasswordValue(message, state.value, options); + + while (true) { + const key = await environment.input.readKey(); + + if (key === null) { + return state.value; + } + + const next = applyTypedKey(state, key); + + if (next.cancelled) { + environment.error.write('Cancelled.\n'); + + return state.value; + } + + state = { + cursor: next.cursor, + value: next.value, + }; + + if (next.submitted) { + environment.output.write('\n'); + + return state.value; + } + + renderPasswordValue(message, state.value, options); + } +}; diff --git a/packages/tui/tests/password.test.ts b/packages/tui/tests/password.test.ts new file mode 100644 index 00000000..1486ce71 --- /dev/null +++ b/packages/tui/tests/password.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; +import { createMemoryOutput, createScriptedInput, Key, password, withPromptEnvironment } from '#tui/index'; + +describe('password prompt', () => { + it('returns typed values without rendering the raw password', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['s', 'e', 'c', 'r', 'e', 't', Key.enter]), + output, + error: output, + interactive: true, + }, + () => password('Password'), + ); + + expect(result).toBe('secret'); + expect(output.text()).not.toContain('secret'); + expect(output.text()).toContain('••••••'); + }); + + it('renders password placeholders before input', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment( + { + input: createScriptedInput([Key.enter]), + output, + error: output, + interactive: true, + }, + () => password('Password', 'Required'), + ); + + expect(output.text()).toContain('? Password Required'); + }); +}); From 5251553f7e6d99a9b38884e8dee4fd5e118f504e Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 16:43:45 +0800 Subject: [PATCH 071/675] Render typed prompt values --- packages/tui/src/prompts/text.ts | 1 + packages/tui/src/prompts/textarea.ts | 1 + packages/tui/src/typed-value.ts | 21 +++++++++++++--- packages/tui/tests/text.test.ts | 37 ++++++++++++++++++++++++++++ packages/tui/tests/textarea.test.ts | 17 +++++++++++++ 5 files changed, 74 insertions(+), 3 deletions(-) create mode 100644 packages/tui/tests/text.test.ts diff --git a/packages/tui/src/prompts/text.ts b/packages/tui/src/prompts/text.ts index 03b0650a..e6a70483 100644 --- a/packages/tui/src/prompts/text.ts +++ b/packages/tui/src/prompts/text.ts @@ -30,6 +30,7 @@ export async function text( const answer = await readTypedValue(options.message, { default: options.default, hint: options.hint, + placeholder: options.placeholder, }); const value = answer === '' && options.default !== undefined ? options.default : answer; diff --git a/packages/tui/src/prompts/textarea.ts b/packages/tui/src/prompts/textarea.ts index 807a2cfb..bf198ddd 100644 --- a/packages/tui/src/prompts/textarea.ts +++ b/packages/tui/src/prompts/textarea.ts @@ -33,6 +33,7 @@ export async function textarea( default: options.default, hint: options.hint, allowNewLine: true, + placeholder: options.placeholder, }); const value = answer === '' && options.default !== undefined ? options.default : answer; diff --git a/packages/tui/src/typed-value.ts b/packages/tui/src/typed-value.ts index 31f27c2e..160817f2 100644 --- a/packages/tui/src/typed-value.ts +++ b/packages/tui/src/typed-value.ts @@ -8,6 +8,13 @@ export type TypedValueState = { value: string; }; +type TypedValueOptions = { + allowNewLine?: boolean; + default?: string; + hint?: string; + placeholder?: string; +}; + const characters = (value: string): string[] => [...value]; const fromCharacters = (value: string[]): string => value.join(''); @@ -24,6 +31,12 @@ const isPrintable = (key: string): boolean => { }); }; +const renderTypedValue = (message: string, value: string, options: TypedValueOptions): void => { + const displayValue = value.length > 0 ? value : (options.placeholder ?? ''); + + promptEnvironment().output.write(`${renderQuestion(message, options.hint)}${displayValue}\n`); +}; + const lineRanges = (value: string[]): LineRange[] => { const ranges: LineRange[] = []; @@ -152,7 +165,7 @@ export const applyTypedKey = (state: TypedValueState, key: string, allowNewLine return { cursor, value: fromCharacters(value), submitted: false, cancelled: false }; }; -export const readTypedValue = async (message: string, options: { default?: string; hint?: string; allowNewLine?: boolean } = {}): Promise => { +export const readTypedValue = async (message: string, options: TypedValueOptions = {}): Promise => { const environment = promptEnvironment(); if (!environment.input.readKey) { @@ -165,13 +178,13 @@ export const readTypedValue = async (message: string, options: { default?: strin return answer === '' && options.default !== undefined ? options.default : answer; } - environment.output.write(renderQuestion(message, options.hint)); - let state: TypedValueState = { cursor: options.default?.length ?? 0, value: options.default ?? '', }; + renderTypedValue(message, state.value, options); + while (true) { const key = await environment.input.readKey(); @@ -197,5 +210,7 @@ export const readTypedValue = async (message: string, options: { default?: strin return state.value; } + + renderTypedValue(message, state.value, options); } }; diff --git a/packages/tui/tests/text.test.ts b/packages/tui/tests/text.test.ts new file mode 100644 index 00000000..5a69773e --- /dev/null +++ b/packages/tui/tests/text.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; +import { createMemoryOutput, createScriptedInput, Key, text, withPromptEnvironment } from '#tui/index'; + +describe('text prompt', () => { + it('renders typed values while reading raw key input', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['A', 'd', 'a', Key.enter]), + output, + error: output, + interactive: true, + }, + () => text('Name'), + ); + + expect(result).toBe('Ada'); + expect(output.text()).toContain('? Name Ada'); + }); + + it('renders placeholders before text input', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment( + { + input: createScriptedInput([Key.enter]), + output, + error: output, + interactive: true, + }, + () => text('Name', 'Jane Doe'), + ); + + expect(output.text()).toContain('? Name Jane Doe'); + }); +}); diff --git a/packages/tui/tests/textarea.test.ts b/packages/tui/tests/textarea.test.ts index 2d4d3eb6..bfe7127d 100644 --- a/packages/tui/tests/textarea.test.ts +++ b/packages/tui/tests/textarea.test.ts @@ -17,5 +17,22 @@ describe('textarea prompt', () => { expect(result).toBe('A\nB'); expect(output.text()).toContain('Description'); + expect(output.text()).toContain('? Description A\nB'); + }); + + it('renders textarea placeholders before input', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment( + { + input: createScriptedInput([Key.ctrlD]), + output, + error: output, + interactive: true, + }, + () => textarea('Description', 'Type here'), + ); + + expect(output.text()).toContain('? Description Type here'); }); }); From a69d34804f13c3f35891ea7d6e2ecac24c228985 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 16:48:34 +0800 Subject: [PATCH 072/675] Respect textarea row windows --- packages/tui/src/index.ts | 1 + packages/tui/src/prompts/textarea.ts | 23 +++++++++++++---------- packages/tui/src/typed-value.ts | 26 ++++++++++++++++++++++---- packages/tui/src/types.ts | 4 ++++ packages/tui/tests/textarea.test.ts | 19 +++++++++++++++++++ 5 files changed, 59 insertions(+), 14 deletions(-) diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index 9d1dd7fd..4e6bb0a7 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -33,6 +33,7 @@ export type { StatusOptions, TableCell, TableOptions, + TextareaPromptOptions, TextPromptOptions, ValidationResult, Validator, diff --git a/packages/tui/src/prompts/textarea.ts b/packages/tui/src/prompts/textarea.ts index bf198ddd..88034dd7 100644 --- a/packages/tui/src/prompts/textarea.ts +++ b/packages/tui/src/prompts/textarea.ts @@ -1,32 +1,34 @@ -import { textOptions } from '#tui/concerns/text-options'; import { promptUntilValid } from '#tui/prompt'; import { readTypedValue } from '#tui/typed-value'; -import type { TextPromptOptions } from '#tui/types'; +import type { TextareaPromptOptions } from '#tui/types'; -export function textarea(options: TextPromptOptions & { rows?: number }): Promise; +export function textarea(options: TextareaPromptOptions): Promise; export function textarea( label: string, placeholder?: string, defaultValue?: string, required?: boolean | string, - validate?: TextPromptOptions['validate'], + validate?: TextareaPromptOptions['validate'], hint?: string, rows?: number, - transform?: TextPromptOptions['transform'], + transform?: TextareaPromptOptions['transform'], ): Promise; export async function textarea( - message: string | (TextPromptOptions & { rows?: number }), + message: string | TextareaPromptOptions, placeholder = '', defaultValue = '', required: boolean | string = false, - validate: TextPromptOptions['validate'] = undefined, + validate: TextareaPromptOptions['validate'] = undefined, hint = '', - _rows = 5, - transform: TextPromptOptions['transform'] = undefined, + rows = 5, + transform: TextareaPromptOptions['transform'] = undefined, ): Promise { - const options = typeof message === 'string' ? textOptions({ message, label: message, placeholder, default: defaultValue, required, validate, hint, transform }) : textOptions(message); + const options: TextareaPromptOptions = + typeof message === 'string' + ? { message, label: message, placeholder, default: defaultValue, required, validate, hint, rows, transform } + : { ...message, default: message.default ?? '', rows: message.rows ?? rows }; return promptUntilValid(options, async () => { const answer = await readTypedValue(options.message, { @@ -34,6 +36,7 @@ export async function textarea( hint: options.hint, allowNewLine: true, placeholder: options.placeholder, + rows: options.rows ?? rows, }); const value = answer === '' && options.default !== undefined ? options.default : answer; diff --git a/packages/tui/src/typed-value.ts b/packages/tui/src/typed-value.ts index 160817f2..56b016a0 100644 --- a/packages/tui/src/typed-value.ts +++ b/packages/tui/src/typed-value.ts @@ -13,6 +13,7 @@ type TypedValueOptions = { default?: string; hint?: string; placeholder?: string; + rows?: number; }; const characters = (value: string): string[] => [...value]; @@ -31,8 +32,25 @@ const isPrintable = (key: string): boolean => { }); }; -const renderTypedValue = (message: string, value: string, options: TypedValueOptions): void => { - const displayValue = value.length > 0 ? value : (options.placeholder ?? ''); +const visibleLines = (value: string, cursor: number, rows: number | undefined): string => { + if (rows === undefined || rows <= 0) { + return value; + } + + const valueCharacters = characters(value); + const ranges = lineRanges(valueCharacters); + const line = currentLine(ranges, cursor); + const start = Math.max(0, line - rows + 1); + const end = start + rows; + + return ranges + .slice(start, end) + .map((range) => fromCharacters(valueCharacters.slice(range.start, range.end))) + .join('\n'); +}; + +const renderTypedValue = (message: string, state: TypedValueState, options: TypedValueOptions): void => { + const displayValue = state.value.length > 0 ? visibleLines(state.value, state.cursor, options.rows) : (options.placeholder ?? ''); promptEnvironment().output.write(`${renderQuestion(message, options.hint)}${displayValue}\n`); }; @@ -183,7 +201,7 @@ export const readTypedValue = async (message: string, options: TypedValueOptions value: options.default ?? '', }; - renderTypedValue(message, state.value, options); + renderTypedValue(message, state, options); while (true) { const key = await environment.input.readKey(); @@ -211,6 +229,6 @@ export const readTypedValue = async (message: string, options: TypedValueOptions return state.value; } - renderTypedValue(message, state.value, options); + renderTypedValue(message, state, options); } }; diff --git a/packages/tui/src/types.ts b/packages/tui/src/types.ts index 940565e2..59b1eea6 100644 --- a/packages/tui/src/types.ts +++ b/packages/tui/src/types.ts @@ -48,6 +48,10 @@ export type TextPromptOptions = BasePromptOptions & { transform?: (value: string) => MaybePromise; }; +export type TextareaPromptOptions = TextPromptOptions & { + rows?: number; +}; + export type NumberPromptOptions = BasePromptOptions & { label?: string; min?: number; diff --git a/packages/tui/tests/textarea.test.ts b/packages/tui/tests/textarea.test.ts index bfe7127d..b6fe7a97 100644 --- a/packages/tui/tests/textarea.test.ts +++ b/packages/tui/tests/textarea.test.ts @@ -35,4 +35,23 @@ describe('textarea prompt', () => { expect(output.text()).toContain('? Description Type here'); }); + + it('keeps rendered textarea output within the configured row window', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment( + { + input: createScriptedInput(['A', Key.enter, 'B', Key.enter, 'C', Key.ctrlD]), + output, + error: output, + interactive: true, + }, + () => textarea('Description', '', '', false, undefined, '', 2), + ); + + const latestFrame = output.text().split('? Description ').at(-1) ?? ''; + + expect(latestFrame).toContain('B\nC'); + expect(latestFrame).not.toContain('A\nB\nC'); + }); }); From 3fc24daa67269031cfb0eb1e9ccc7b9a1767029c Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 16:52:10 +0800 Subject: [PATCH 073/675] Align multiline typed navigation --- packages/acceptance/tsconfig.json | 15 ++++++++++----- packages/acceptance/vitest.config.ts | 16 ++++++++++++++++ packages/tui/src/typed-value.ts | 19 +++++++++++++++++-- packages/tui/tests/typed-value.test.ts | 17 +++++++++++++++++ 4 files changed, 60 insertions(+), 7 deletions(-) diff --git a/packages/acceptance/tsconfig.json b/packages/acceptance/tsconfig.json index a24c7799..e10cbc7c 100644 --- a/packages/acceptance/tsconfig.json +++ b/packages/acceptance/tsconfig.json @@ -1,7 +1,12 @@ { - "extends": ["@ollin/tsconfig/base.json", "@ollin/artefacts/tsconfig/acceptance.json"], - "compilerOptions": { - "baseUrl": "." - }, - "include": ["tests/**/*.ts", "vitest.config.ts"] + "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 index dfeeb7cb..19132acd 100644 --- a/packages/acceptance/vitest.config.ts +++ b/packages/acceptance/vitest.config.ts @@ -1,6 +1,22 @@ import { defineConfig } from 'vitest/config'; +import { fileURLToPath } from 'node:url'; + +const sourcePath = fileURLToPath(new URL('../tui/src/index.ts', import.meta.url)); +const internalSourcePath = fileURLToPath(new URL('../tui/src/$1', import.meta.url)); export default defineConfig({ + resolve: { + alias: [ + { + find: '@ollin/tui', + replacement: sourcePath, + }, + { + find: /^#tui\/(.+)$/u, + replacement: internalSourcePath, + }, + ], + }, test: { environment: 'node', globals: false, diff --git a/packages/tui/src/typed-value.ts b/packages/tui/src/typed-value.ts index 56b016a0..68aa2dcf 100644 --- a/packages/tui/src/typed-value.ts +++ b/packages/tui/src/typed-value.ts @@ -99,6 +99,17 @@ const moveLine = (value: string[], cursor: number, direction: 1 | -1): number => return target.start + targetColumn; }; +const moveToLineBoundary = (value: string[], cursor: number, boundary: 'start' | 'end'): number => { + const ranges = lineRanges(value); + const range = ranges[currentLine(ranges, cursor)]; + + if (!range) { + return cursor; + } + + return boundary === 'start' ? range.start : range.end; +}; + export const applyTypedKey = (state: TypedValueState, key: string, allowNewLine = false): TypedValueState & { submitted: boolean; cancelled: boolean } => { const value = characters(state.value); @@ -140,11 +151,15 @@ export const applyTypedKey = (state: TypedValueState, key: string, allowNewLine } if (oneOf([Key.home, Key.ctrlA], key)) { - return { cursor: 0, value: fromCharacters(value), submitted: false, cancelled: false }; + const nextCursor = allowNewLine ? moveToLineBoundary(value, cursor, 'start') : 0; + + return { cursor: nextCursor, value: fromCharacters(value), submitted: false, cancelled: false }; } if (oneOf([Key.end, Key.ctrlE], key)) { - return { cursor: value.length, value: fromCharacters(value), submitted: false, cancelled: false }; + const nextCursor = allowNewLine ? moveToLineBoundary(value, cursor, 'end') : value.length; + + return { cursor: nextCursor, value: fromCharacters(value), submitted: false, cancelled: false }; } if (key === Key.delete) { diff --git a/packages/tui/tests/typed-value.test.ts b/packages/tui/tests/typed-value.test.ts index c321e472..099b301d 100644 --- a/packages/tui/tests/typed-value.test.ts +++ b/packages/tui/tests/typed-value.test.ts @@ -80,4 +80,21 @@ describe('typed value editing', () => { value: 'abc\nde\nfghi', }); }); + + it('moves to textarea line boundaries', () => { + expect(applyMultilineState('abc\nde\nfghi', 5, [Key.home[0]])).toEqual({ + cursor: 4, + value: 'abc\nde\nfghi', + }); + + expect(applyMultilineState('abc\nde\nfghi', 5, [Key.end[0]])).toEqual({ + cursor: 6, + value: 'abc\nde\nfghi', + }); + + expect(applyMultilineState('abc\nde\nfghi', 9, [Key.ctrlA, 'X', Key.ctrlE, 'Y'])).toEqual({ + cursor: 13, + value: 'abc\nde\nXfghiY', + }); + }); }); From 5d7b11389830b9b9ef10845ce8dff1e81b47d64b Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 16:53:51 +0800 Subject: [PATCH 074/675] Support ctrl-u typed editing --- packages/tui/src/typed-value.ts | 8 ++++++++ packages/tui/tests/typed-value.test.ts | 11 +++++++++++ 2 files changed, 19 insertions(+) diff --git a/packages/tui/src/typed-value.ts b/packages/tui/src/typed-value.ts index 68aa2dcf..ecadc86a 100644 --- a/packages/tui/src/typed-value.ts +++ b/packages/tui/src/typed-value.ts @@ -168,6 +168,14 @@ export const applyTypedKey = (state: TypedValueState, key: string, allowNewLine return { cursor, value: fromCharacters(value), submitted: false, cancelled: false }; } + if (key === Key.ctrlU) { + const start = allowNewLine ? moveToLineBoundary(value, cursor, 'start') : 0; + + value.splice(start, cursor - start); + + return { cursor: start, value: fromCharacters(value), submitted: false, cancelled: false }; + } + if (key === Key.backspace || key === Key.ctrlH) { if (cursor === 0) { return { cursor, value: fromCharacters(value), submitted: false, cancelled: false }; diff --git a/packages/tui/tests/typed-value.test.ts b/packages/tui/tests/typed-value.test.ts index 099b301d..6b06aab2 100644 --- a/packages/tui/tests/typed-value.test.ts +++ b/packages/tui/tests/typed-value.test.ts @@ -49,6 +49,10 @@ describe('typed value editing', () => { expect(apply(['J', 'e', 'z', Key.left, Key.delete, 's', 's'])).toBe('Jess'); }); + it('deletes typed input back to the line start', () => { + expect(apply(['J', 'a', 'n', 'e', Key.left, Key.left, Key.ctrlU, 'D', 'o'])).toBe('Done'); + }); + it('supports emacs-style movement keys', () => { expect(apply(['J', 'z', 'e', Key.ctrlB, Key.ctrlH, Key.ctrlF, 's', 's'])).toBe('Jess'); }); @@ -97,4 +101,11 @@ describe('typed value editing', () => { value: 'abc\nde\nXfghiY', }); }); + + it('deletes textarea input back to the current line start', () => { + expect(applyMultilineState('abc\nde\nfghi', 9, [Key.ctrlU])).toEqual({ + cursor: 7, + value: 'abc\nde\nhi', + }); + }); }); From 2443fc64a307f5887539833b686770f87569fb6a Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 16:57:24 +0800 Subject: [PATCH 075/675] Preserve decimal number input --- packages/tui/src/prompts/number/validators/value.ts | 2 +- packages/tui/tests/number.test.ts | 11 +++++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/tui/src/prompts/number/validators/value.ts b/packages/tui/src/prompts/number/validators/value.ts index 8665b44d..cb266ac0 100644 --- a/packages/tui/src/prompts/number/validators/value.ts +++ b/packages/tui/src/prompts/number/validators/value.ts @@ -35,5 +35,5 @@ export const parseNumberInput = (input: unknown, options: Pick { it('rejects partial decimal input and accepts a retry', async () => { @@ -69,7 +70,7 @@ describe('number prompt', () => { expect(output.text()).toContain('Required.'); }); - it('coerces decimal input to an integer value', async () => { + it('returns decimal input as a decimal value', async () => { const output = createMemoryOutput(); const result = await withPromptEnvironment( @@ -82,7 +83,13 @@ describe('number prompt', () => { () => number({ message: 'Count' }), ); - expect(result).toBe(1); + expect(result).toBe(1.9); + }); + + it('parses decimals unless integer input is required', () => { + expect(parseNumberInput('1.9')).toEqual({ value: 1.9 }); + expect(parseNumberInput('1.9', { integer: true })).toEqual({ error: 'Must be a number' }); + expect(parseNumberInput('1', { integer: true })).toEqual({ value: 1 }); }); it('validates typed values against min and max bounds', async () => { From bf41d7f0cce061438ec80bf3901f4a5745d1f798 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 16:59:03 +0800 Subject: [PATCH 076/675] Clamp progress updates --- packages/tui/src/status/progress.ts | 2 +- packages/tui/tests/progress.test.ts | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/tui/src/status/progress.ts b/packages/tui/src/status/progress.ts index b7c9ee11..e929cf99 100644 --- a/packages/tui/src/status/progress.ts +++ b/packages/tui/src/status/progress.ts @@ -24,7 +24,7 @@ export class Progress { } advance(step = 1): void { - this.#current = Math.min(this.total, this.#current + step); + this.#current = Math.max(0, Math.min(this.total, this.#current + step)); this.render(); } diff --git a/packages/tui/tests/progress.test.ts b/packages/tui/tests/progress.test.ts index a5e01346..32e91bd3 100644 --- a/packages/tui/tests/progress.test.ts +++ b/packages/tui/tests/progress.test.ts @@ -58,4 +58,21 @@ describe('progress helper', () => { expect(bar.value()).toBe(true); }); }); + + it('clamps manual progress updates to the valid range', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + const bar = progress('Adding States', 2); + + bar.advance(3); + expect(bar.current()).toBe(2); + + bar.advance(-5); + expect(bar.current()).toBe(0); + }); + + expect(output.text()).toContain('2 / 2'); + expect(output.text()).toContain('0 / 2'); + }); }); From 114befc934daeb0373561b14c02f12b3e421ca8f Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 17:03:34 +0800 Subject: [PATCH 077/675] Add page navigation to prompts --- packages/tui/src/prompts/search/read-multi.ts | 21 ++++++++++ .../tui/src/prompts/search/read-single.ts | 17 +++++++++ packages/tui/src/prompts/select/navigation.ts | 30 +++++++++++++++ .../tui/src/prompts/select/read-multiple.ts | 14 ++++++- .../tui/src/prompts/select/read-selected.ts | 14 ++++++- packages/tui/src/prompts/suggest/read.ts | 17 +++++++++ packages/tui/tests/choices.test.ts | 32 ++++++++++++++++ packages/tui/tests/search.test.ts | 38 +++++++++++++++++++ packages/tui/tests/suggest.test.ts | 4 ++ 9 files changed, 185 insertions(+), 2 deletions(-) diff --git a/packages/tui/src/prompts/search/read-multi.ts b/packages/tui/src/prompts/search/read-multi.ts index 63b84ae4..6b2c6ec0 100644 --- a/packages/tui/src/prompts/search/read-multi.ts +++ b/packages/tui/src/prompts/search/read-multi.ts @@ -5,6 +5,7 @@ import { applyTypedKey } from '#tui/typed-value'; import { findChoice, firstEnabledIndex, nextEnabledIndex } from '#tui/concerns/choices'; import { lastEnabledIndex, resolveSearchChoices } from '#tui/prompts/search/choices'; import { renderSearchChoices } from '#tui/prompts/search/render'; +import { pageEnabledChoiceIndex } from '#tui/prompts/select/navigation'; import type { Choice, MultiSearchPromptOptions } from '#tui/types'; export const readMultiSearchChoices = async (options: MultiSearchPromptOptions): Promise => { @@ -89,6 +90,26 @@ export const readMultiSearchChoices = async (options: MultiSearchPromptOption continue; } + if (key === Key.pageDown) { + choices = await resolveSearchChoices(options.options, state.value); + + const currentChoices = displayedChoices(); + + highlighted = currentChoices.length === 0 ? null : highlighted === null ? firstEnabledIndex(currentChoices) : pageEnabledChoiceIndex(currentChoices, highlighted, 1, options.scroll); + render(); + continue; + } + + if (key === Key.pageUp) { + choices = await resolveSearchChoices(options.options, state.value); + + const currentChoices = displayedChoices(); + + highlighted = currentChoices.length === 0 ? null : highlighted === null ? lastEnabledIndex(currentChoices) : pageEnabledChoiceIndex(currentChoices, highlighted, -1, options.scroll); + render(); + continue; + } + if (oneOf([Key.home], key) && highlighted !== null) { highlighted = firstEnabledIndex(displayedChoices()); render(); diff --git a/packages/tui/src/prompts/search/read-single.ts b/packages/tui/src/prompts/search/read-single.ts index 511349dd..1dbc2b81 100644 --- a/packages/tui/src/prompts/search/read-single.ts +++ b/packages/tui/src/prompts/search/read-single.ts @@ -5,6 +5,7 @@ import { applyTypedKey } from '#tui/typed-value'; import { findChoice, firstEnabledIndex, nextEnabledIndex } from '#tui/concerns/choices'; import { lastEnabledIndex, resolveSearchChoices } from '#tui/prompts/search/choices'; import { renderSearchChoices } from '#tui/prompts/search/render'; +import { pageEnabledChoiceIndex } from '#tui/prompts/select/navigation'; import type { SearchPromptOptions } from '#tui/types'; export const readSearchChoice = async (options: SearchPromptOptions, attempt = 0): Promise => { @@ -58,6 +59,22 @@ export const readSearchChoice = async (options: SearchPromptOptions, attem continue; } + if (key === Key.pageDown) { + choices = await resolveSearchChoices(options.options, state.value); + + highlighted = choices.length === 0 ? null : highlighted === null ? firstEnabledIndex(choices) : pageEnabledChoiceIndex(choices, highlighted, 1, options.scroll); + renderSearchChoices(options.message, state.value, choices, highlighted, new Set(), [], options.scroll, options.info); + continue; + } + + if (key === Key.pageUp) { + choices = await resolveSearchChoices(options.options, state.value); + + highlighted = choices.length === 0 ? null : highlighted === null ? lastEnabledIndex(choices) : pageEnabledChoiceIndex(choices, highlighted, -1, options.scroll); + renderSearchChoices(options.message, state.value, choices, highlighted, new Set(), [], options.scroll, options.info); + continue; + } + if (oneOf([Key.home, Key.ctrlA], key) && highlighted !== null) { highlighted = firstEnabledIndex(choices); renderSearchChoices(options.message, state.value, choices, highlighted, new Set(), [], options.scroll, options.info); diff --git a/packages/tui/src/prompts/select/navigation.ts b/packages/tui/src/prompts/select/navigation.ts index a59a25cc..d2f94b7e 100644 --- a/packages/tui/src/prompts/select/navigation.ts +++ b/packages/tui/src/prompts/select/navigation.ts @@ -20,3 +20,33 @@ export const lastEnabledChoiceIndex = (choices: Array>): number => return selected; }; + +export const pageSize = (scroll?: number): number => { + return Math.max(1, 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/read-multiple.ts b/packages/tui/src/prompts/select/read-multiple.ts index f3586675..ceda64f9 100644 --- a/packages/tui/src/prompts/select/read-multiple.ts +++ b/packages/tui/src/prompts/select/read-multiple.ts @@ -3,7 +3,7 @@ import { Key, oneOf } from '#tui/key'; import { ask, PromptValidationError } from '#tui/prompt'; import { renderChoices } from '#tui/theme'; import { findChoice, firstEnabledIndex, nextEnabledIndex } from '#tui/concerns/choices'; -import { lastEnabledChoiceIndex, nextChoiceKeys, parseChoiceIndex, previousChoiceKeys } from '#tui/prompts/select/navigation'; +import { lastEnabledChoiceIndex, nextChoiceKeys, pageEnabledChoiceIndex, parseChoiceIndex, previousChoiceKeys } from '#tui/prompts/select/navigation'; import { renderMultipleChoices } from '#tui/prompts/select/render'; import type { Choice, MultiSelectPromptOptions } from '#tui/types'; @@ -85,6 +85,18 @@ export const readMultipleChoices = async ( continue; } + if (key === Key.pageDown) { + selected = pageEnabledChoiceIndex(choices, selected, 1, scroll); + renderMultipleChoices(message, choices, selected, marked, scroll, info); + continue; + } + + if (key === Key.pageUp) { + selected = pageEnabledChoiceIndex(choices, selected, -1, scroll); + renderMultipleChoices(message, choices, selected, marked, scroll, info); + continue; + } + if (oneOf([Key.home], key)) { selected = firstEnabledIndex(choices); renderMultipleChoices(message, choices, selected, marked, scroll, info); diff --git a/packages/tui/src/prompts/select/read-selected.ts b/packages/tui/src/prompts/select/read-selected.ts index d20eaf58..f030d6d4 100644 --- a/packages/tui/src/prompts/select/read-selected.ts +++ b/packages/tui/src/prompts/select/read-selected.ts @@ -3,7 +3,7 @@ import { Key, oneOf } from '#tui/key'; import { ask, PromptValidationError } from '#tui/prompt'; import { renderChoices } from '#tui/theme'; import { findChoice, firstEnabledIndex, nextEnabledIndex } from '#tui/concerns/choices'; -import { lastEnabledChoiceIndex, nextChoiceKeys, parseChoiceIndex, previousChoiceKeys } from '#tui/prompts/select/navigation'; +import { lastEnabledChoiceIndex, nextChoiceKeys, pageEnabledChoiceIndex, parseChoiceIndex, previousChoiceKeys } from '#tui/prompts/select/navigation'; import { renderSelectedChoice } from '#tui/prompts/select/render'; import type { Choice, SelectPromptOptions } from '#tui/types'; @@ -63,6 +63,18 @@ export const readSelectedChoice = async (message: string, choices: Array => { @@ -54,6 +55,22 @@ export const readSuggestionValue = async (options: SuggestOptions): Promise { expect(result).toBe('first'); }); + it('supports page select navigation keys', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.pageDown, Key.pageUp, Key.pageDown, Key.enter]), + output, + error: output, + interactive: true, + }, + () => select({ message: 'Pick one', options: ['first', 'second', 'third', 'fourth'], scroll: 2 }), + ); + + expect(result).toBe('third'); + }); + it('renders select info for the highlighted option', async () => { const output = createMemoryOutput(); @@ -310,6 +326,22 @@ describe('choice prompts', () => { expect(output.text()).toContain('1 selected'); }); + it('supports page multiselect navigation keys', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.pageDown, Key.space, Key.pageUp, Key.space, Key.enter]), + output, + error: output, + interactive: true, + }, + () => multiselect({ message: 'Pick many', options: ['first', 'second', 'third', 'fourth'], scroll: 2 }), + ); + + expect(result).toEqual(['third', 'first']); + }); + it('combines multiselect info with selected counts', async () => { const output = createMemoryOutput(); diff --git a/packages/tui/tests/search.test.ts b/packages/tui/tests/search.test.ts index b72036b5..0da2d5b2 100644 --- a/packages/tui/tests/search.test.ts +++ b/packages/tui/tests/search.test.ts @@ -96,6 +96,27 @@ describe('search prompt', () => { expect(latestFrame).toContain('four'); }); + it('supports page search navigation keys', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.pageDown, Key.pageUp, Key.pageDown, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + search({ + message: 'Pick number', + options: ['one', 'two', 'three', 'four'], + scroll: 2, + }), + ); + + expect(result).toBe('three'); + }); + it('renders search info for the highlighted result', async () => { const output = createMemoryOutput(); @@ -364,6 +385,23 @@ describe('multisearch prompt', () => { expect(output.text()).toContain('Selected: Blue, Red'); }); + it('supports page multisearch navigation keys', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.pageDown, Key.space, Key.pageUp, Key.space, Key.enter]), + output, + error: output, + interactive: true, + }, + () => multisearch({ message: 'Favorite colors?', options: colors, scroll: 2 }), + ); + + expect(result).toEqual(['blue', 'red']); + expect(output.text()).toContain('Selected: Blue, Red'); + }); + it('skips disabled multisearch results while navigating and toggling all', async () => { const output = createMemoryOutput(); diff --git a/packages/tui/tests/suggest.test.ts b/packages/tui/tests/suggest.test.ts index 295bbd27..a72ad080 100644 --- a/packages/tui/tests/suggest.test.ts +++ b/packages/tui/tests/suggest.test.ts @@ -56,6 +56,10 @@ describe('suggest prompt', () => { await expectSuggestion(['b', Key.shiftTab, Key.enter], ['Red', 'Blue', 'Black', 'Blurple'], 'Blurple'); }); + it('supports page suggestion navigation keys', async () => { + await expectSuggestion(['b', Key.down, Key.pageDown, Key.pageUp, Key.pageDown, Key.enter], ['Red', 'Blue', 'Black', 'Blurple'], 'Blurple'); + }); + it('supports callback options', async () => { await expectSuggestion(['e', 'e', Key.down, Key.enter], (value) => ['Red', 'Green', 'Blue'].filter((option) => option.toLowerCase().includes(value.toLowerCase())), 'Green'); }); From f4cf6690a20d2dcfdbe0c70b8025e5f34905b2bf Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 17:06:21 +0800 Subject: [PATCH 078/675] Render number input values --- packages/tui/src/prompts/number.ts | 5 ++- packages/tui/src/prompts/number/input.ts | 15 ++++++- packages/tui/src/types.ts | 1 + packages/tui/tests/number.test.ts | 52 ++++++++++++++++++++++++ 4 files changed, 69 insertions(+), 4 deletions(-) diff --git a/packages/tui/src/prompts/number.ts b/packages/tui/src/prompts/number.ts index c5d49520..e483c691 100644 --- a/packages/tui/src/prompts/number.ts +++ b/packages/tui/src/prompts/number.ts @@ -19,7 +19,7 @@ export function number( export async function number( message: string | NumberPromptOptions, - _placeholder = '', + placeholder = '', defaultValue: number | string | undefined = undefined, required: boolean | string = false, validate: NumberPromptOptions['validate'] = undefined, @@ -28,7 +28,7 @@ export async function number( max: number | undefined = undefined, step: number | undefined = undefined, ): Promise { - const options: NumberPromptOptions = typeof message === 'string' ? { message, label: message, default: defaultValue, required, validate, hint, min, max, step } : { ...message }; + const options: NumberPromptOptions = typeof message === 'string' ? { message, label: message, placeholder, default: defaultValue, required, validate, hint, min, max, step } : { ...message }; return promptUntilValid(options, async () => { const answer = await readNumberValue(options.message, { @@ -36,6 +36,7 @@ export async function number( hint: options.hint, max: options.max, min: options.min, + placeholder: options.placeholder, step: options.step, }); diff --git a/packages/tui/src/prompts/number/input.ts b/packages/tui/src/prompts/number/input.ts index 3a9b91f3..1dc9e68b 100644 --- a/packages/tui/src/prompts/number/input.ts +++ b/packages/tui/src/prompts/number/input.ts @@ -9,6 +9,7 @@ type NumberInputOptions = { hint?: string; max?: number; min?: number; + placeholder?: string; step?: number; }; @@ -34,6 +35,12 @@ const steppedValue = (value: string, direction: 1 | -1, options: NumberInputOpti return String(clamp(Math.trunc(Number(value)) + step * direction, options.min, options.max)); }; +const renderNumberValue = (message: string, value: string, options: NumberInputOptions): void => { + const displayValue = value.length > 0 ? value : (options.placeholder ?? ''); + + promptEnvironment().output.write(`${renderQuestion(message, options.hint)}${displayValue}\n`); +}; + export const readNumberValue = async (message: string, options: NumberInputOptions = {}): Promise => { const environment = promptEnvironment(); @@ -47,13 +54,13 @@ export const readNumberValue = async (message: string, options: NumberInputOptio return answer === '' && options.default !== undefined ? String(options.default) : answer; } - environment.output.write(renderQuestion(message, options.hint)); - let state = { cursor: options.default === undefined ? 0 : String(options.default).length, value: options.default === undefined ? '' : String(options.default), }; + renderNumberValue(message, state.value, options); + while (true) { const key = await environment.input.readKey(); @@ -64,12 +71,14 @@ export const readNumberValue = async (message: string, options: NumberInputOptio if (key === Key.up || key === Key.upArrow) { state.value = steppedValue(state.value, 1, options); state.cursor = state.value.length; + renderNumberValue(message, state.value, options); continue; } if (key === Key.down || key === Key.downArrow) { state.value = steppedValue(state.value, -1, options); state.cursor = state.value.length; + renderNumberValue(message, state.value, options); continue; } @@ -91,5 +100,7 @@ export const readNumberValue = async (message: string, options: NumberInputOptio return state.value; } + + renderNumberValue(message, state.value, options); } }; diff --git a/packages/tui/src/types.ts b/packages/tui/src/types.ts index 59b1eea6..e48605ae 100644 --- a/packages/tui/src/types.ts +++ b/packages/tui/src/types.ts @@ -54,6 +54,7 @@ export type TextareaPromptOptions = TextPromptOptions & { export type NumberPromptOptions = BasePromptOptions & { label?: string; + placeholder?: string; min?: number; max?: number; integer?: boolean; diff --git a/packages/tui/tests/number.test.ts b/packages/tui/tests/number.test.ts index 354f6fd0..bedd7511 100644 --- a/packages/tui/tests/number.test.ts +++ b/packages/tui/tests/number.test.ts @@ -53,6 +53,40 @@ describe('number prompt', () => { expect(result).toBe(''); }); + it('renders placeholders before number input', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment( + { + input: createScriptedInput([Key.enter]), + output, + error: output, + interactive: true, + }, + () => number('Count', '0'), + ); + + expect(output.text()).toContain('? Count 0'); + }); + + it('renders typed number values while reading raw key input', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['4', '2', Key.enter]), + output, + error: output, + interactive: true, + }, + () => number({ message: 'Count' }), + ); + + expect(result).toBe(42); + expect(output.text()).toContain('? Count 4'); + expect(output.text()).toContain('? Count 42'); + }); + it('requires number input when configured', async () => { const output = createMemoryOutput(); @@ -124,6 +158,24 @@ describe('number prompt', () => { ); expect(result).toBe(1); + expect(output.text()).toContain('? Count 1'); + }); + + it('renders default number values before input', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.enter]), + output, + error: output, + interactive: true, + }, + () => number({ message: 'Count', default: 7 }), + ); + + expect(result).toBe(7); + expect(output.text()).toContain('? Count 7'); }); it('clamps arrow key changes to min and max', async () => { From cc3dfc33827f1c2e34e8f07d37ba857b7f651d6c Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 17:08:30 +0800 Subject: [PATCH 079/675] Add page navigation to autocomplete --- .../src/prompts/suggest/read-autocomplete.ts | 17 +++++++++++++++++ packages/tui/tests/suggest.test.ts | 17 +++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/packages/tui/src/prompts/suggest/read-autocomplete.ts b/packages/tui/src/prompts/suggest/read-autocomplete.ts index fda9aace..5d88a37c 100644 --- a/packages/tui/src/prompts/suggest/read-autocomplete.ts +++ b/packages/tui/src/prompts/suggest/read-autocomplete.ts @@ -4,6 +4,7 @@ import { ask } from '#tui/prompt'; import { applyTypedKey } from '#tui/typed-value'; import { renderAutocomplete } from '#tui/prompts/suggest/render-autocomplete'; import { resolveSuggestions } from '#tui/prompts/suggest/resolve'; +import { pageIndex } from '#tui/prompts/select/navigation'; import type { SuggestOptions } from '#tui/prompts/suggest/options'; export const readAutocompleteValue = async (options: SuggestOptions): Promise => { @@ -46,6 +47,22 @@ export const readAutocompleteValue = async (options: SuggestOptions): Promise= [...state.value].length) { matches = await resolveSuggestions(options.options, state.value); diff --git a/packages/tui/tests/suggest.test.ts b/packages/tui/tests/suggest.test.ts index a72ad080..418dd954 100644 --- a/packages/tui/tests/suggest.test.ts +++ b/packages/tui/tests/suggest.test.ts @@ -182,4 +182,21 @@ describe('autocomplete prompt', () => { expect(result).toBe('Black'); }); + + it('supports page autocomplete navigation before accepting completion', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['b', Key.pageDown, Key.pageUp, Key.pageDown, Key.tab, Key.enter]), + output, + error: output, + interactive: true, + }, + () => autocomplete({ message: 'Favorite color?', options: ['Blue', 'Black', 'Blurple'], scroll: 2 }), + ); + + expect(result).toBe('Blurple'); + expect(output.text()).toContain('Favorite color? Blurple'); + }); }); From 6d279915c696006d9e6dbefa5114a14bd25722b1 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 17:11:15 +0800 Subject: [PATCH 080/675] Add spinner label overload --- packages/tui/src/status/spinner.ts | 23 +++++++++++---- packages/tui/tests/spinner.test.ts | 45 ++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 5 deletions(-) create mode 100644 packages/tui/tests/spinner.test.ts diff --git a/packages/tui/src/status/spinner.ts b/packages/tui/src/status/spinner.ts index 8132c77c..dd329112 100644 --- a/packages/tui/src/status/spinner.ts +++ b/packages/tui/src/status/spinner.ts @@ -1,17 +1,30 @@ import { promptEnvironment } from '#tui/environment'; import type { MaybePromise, StatusOptions } from '#tui/types'; -export const spin = async (callback: () => MaybePromise, options: StatusOptions = { message: 'Loading' }): Promise => { - promptEnvironment().output.write(`${options.message}...\n`); +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: 'Loading' }): Promise { + const callback = typeof callbackOrMessage === 'string' ? optionsOrCallback : callbackOrMessage; + const options = typeof callbackOrMessage === 'string' ? { message: callbackOrMessage } : optionsOrCallback; + + if (typeof callback !== 'function') { + throw new Error('A spinner callback is required.'); + } + + const message = typeof options === 'function' ? 'Loading' : options.message; + + promptEnvironment().output.write(`${message}...\n`); try { const result = await callback(); - promptEnvironment().output.write(`Done: ${options.message}\n`); + promptEnvironment().output.write(`Done: ${message}\n`); return result; } catch (error) { - promptEnvironment().error.write(`Failed: ${options.message}\n`); + promptEnvironment().error.write(`Failed: ${message}\n`); throw error; } -}; +} diff --git a/packages/tui/tests/spinner.test.ts b/packages/tui/tests/spinner.test.ts new file mode 100644 index 00000000..54298e9b --- /dev/null +++ b/packages/tui/tests/spinner.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import { createMemoryOutput, spin, withPromptEnvironment } from '#tui/index'; + +describe('spinner helper', () => { + it('runs callback-first spinner callbacks', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment({ output, error: output }, async () => { + return spin(async () => 'done', { message: 'Working' }); + }); + + expect(result).toBe('done'); + expect(output.text()).toContain('Working...'); + expect(output.text()).toContain('Done: Working'); + }); + + it('runs label-first spinner callbacks', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment({ output, error: output }, async () => { + return spin('Working', async () => 'done'); + }); + + expect(result).toBe('done'); + expect(output.text()).toContain('Working...'); + expect(output.text()).toContain('Done: Working'); + }); + + it('writes failure output before rethrowing spinner errors', async () => { + const output = createMemoryOutput(); + const failure = new Error('failed'); + + await expect( + withPromptEnvironment({ output, error: output }, async () => { + await spin('Working', async () => { + throw failure; + }); + }), + ).rejects.toBe(failure); + + expect(output.text()).toContain('Working...'); + expect(output.text()).toContain('Failed: Working'); + expect(output.text()).not.toContain('Done: Working'); + }); +}); From 095fd6ebf383299452e107ea335f210c0b964e5a Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 17:13:52 +0800 Subject: [PATCH 081/675] Align form status helpers --- packages/tui/src/form/builder/status.ts | 2 +- packages/tui/tests/form.test.ts | 23 +++++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/packages/tui/src/form/builder/status.ts b/packages/tui/src/form/builder/status.ts index a976c1fb..33ffeac0 100644 --- a/packages/tui/src/form/builder/status.ts +++ b/packages/tui/src/form/builder/status.ts @@ -15,7 +15,7 @@ export const statusBuilderMethods: StatusBuilderMethods & ThisType progress(label: string, steps: Iterable | number, callback?: (step: T | number, bar: Progress) => MaybePromise, hint = '', name?: string) { return this.add(() => progress(label, steps, callback, hint), name, true); }, - spin(callback: () => MaybePromise, message = '', name?: string) { + spin(callback: () => MaybePromise, message = 'Loading', name?: string) { return this.add(() => spin(callback, { message }), name, true); }, stream(source: AsyncIterable | Iterable, name?: string) { diff --git a/packages/tui/tests/form.test.ts b/packages/tui/tests/form.test.ts index 92c31aa4..7f90327b 100644 --- a/packages/tui/tests/form.test.ts +++ b/packages/tui/tests/form.test.ts @@ -177,6 +177,29 @@ describe('form builder', () => { expect(output.text()).toContain('line one'); }); + it('runs spin and progress form steps', async () => { + const output = createMemoryOutput(); + + const responses = await withPromptEnvironment( + { + output, + error: output, + }, + () => + form() + .spin(async () => 'spun', undefined, 'spin') + .progress('Files', [1, 2], (step) => Number(step) * 2, '', 'progress') + .submit(), + ); + + expect(responses.spin).toBe('spun'); + expect(responses.progress).toEqual([2, 4]); + expect(output.text()).toContain('Loading...'); + expect(output.text()).toContain('Done: Loading'); + expect(output.text()).toContain('Files'); + expect(output.text()).toContain('2 / 2'); + }); + it('stores output helper success responses', async () => { const output = createMemoryOutput(); From 67609f6e1a5521cf192d1a3122f9c5d804db968e Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 17:16:16 +0800 Subject: [PATCH 082/675] Support decimal number steps --- packages/tui/src/prompts/number/input.ts | 4 +-- packages/tui/tests/number.test.ts | 34 ++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/packages/tui/src/prompts/number/input.ts b/packages/tui/src/prompts/number/input.ts index 1dc9e68b..446540c3 100644 --- a/packages/tui/src/prompts/number/input.ts +++ b/packages/tui/src/prompts/number/input.ts @@ -22,7 +22,7 @@ const clamp = (value: number, min?: number, max?: number): number => { }; const steppedValue = (value: string, direction: 1 | -1, options: NumberInputOptions): string => { - const step = Math.max(1, options.step ?? 1); + const step = options.step !== undefined && options.step > 0 ? options.step : 1; if (value === '') { return String(direction === 1 ? (options.min ?? 1) : (options.max ?? 0)); @@ -32,7 +32,7 @@ const steppedValue = (value: string, direction: 1 | -1, options: NumberInputOpti return value; } - return String(clamp(Math.trunc(Number(value)) + step * direction, options.min, options.max)); + return String(clamp(Number(value) + step * direction, options.min, options.max)); }; const renderNumberValue = (message: string, value: string, options: NumberInputOptions): void => { diff --git a/packages/tui/tests/number.test.ts b/packages/tui/tests/number.test.ts index bedd7511..6541dd65 100644 --- a/packages/tui/tests/number.test.ts +++ b/packages/tui/tests/number.test.ts @@ -161,6 +161,40 @@ describe('number prompt', () => { expect(output.text()).toContain('? Count 1'); }); + it('increments and decrements decimal values with fractional steps', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.up, Key.down, Key.down, Key.enter]), + output, + error: output, + interactive: true, + }, + () => number({ message: 'Amount', default: 1.5, step: 0.25 }), + ); + + expect(result).toBe(1.25); + expect(output.text()).toContain('? Amount 1.75'); + expect(output.text()).toContain('? Amount 1.25'); + }); + + it('falls back to a whole step for invalid step sizes', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.up, Key.enter]), + output, + error: output, + interactive: true, + }, + () => number({ message: 'Amount', default: 1.5, step: 0 }), + ); + + expect(result).toBe(2.5); + }); + it('renders default number values before input', async () => { const output = createMemoryOutput(); From c01b39f641f0aea592f35995e37648dc43b2149c Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 17:18:32 +0800 Subject: [PATCH 083/675] Clamp grid column width --- packages/tui/src/output/table.ts | 2 +- packages/tui/tests/output.test.ts | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/tui/src/output/table.ts b/packages/tui/src/output/table.ts index a81b3de4..908b1a60 100644 --- a/packages/tui/src/output/table.ts +++ b/packages/tui/src/output/table.ts @@ -36,7 +36,7 @@ export const table = (headersOrOptions: TableOptions | string[] = [], rows: Tabl }; export const grid = (items: Array = [], maxWidth?: number): boolean => { - const width = maxWidth ?? 4; + const width = Math.max(1, maxWidth ?? 4); const rows: string[][] = []; for (let index = 0; index < items.length; index += width) { diff --git a/packages/tui/tests/output.test.ts b/packages/tui/tests/output.test.ts index 631e485d..55033e2a 100644 --- a/packages/tui/tests/output.test.ts +++ b/packages/tui/tests/output.test.ts @@ -30,4 +30,16 @@ describe('output helpers', () => { expect(output.text()).toContain('| Name'); expect(output.text()).toContain('A B'); }); + + it('keeps grid rendering finite for invalid widths', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + expect(grid(['A', 'B'], 0)).toBe(true); + expect(grid(['C', 'D'], -2)).toBe(true); + }); + + expect(output.text()).toContain('A\nB'); + expect(output.text()).toContain('C\nD'); + }); }); From 685115abfad21a5c35f53f3f90a92f0576bf3edb Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 17:20:54 +0800 Subject: [PATCH 084/675] Normalize grid column width --- packages/tui/src/output/table.ts | 2 +- packages/tui/tests/output.test.ts | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/tui/src/output/table.ts b/packages/tui/src/output/table.ts index 908b1a60..3591e5bb 100644 --- a/packages/tui/src/output/table.ts +++ b/packages/tui/src/output/table.ts @@ -36,7 +36,7 @@ export const table = (headersOrOptions: TableOptions | string[] = [], rows: Tabl }; export const grid = (items: Array = [], maxWidth?: number): boolean => { - const width = Math.max(1, maxWidth ?? 4); + const width = Math.max(1, Math.trunc(maxWidth ?? 4)); const rows: string[][] = []; for (let index = 0; index < items.length; index += width) { diff --git a/packages/tui/tests/output.test.ts b/packages/tui/tests/output.test.ts index 55033e2a..fa98e05b 100644 --- a/packages/tui/tests/output.test.ts +++ b/packages/tui/tests/output.test.ts @@ -42,4 +42,14 @@ describe('output helpers', () => { expect(output.text()).toContain('A\nB'); expect(output.text()).toContain('C\nD'); }); + + it('normalizes fractional grid widths without overlapping rows', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + expect(grid(['A', 'B', 'C', 'D'], 2.5)).toBe(true); + }); + + expect(output.text()).toBe('A B\nC D\n'); + }); }); From fe48bbced615b85db032fce2365d79738400234a Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 17:24:44 +0800 Subject: [PATCH 085/675] Support password defaults --- packages/tui/src/prompts/password.ts | 1 + packages/tui/src/prompts/password/input.ts | 9 +++-- packages/tui/tests/password.test.ts | 38 ++++++++++++++++++++++ 3 files changed, 45 insertions(+), 3 deletions(-) diff --git a/packages/tui/src/prompts/password.ts b/packages/tui/src/prompts/password.ts index 761b04df..4a548566 100644 --- a/packages/tui/src/prompts/password.ts +++ b/packages/tui/src/prompts/password.ts @@ -26,6 +26,7 @@ export async function password( return promptUntilValid(options, async () => { const value = await readPasswordValue(options.message, { + default: options.default, hint: options.hint, placeholder: options.placeholder, }); diff --git a/packages/tui/src/prompts/password/input.ts b/packages/tui/src/prompts/password/input.ts index 5bfd8f63..cf9a845c 100644 --- a/packages/tui/src/prompts/password/input.ts +++ b/packages/tui/src/prompts/password/input.ts @@ -4,6 +4,7 @@ import { renderQuestion } from '#tui/theme'; import { applyTypedKey } from '#tui/typed-value'; type PasswordInputOptions = { + default?: string; hint?: string; placeholder?: string; }; @@ -24,12 +25,14 @@ export const readPasswordValue = async (message: string, options: PasswordInputO throw new PromptValidationError('The configured prompt input cannot read input.'); } - return environment.input.readLine(renderQuestion(message, options.hint)); + const answer = await environment.input.readLine(renderQuestion(message, options.hint)); + + return answer === '' && options.default !== undefined ? options.default : answer; } let state = { - cursor: 0, - value: '', + cursor: options.default?.length ?? 0, + value: options.default ?? '', }; renderPasswordValue(message, state.value, options); diff --git a/packages/tui/tests/password.test.ts b/packages/tui/tests/password.test.ts index 1486ce71..6d4f07b2 100644 --- a/packages/tui/tests/password.test.ts +++ b/packages/tui/tests/password.test.ts @@ -35,4 +35,42 @@ describe('password prompt', () => { expect(output.text()).toContain('? Password Required'); }); + + it('returns object-option defaults without rendering the raw value', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.enter]), + output, + error: output, + interactive: true, + }, + () => password({ message: 'Password', default: 'secret' }), + ); + + expect(result).toBe('secret'); + expect(output.text()).not.toContain('secret'); + expect(output.text()).toContain('••••••'); + }); + + it('edits object-option defaults without rendering the raw value', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.backspace, 'x', Key.enter]), + output, + error: output, + interactive: true, + }, + () => password({ message: 'Password', default: 'secret' }), + ); + + expect(result).toBe('secrex'); + expect(output.text()).not.toContain('secret'); + expect(output.text()).not.toContain('secrex'); + expect(output.text()).toContain('•••••'); + expect(output.text()).toContain('••••••'); + }); }); From cf03283b2c82f850590d4dae23baf6d2bcb74c45 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 17:27:54 +0800 Subject: [PATCH 086/675] Respect search line defaults --- .../tui/src/prompts/search/read-single.ts | 6 ++++- packages/tui/tests/search.test.ts | 25 +++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/packages/tui/src/prompts/search/read-single.ts b/packages/tui/src/prompts/search/read-single.ts index 1dbc2b81..ecd7e0c8 100644 --- a/packages/tui/src/prompts/search/read-single.ts +++ b/packages/tui/src/prompts/search/read-single.ts @@ -16,9 +16,13 @@ export const readSearchChoice = async (options: SearchPromptOptions, attem const choices = await resolveSearchChoices(options.options, query); + if (query.trim() === '' && options.default !== undefined) { + return options.default; + } + const choice = findChoice(choices, query) ?? choices.find((candidate) => !candidate.disabled); - return choice?.value ?? options.default ?? choices[0]?.value; + return choice?.value ?? choices[0]?.value; } let state = { cursor: 0, value: '' }; diff --git a/packages/tui/tests/search.test.ts b/packages/tui/tests/search.test.ts index 0da2d5b2..7ee62ed8 100644 --- a/packages/tui/tests/search.test.ts +++ b/packages/tui/tests/search.test.ts @@ -219,6 +219,31 @@ describe('search prompt', () => { expect(result).toBe('RED'); }); + + it('returns search defaults for empty line-mode answers', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: { + async readLine(): Promise { + return ''; + }, + }, + output, + error: output, + interactive: true, + }, + () => + search({ + message: 'Favorite color?', + options: colors, + default: 'green', + }), + ); + + expect(result).toBe('green'); + }); }); describe('multisearch prompt', () => { From 7dc0db7cf586c94a8bd1c00b7639af5158d799a6 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 17:29:59 +0800 Subject: [PATCH 087/675] Track stream lifecycle --- packages/tui/src/status/stream.ts | 19 ++++++++++++++++--- packages/tui/tests/stream.test.ts | 31 +++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/packages/tui/src/status/stream.ts b/packages/tui/src/status/stream.ts index a604e7f4..586b01d3 100644 --- a/packages/tui/src/status/stream.ts +++ b/packages/tui/src/status/stream.ts @@ -1,9 +1,14 @@ import { promptEnvironment } from '#tui/environment'; export class Stream { + #closed = false; #value = ''; write(content: string): this { + if (this.#closed) { + throw new Error('Stream is closed.'); + } + this.#value += content; promptEnvironment().output.write(content); @@ -15,7 +20,11 @@ export class Stream { } close(): void { - // + this.#closed = true; + } + + closed(): boolean { + return this.#closed; } lines(): string[] { @@ -23,8 +32,12 @@ export class Stream { } async pipe(source: AsyncIterable | Iterable): Promise { - for await (const chunk of source) { - this.write(chunk); + try { + for await (const chunk of source) { + this.write(chunk); + } + } finally { + this.close(); } } diff --git a/packages/tui/tests/stream.test.ts b/packages/tui/tests/stream.test.ts index a20c1100..c74ffc99 100644 --- a/packages/tui/tests/stream.test.ts +++ b/packages/tui/tests/stream.test.ts @@ -14,6 +14,7 @@ describe('stream helper', () => { expect(outputStream.value()).toBe('hello world'); expect(outputStream.lines()).toEqual(['hello world']); + expect(outputStream.closed()).toBe(true); }); expect(output.text()).toBe('hello world'); @@ -29,6 +30,36 @@ describe('stream helper', () => { expect(output.text()).toBe('one\ntwo'); }); + it('closes piped streams after the source is exhausted', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + const outputStream = stream(); + + await outputStream.pipe(['done']); + + expect(outputStream.closed()).toBe(true); + expect(outputStream.value()).toBe('done'); + expect(() => outputStream.write(' again')).toThrow('Stream is closed.'); + }); + }); + + it('rejects writes after close without losing buffered content', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + const outputStream = stream(); + + outputStream.write('finished'); + outputStream.close(); + + expect(() => outputStream.append(' later')).toThrow('Stream is closed.'); + expect(outputStream.value()).toBe('finished'); + }); + + expect(output.text()).toBe('finished'); + }); + it('cannot be prompted for input', async () => { const output = createMemoryOutput(); From 484ae590c12f544e5ac6dc6e741a016b085662ce Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 17:32:36 +0800 Subject: [PATCH 088/675] Validate table option shapes --- packages/tui/src/output/table.ts | 16 ++---------- packages/tui/src/output/validators/table.ts | 29 +++++++++++++++++++++ packages/tui/tests/table.test.ts | 26 ++++++++++++++++++ 3 files changed, 57 insertions(+), 14 deletions(-) create mode 100644 packages/tui/src/output/validators/table.ts diff --git a/packages/tui/src/output/table.ts b/packages/tui/src/output/table.ts index 3591e5bb..08ea5897 100644 --- a/packages/tui/src/output/table.ts +++ b/packages/tui/src/output/table.ts @@ -1,4 +1,5 @@ import { promptEnvironment } from '#tui/environment'; +import { parseTableOptions } from '#tui/output/validators/table'; import { renderTable } from '#tui/theme'; import type { TableOptions } from '#tui/types'; @@ -6,21 +7,8 @@ const stringify = (value: string | number | boolean | null | undefined): string return value === null || value === undefined ? '' : String(value); }; -const normalizeTableOptions = (headersOrOptions: TableOptions | string[] = [], rows: TableOptions['rows'] | null = null): Required => { - if (!Array.isArray(headersOrOptions) && 'rows' in headersOrOptions) { - const headers = headersOrOptions.headers ?? Object.keys(headersOrOptions.rows[0] ?? {}); - - return { headers, rows: headersOrOptions.rows }; - } - - return { - headers: headersOrOptions, - rows: rows ?? [], - }; -}; - export const table = (headersOrOptions: TableOptions | string[] = [], rows: TableOptions['rows'] | null = null): boolean => { - const options = normalizeTableOptions(headersOrOptions, rows); + const options = parseTableOptions(headersOrOptions, rows); const normalizedRows = options.rows.map((row) => { if (Array.isArray(row)) { diff --git a/packages/tui/src/output/validators/table.ts b/packages/tui/src/output/validators/table.ts new file mode 100644 index 00000000..6c8a0f8f --- /dev/null +++ b/packages/tui/src/output/validators/table.ts @@ -0,0 +1,29 @@ +import { z } from 'zod'; +import type { TableCell, TableOptions } from '#tui/types'; + +const tableCellSchema: z.ZodType = z.union([z.string(), z.number(), z.boolean(), z.null(), z.undefined()]); +const tableRowSchema = z.union([z.array(tableCellSchema), z.record(z.string(), tableCellSchema)]); +const tableRowsSchema = z.array(tableRowSchema); +const tableHeadersSchema = z.array(z.string()); + +const tableOptionsSchema = z + .object({ + headers: tableHeadersSchema.optional(), + rows: tableRowsSchema, + }) + .passthrough(); + +export const parseTableOptions = (headersOrOptions: unknown = [], rows: unknown = null): Required => { + const tableOptions = tableOptionsSchema.safeParse(headersOrOptions); + + if (tableOptions.success) { + const headers = tableOptions.data.headers ?? Object.keys(tableOptions.data.rows[0] ?? {}); + + return { headers, rows: tableOptions.data.rows }; + } + + return { + headers: tableHeadersSchema.parse(headersOrOptions), + rows: rows === null ? [] : tableRowsSchema.parse(rows), + }; +}; diff --git a/packages/tui/tests/table.test.ts b/packages/tui/tests/table.test.ts index 7b9ac6fe..152c1f0f 100644 --- a/packages/tui/tests/table.test.ts +++ b/packages/tui/tests/table.test.ts @@ -12,6 +12,32 @@ describe('table output', () => { expect(output.text()).toContain('| Name | Runtime |'); expect(output.text()).toContain('| Ollin | OpenTUI |'); }); + + it('renders object-form table options with inferred headers', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + table({ + rows: [ + { Name: 'Ollin', Runtime: 'OpenTUI' }, + { Name: 'Prompts', Runtime: 'TypeScript' }, + ], + }); + }); + + expect(output.text()).toContain('| Name | Runtime |'); + expect(output.text()).toContain('| Prompts | TypeScript |'); + }); + + it('rejects invalid table runtime shapes through the validator layer', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + expect(() => table(null as never)).toThrow(); + expect(() => table(['Name'], null as never)).not.toThrow(); + expect(() => table(['Name'], [null] as never)).toThrow(); + }); + }); }); describe('data table prompt', () => { From 34addd2dd0789fe55893111103804fdf33cc10b8 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 17:35:35 +0800 Subject: [PATCH 089/675] Validate form previous values --- packages/tui/src/form/builder/previous.ts | 8 +++-- .../src/form/builder/validators/previous.ts | 20 +++++++++++ packages/tui/tests/form.test.ts | 34 +++++++++++++++++++ 3 files changed, 59 insertions(+), 3 deletions(-) create mode 100644 packages/tui/src/form/builder/validators/previous.ts diff --git a/packages/tui/src/form/builder/previous.ts b/packages/tui/src/form/builder/previous.ts index fff4ecf8..62e38705 100644 --- a/packages/tui/src/form/builder/previous.ts +++ b/packages/tui/src/form/builder/previous.ts @@ -1,11 +1,13 @@ +import { parsePreviousArray, parsePreviousNumber, parsePreviousString } from '#tui/form/builder/validators/previous'; + export const previousString = (previous: unknown, defaultValue: string): string => { - return previous === undefined || previous === null ? defaultValue : String(previous); + return parsePreviousString(previous, defaultValue); }; export const previousNumber = (previous: unknown, defaultValue: number | string): number | string => { - return typeof previous === 'number' || typeof previous === 'string' ? previous : defaultValue; + return parsePreviousNumber(previous, defaultValue); }; export const previousArray = (previous: unknown, defaultValue: T[]): T[] => { - return Array.isArray(previous) ? (previous as T[]) : defaultValue; + return parsePreviousArray(previous, defaultValue); }; diff --git a/packages/tui/src/form/builder/validators/previous.ts b/packages/tui/src/form/builder/validators/previous.ts new file mode 100644 index 00000000..f062c81b --- /dev/null +++ b/packages/tui/src/form/builder/validators/previous.ts @@ -0,0 +1,20 @@ +import { z } from 'zod'; + +const previousNumberSchema = z.union([z.number(), z.string()]); +const previousArraySchema = z.array(z.unknown()); + +export const parsePreviousString = (previous: unknown, defaultValue: string): string => { + return previous === undefined || previous === null ? defaultValue : String(previous); +}; + +export const parsePreviousNumber = (previous: unknown, defaultValue: number | string): number | string => { + const parsed = previousNumberSchema.safeParse(previous); + + return parsed.success ? parsed.data : defaultValue; +}; + +export const parsePreviousArray = (previous: unknown, defaultValue: T[]): T[] => { + const parsed = previousArraySchema.safeParse(previous); + + return parsed.success ? (parsed.data as T[]) : defaultValue; +}; diff --git a/packages/tui/tests/form.test.ts b/packages/tui/tests/form.test.ts index 7f90327b..9a730afb 100644 --- a/packages/tui/tests/form.test.ts +++ b/packages/tui/tests/form.test.ts @@ -81,6 +81,40 @@ describe('form builder', () => { expect(responses[1]).toBe('D'); }); + it('reuses numeric previous responses when reverting prompt builder steps', async () => { + const output = createMemoryOutput(); + + const responses = await withPromptEnvironment( + { + input: createScriptedInput(['4', Key.enter, Key.ctrlU, '5', Key.enter, 'D', Key.enter]), + output, + error: output, + interactive: true, + }, + () => form().number('Count').text('Done').submit(), + ); + + expect(responses[0]).toBe(45); + expect(responses[1]).toBe('D'); + }); + + it('reuses array previous responses when reverting prompt builder steps', async () => { + const output = createMemoryOutput(); + + const responses = await withPromptEnvironment( + { + input: createScriptedInput([Key.space, Key.enter, Key.ctrlU, Key.enter, 'D', Key.enter]), + output, + error: output, + interactive: true, + }, + () => form().multiselect('Flags', ['alpha', 'beta']).text('Done').submit(), + ); + + expect(responses[0]).toEqual(['alpha']); + expect(responses[1]).toBe('D'); + }); + it('does not revert the first form step', async () => { const output = createMemoryOutput(); From 9cdd9ef1f0f3ac0ce919bce61429cf709c98e507 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 17:38:49 +0800 Subject: [PATCH 090/675] Verify package entrypoint consumption --- packages/acceptance/tests/package.test.ts | 28 +++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 packages/acceptance/tests/package.test.ts diff --git a/packages/acceptance/tests/package.test.ts b/packages/acceptance/tests/package.test.ts new file mode 100644 index 00000000..91b69202 --- /dev/null +++ b/packages/acceptance/tests/package.test.ts @@ -0,0 +1,28 @@ +import { execFileSync } from 'node:child_process'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +const testPath = dirname(fileURLToPath(import.meta.url)); +const acceptancePath = resolve(testPath, '..'); +const workspacePath = resolve(acceptancePath, '../..'); + +describe('package consumption', () => { + it('imports the built public entrypoint through ESM package resolution', () => { + execFileSync('pnpm', ['--filter', '@ollin/tui', 'build'], { + cwd: workspacePath, + stdio: 'pipe', + }); + + const output = execFileSync( + process.execPath, + ['--input-type=module', '-e', "const module = await import('@ollin/tui'); console.log([typeof module.text, typeof module.table, typeof module.stream].join(','));"], + { + cwd: acceptancePath, + encoding: 'utf8', + }, + ); + + expect(output.trim()).toBe('function,function,function'); + }); +}); From a04fd591d395f9c97c3b942357c8ed3df3782768 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 17:41:13 +0800 Subject: [PATCH 091/675] Normalize terminal key aliases --- packages/tui/src/key.ts | 14 ++++++++++++++ packages/tui/tests/key.test.ts | 6 ++++++ 2 files changed, 20 insertions(+) diff --git a/packages/tui/src/key.ts b/packages/tui/src/key.ts index 39b9f695..740ea5fb 100644 --- a/packages/tui/src/key.ts +++ b/packages/tui/src/key.ts @@ -58,6 +58,12 @@ const ctrlKeyMap: Record = { u: Key.ctrlU, }; +const namedKeyMap: Record = { + pagedown: Key.pageDown, + pageup: Key.pageUp, + return: Key.enter, +}; + export const keyFromEvent = (event: KeyboardEventLike): KeyName | string => { if (event.ctrl && event.name) { const key = ctrlKeyMap[event.name.toLowerCase()]; @@ -71,6 +77,14 @@ export const keyFromEvent = (event: KeyboardEventLike): KeyName | string => { return Key.optionBackspace; } + if (event.name) { + const key = namedKeyMap[event.name.toLowerCase()]; + + if (key !== undefined) { + return key; + } + } + if (event.name && event.name in Key) { const key = Key[event.name as keyof typeof Key]; diff --git a/packages/tui/tests/key.test.ts b/packages/tui/tests/key.test.ts index 66f6c958..906cfee5 100644 --- a/packages/tui/tests/key.test.ts +++ b/packages/tui/tests/key.test.ts @@ -6,6 +6,12 @@ describe('keyboard mapping', () => { expect(keyFromEvent({ name: 'enter' })).toBe(Key.enter); }); + it('maps terminal key name aliases', () => { + expect(keyFromEvent({ name: 'return' })).toBe(Key.enter); + expect(keyFromEvent({ name: 'pageup' })).toBe(Key.pageUp); + expect(keyFromEvent({ name: 'pagedown' })).toBe(Key.pageDown); + }); + it('maps space and newline sequences', () => { expect(keyFromEvent({ sequence: ' ' })).toBe(Key.space); expect(keyFromEvent({ sequence: '\n' })).toBe(Key.enter); From 9999100f4bbebbc9f8d8941b4dd8b27f65b43001 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 17:43:41 +0800 Subject: [PATCH 092/675] Clamp task log limits --- packages/tui/src/status/task.ts | 9 ++++----- packages/tui/src/status/validators/limit.ts | 13 +++++++++++++ packages/tui/tests/task.test.ts | 20 ++++++++++++++++++++ 3 files changed, 37 insertions(+), 5 deletions(-) create mode 100644 packages/tui/src/status/validators/limit.ts diff --git a/packages/tui/src/status/task.ts b/packages/tui/src/status/task.ts index f7777f59..df28053f 100644 --- a/packages/tui/src/status/task.ts +++ b/packages/tui/src/status/task.ts @@ -1,4 +1,5 @@ import { promptEnvironment } from '#tui/environment'; +import { parseLogLimit } from '#tui/status/validators/limit'; import type { MaybePromise } from '#tui/types'; const cursorReset = `${String.fromCharCode(27)}[1G`; @@ -24,12 +25,10 @@ export class Logger { subLabelValue: string; #partialBuffer = ''; #partialStartIndex: number | null = null; + private readonly limit: number; - constructor( - private readonly limit: number, - label: string, - subLabel = '', - ) { + constructor(limit: number, label: string, subLabel = '') { + this.limit = parseLogLimit(limit, 10); this.labelValue = label; this.subLabelValue = subLabel; } diff --git a/packages/tui/src/status/validators/limit.ts b/packages/tui/src/status/validators/limit.ts new file mode 100644 index 00000000..cd87b7b2 --- /dev/null +++ b/packages/tui/src/status/validators/limit.ts @@ -0,0 +1,13 @@ +import { z } from 'zod'; + +const limitSchema = z.number().finite(); + +export const parseLogLimit = (limit: unknown, defaultValue: number): number => { + const parsed = limitSchema.safeParse(limit); + + if (!parsed.success) { + return defaultValue; + } + + return Math.max(0, Math.trunc(parsed.data)); +}; diff --git a/packages/tui/tests/task.test.ts b/packages/tui/tests/task.test.ts index 59fe8603..fdb12987 100644 --- a/packages/tui/tests/task.test.ts +++ b/packages/tui/tests/task.test.ts @@ -26,6 +26,26 @@ describe('task helper', () => { expect(output.text()).toContain('line three'); }); + it('keeps task logging finite for invalid limits', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + await task( + 'Running...', + (logger) => { + logger.log('line one'); + logger.log('line two'); + }, + -1, + ); + }); + + expect(output.text()).toContain('Running...'); + expect(output.text()).not.toContain('line one'); + expect(output.text()).not.toContain('line two'); + expect(output.text()).toContain('Done: Running...'); + }); + it('strips cursor reset control sequences from log lines', async () => { const output = createMemoryOutput(); From b9de64a037cd92fe3ba16f5ad948dcc01bdb06fb Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 17:46:00 +0800 Subject: [PATCH 093/675] Validate progress counts --- packages/tui/src/status/progress.ts | 17 ++++++--------- .../tui/src/status/validators/progress.ts | 20 ++++++++++++++++++ packages/tui/tests/progress.test.ts | 21 +++++++++++++++++++ 3 files changed, 47 insertions(+), 11 deletions(-) create mode 100644 packages/tui/src/status/validators/progress.ts diff --git a/packages/tui/src/status/progress.ts b/packages/tui/src/status/progress.ts index e929cf99..6696e1e3 100644 --- a/packages/tui/src/status/progress.ts +++ b/packages/tui/src/status/progress.ts @@ -1,20 +1,15 @@ import { promptEnvironment } from '#tui/environment'; +import { parseProgressStep, parseProgressTotal } from '#tui/status/validators/progress'; import type { MaybePromise } from '#tui/types'; export class Progress { #current = 0; #label: string; #hint: string; + readonly total: number; - constructor( - readonly total: number, - message = 'Progress', - hint = '', - ) { - if (total <= 0) { - throw new Error('Progress bar must have at least one item.'); - } - + constructor(total: number, message = 'Progress', hint = '') { + this.total = parseProgressTotal(total); this.#label = message; this.#hint = hint; } @@ -24,7 +19,7 @@ export class Progress { } advance(step = 1): void { - this.#current = Math.max(0, Math.min(this.total, this.#current + step)); + this.#current = Math.max(0, Math.min(this.total, this.#current + parseProgressStep(step))); this.render(); } @@ -87,7 +82,7 @@ export function progress( throw new Error('Progress steps must be an iterable or a number.'); } - const values = typeof steps === 'number' ? Array.from({ length: steps }, (_, index) => index) : Array.from(steps); + const values = typeof steps === 'number' ? Array.from({ length: parseProgressTotal(steps) }, (_, index) => index) : Array.from(steps); const bar = new Progress(values.length, labelOrTotal, hint); if (!callback) { diff --git a/packages/tui/src/status/validators/progress.ts b/packages/tui/src/status/validators/progress.ts new file mode 100644 index 00000000..c8ffb33c --- /dev/null +++ b/packages/tui/src/status/validators/progress.ts @@ -0,0 +1,20 @@ +import { z } from 'zod'; + +const progressNumberSchema = z.number().finite(); + +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 ? parsed.data : 0; +}; diff --git a/packages/tui/tests/progress.test.ts b/packages/tui/tests/progress.test.ts index 32e91bd3..6a73a78c 100644 --- a/packages/tui/tests/progress.test.ts +++ b/packages/tui/tests/progress.test.ts @@ -75,4 +75,25 @@ describe('progress helper', () => { expect(output.text()).toContain('2 / 2'); expect(output.text()).toContain('0 / 2'); }); + + it('normalizes fractional totals and ignores non-finite advance values', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + const bar = progress(2.9, 'Adding States'); + + expect(bar.total).toBe(2); + + bar.advance(Number.NaN); + + expect(bar.current()).toBe(0); + }); + + expect(output.text()).toContain('0 / 2'); + }); + + it('rejects invalid numeric progress step counts before rendering', () => { + expect(() => progress('Adding States', -1)).toThrow('Progress bar must have at least one item.'); + expect(() => progress('Adding States', Number.POSITIVE_INFINITY)).toThrow('Progress bar must have at least one item.'); + }); }); From 12a575b0cae0e91ca6d21f9a3f7bffe8613d9172 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 17:49:09 +0800 Subject: [PATCH 094/675] Normalize scroll sizes --- packages/tui/src/concerns/choices.ts | 11 +++++--- .../tui/src/concerns/validators/scroll.ts | 13 +++++++++ packages/tui/src/output/data-table.ts | 15 ++++++---- packages/tui/src/prompts/select/navigation.ts | 3 +- packages/tui/tests/choices.test.ts | 22 +++++++++++++++ packages/tui/tests/table.test.ts | 28 +++++++++++++++++++ 6 files changed, 81 insertions(+), 11 deletions(-) create mode 100644 packages/tui/src/concerns/validators/scroll.ts diff --git a/packages/tui/src/concerns/choices.ts b/packages/tui/src/concerns/choices.ts index 9a97f1c8..c7c18fd3 100644 --- a/packages/tui/src/concerns/choices.ts +++ b/packages/tui/src/concerns/choices.ts @@ -1,5 +1,6 @@ import { promptEnvironment } from '#tui/environment'; import { parseChoice } from '#tui/concerns/validators/choice'; +import { parseOptionalScrollSize } from '#tui/concerns/validators/scroll'; import type { Choice, ChoiceInput } from '#tui/types'; export const normalizeChoices = (options: Array>): Array> => { @@ -63,14 +64,16 @@ export const nextEnabledIndex = (choices: Array>, current: number, }; const choiceWindow = (total: number, selected: number, scroll?: number): { end: number; start: number } => { - if (scroll === undefined || scroll <= 0 || scroll >= total) { + const size = parseOptionalScrollSize(scroll); + + if (size === undefined || size >= total) { return { end: total, start: 0 }; } - const before = Math.floor((scroll - 1) / 2); - const start = Math.max(0, Math.min(selected - before, total - scroll)); + const before = Math.floor((size - 1) / 2); + const start = Math.max(0, Math.min(selected - before, total - size)); - return { end: start + scroll, start }; + return { end: start + size, start }; }; export const renderInteractiveChoices = (message: string, choices: Array>, selected: number, marked: Set = new Set(), scroll?: number): void => { diff --git a/packages/tui/src/concerns/validators/scroll.ts b/packages/tui/src/concerns/validators/scroll.ts new file mode 100644 index 00000000..89dcc20b --- /dev/null +++ b/packages/tui/src/concerns/validators/scroll.ts @@ -0,0 +1,13 @@ +import { z } from 'zod'; + +const scrollSchema = z.number().finite().positive(); + +export const parseOptionalScrollSize = (scroll: unknown): number | undefined => { + const parsed = scrollSchema.safeParse(scroll); + + return parsed.success ? Math.max(1, Math.trunc(parsed.data)) : undefined; +}; + +export const parseScrollSize = (scroll: unknown, defaultValue: number): number => { + return parseOptionalScrollSize(scroll) ?? defaultValue; +}; diff --git a/packages/tui/src/output/data-table.ts b/packages/tui/src/output/data-table.ts index d6e9aa56..3534f7fe 100644 --- a/packages/tui/src/output/data-table.ts +++ b/packages/tui/src/output/data-table.ts @@ -1,5 +1,6 @@ import { promptEnvironment } from '#tui/environment'; import { Key, oneOf } from '#tui/key'; +import { parseOptionalScrollSize, parseScrollSize } from '#tui/concerns/validators/scroll'; import { promptUntilValid, PromptValidationError } from '#tui/prompt'; import { renderTable } from '#tui/theme'; import { isDataObjectRow } from '#tui/output/validators/data-table'; @@ -73,14 +74,16 @@ const clampSelected = (selected: number, rows: Array>): number }; const rowWindow = (total: number, selected: number, scroll?: number): { end: number; start: number } => { - if (scroll === undefined || scroll <= 0 || scroll >= total) { + const size = parseOptionalScrollSize(scroll); + + if (size === undefined || size >= total) { return { end: total, start: 0 }; } - const before = Math.floor((scroll - 1) / 2); - const start = Math.max(0, Math.min(selected - before, total - scroll)); + const before = Math.floor((size - 1) / 2); + const start = Math.max(0, Math.min(selected - before, total - size)); - return { end: start + scroll, start }; + return { end: start + size, start }; }; const isPrintable = (key: string): boolean => { @@ -194,13 +197,13 @@ export const datatable = async (options: DataTablePromptOptions) } if (key === Key.pageDown) { - selected = rows.length === 0 ? 0 : Math.min(rows.length - 1, selected + (options.scroll ?? 10)); + selected = rows.length === 0 ? 0 : Math.min(rows.length - 1, selected + parseScrollSize(options.scroll, 10)); render(); continue; } if (key === Key.pageUp) { - selected = Math.max(0, selected - (options.scroll ?? 10)); + selected = Math.max(0, selected - parseScrollSize(options.scroll, 10)); render(); continue; } diff --git a/packages/tui/src/prompts/select/navigation.ts b/packages/tui/src/prompts/select/navigation.ts index d2f94b7e..bd13c7c9 100644 --- a/packages/tui/src/prompts/select/navigation.ts +++ b/packages/tui/src/prompts/select/navigation.ts @@ -1,4 +1,5 @@ import { Key } from '#tui/key'; +import { parseScrollSize } from '#tui/concerns/validators/scroll'; import type { Choice } from '#tui/types'; export const parseChoiceIndex = (key: string): number => (/^\d+$/u.test(key) ? Number.parseInt(key, 10) : Number.NaN); @@ -22,7 +23,7 @@ export const lastEnabledChoiceIndex = (choices: Array>): number => }; export const pageSize = (scroll?: number): number => { - return Math.max(1, scroll ?? 10); + return parseScrollSize(scroll, 10); }; export const pageIndex = (total: number, current: number, direction: 1 | -1, scroll?: number): number => { diff --git a/packages/tui/tests/choices.test.ts b/packages/tui/tests/choices.test.ts index 79664081..110324c6 100644 --- a/packages/tui/tests/choices.test.ts +++ b/packages/tui/tests/choices.test.ts @@ -184,6 +184,28 @@ describe('choice prompts', () => { expect(result).toBe('third'); }); + it('normalizes fractional scroll sizes for select page navigation', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.pageDown, Key.enter]), + output, + error: output, + interactive: true, + }, + () => select({ message: 'Pick one', options: ['first', 'second', 'third', 'fourth'], scroll: 2.9 }), + ); + + const latestFrame = output.text().split('Pick one\n').at(-1) ?? ''; + + expect(result).toBe('third'); + expect(latestFrame).not.toContain('first'); + expect(latestFrame).not.toContain('second'); + expect(latestFrame).toContain('third'); + expect(latestFrame).toContain('fourth'); + }); + it('renders select info for the highlighted option', async () => { const output = createMemoryOutput(); diff --git a/packages/tui/tests/table.test.ts b/packages/tui/tests/table.test.ts index 152c1f0f..d62b6cfc 100644 --- a/packages/tui/tests/table.test.ts +++ b/packages/tui/tests/table.test.ts @@ -226,6 +226,34 @@ describe('data table prompt', () => { expect(endHomeResult).toBe(0); }); + it('normalizes fractional scroll sizes for data table page navigation', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.pageDown, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + datatable({ + message: 'Pick row', + headers: ['Name'], + rows: [['First'], ['Second'], ['Third'], ['Fourth']], + scroll: 2.9, + }), + ); + + const latestFrame = output.text().split('Pick row\n').at(-1) ?? ''; + + expect(result).toBe(2); + expect(latestFrame).not.toContain('First'); + expect(latestFrame).not.toContain('Second'); + expect(latestFrame).toContain('Third'); + expect(latestFrame).toContain('Fourth'); + }); + it('works from form builders', async () => { const output = createMemoryOutput(); From 4d55397c1dfef4a1795a75cd43a538add72d8cb5 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 17:51:40 +0800 Subject: [PATCH 095/675] Handle Unicode typed cursors --- packages/tui/src/prompts/password/input.ts | 3 ++- .../tui/src/prompts/suggest/read-autocomplete.ts | 8 +++++--- packages/tui/src/prompts/suggest/read.ts | 4 +++- packages/tui/src/typed-value.ts | 8 +++++--- packages/tui/tests/typed-value.test.ts | 14 ++++++++++++++ 5 files changed, 29 insertions(+), 8 deletions(-) diff --git a/packages/tui/src/prompts/password/input.ts b/packages/tui/src/prompts/password/input.ts index cf9a845c..edff94c3 100644 --- a/packages/tui/src/prompts/password/input.ts +++ b/packages/tui/src/prompts/password/input.ts @@ -10,6 +10,7 @@ type PasswordInputOptions = { }; const mask = (value: string): string => '•'.repeat([...value].length); +const characterLength = (value: string): number => [...value].length; const renderPasswordValue = (message: string, stateValue: string, options: PasswordInputOptions): void => { const value = stateValue.length > 0 ? mask(stateValue) : (options.placeholder ?? ''); @@ -31,7 +32,7 @@ export const readPasswordValue = async (message: string, options: PasswordInputO } let state = { - cursor: options.default?.length ?? 0, + cursor: characterLength(options.default ?? ''), value: options.default ?? '', }; diff --git a/packages/tui/src/prompts/suggest/read-autocomplete.ts b/packages/tui/src/prompts/suggest/read-autocomplete.ts index 5d88a37c..b401f54a 100644 --- a/packages/tui/src/prompts/suggest/read-autocomplete.ts +++ b/packages/tui/src/prompts/suggest/read-autocomplete.ts @@ -7,6 +7,8 @@ import { resolveSuggestions } from '#tui/prompts/suggest/resolve'; import { pageIndex } from '#tui/prompts/select/navigation'; import type { SuggestOptions } from '#tui/prompts/suggest/options'; +const characterLength = (value: string): number => [...value].length; + export const readAutocompleteValue = async (options: SuggestOptions): Promise => { const environment = promptEnvironment(); @@ -15,7 +17,7 @@ export const readAutocompleteValue = async (options: SuggestOptions): Promise= [...state.value].length) { + if (key === Key.tab && state.cursor >= characterLength(state.value)) { matches = await resolveSuggestions(options.options, state.value); const match = matches[highlighted]; @@ -80,7 +82,7 @@ export const readAutocompleteValue = async (options: SuggestOptions): Promise= [...state.value].length) { + if ((key === Key.right || key === Key.rightArrow) && state.cursor >= characterLength(state.value)) { matches = await resolveSuggestions(options.options, state.value); const match = matches[highlighted]; diff --git a/packages/tui/src/prompts/suggest/read.ts b/packages/tui/src/prompts/suggest/read.ts index bb33874d..0ead87b0 100644 --- a/packages/tui/src/prompts/suggest/read.ts +++ b/packages/tui/src/prompts/suggest/read.ts @@ -7,6 +7,8 @@ import { resolveSuggestions } from '#tui/prompts/suggest/resolve'; import { pageIndex } from '#tui/prompts/select/navigation'; import type { SuggestOptions } from '#tui/prompts/suggest/options'; +const characterLength = (value: string): number => [...value].length; + export const readSuggestionValue = async (options: SuggestOptions): Promise => { const environment = promptEnvironment(); @@ -15,7 +17,7 @@ export const readSuggestionValue = async (options: SuggestOptions): Promise [...value]; const fromCharacters = (value: string[]): string => value.join(''); +const characterLength = (value: string): number => characters(value).length; +const stringIndexToCharacterIndex = (value: string, index: number): number => characterLength(value.slice(0, index)); type LineRange = { end: number; @@ -113,7 +115,7 @@ const moveToLineBoundary = (value: string[], cursor: number, boundary: 'start' | export const applyTypedKey = (state: TypedValueState, key: string, allowNewLine = false): TypedValueState & { submitted: boolean; cancelled: boolean } => { const value = characters(state.value); - let cursor = state.cursor; + let cursor = Math.max(0, Math.min(value.length, state.cursor)); if (key === Key.ctrlC) { return { cursor, value: fromCharacters(value), submitted: false, cancelled: true }; @@ -189,7 +191,7 @@ export const applyTypedKey = (state: TypedValueState, key: string, allowNewLine if (key === Key.optionBackspace) { const before = fromCharacters(value.slice(0, cursor)); const match = before.match(/(?:[\p{L}\p{M}\p{N}]+|[^\p{L}\p{M}\p{N}\s]+)\s*$/u); - const start = match?.index ?? 0; + const start = match?.index === undefined ? 0 : stringIndexToCharacterIndex(before, match.index); value.splice(start, cursor - start); @@ -220,7 +222,7 @@ export const readTypedValue = async (message: string, options: TypedValueOptions } let state: TypedValueState = { - cursor: options.default?.length ?? 0, + cursor: characterLength(options.default ?? ''), value: options.default ?? '', }; diff --git a/packages/tui/tests/typed-value.test.ts b/packages/tui/tests/typed-value.test.ts index 6b06aab2..70653495 100644 --- a/packages/tui/tests/typed-value.test.ts +++ b/packages/tui/tests/typed-value.test.ts @@ -57,6 +57,20 @@ describe('typed value editing', () => { expect(apply(['J', 'z', 'e', Key.ctrlB, Key.ctrlH, Key.ctrlF, 's', 's'])).toBe('Jess'); }); + it('clamps UTF-16 cursor offsets to character positions before editing', () => { + expect(applyMultilineState('😀', '😀'.length, ['!'])).toEqual({ + cursor: 2, + value: '😀!', + }); + }); + + it('deletes the previous word after non-BMP characters', () => { + expect(applyMultilineState('😀 hello', [...'😀 hello'].length, [Key.optionBackspace])).toEqual({ + cursor: 2, + value: '😀 ', + }); + }); + it('moves to the start and end of a line', () => { expect(apply(['A', 'r', Key.home[0], 'J', Key.end[0], 'c', 'h', 'e', 'r'])).toBe('JArcher'); }); From 25332ff8bff7845d6876e756018adf0bee7d418f Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 17:53:56 +0800 Subject: [PATCH 096/675] Reject disabled search line matches --- .../tui/src/prompts/search/read-single.ts | 10 +++++-- packages/tui/tests/search.test.ts | 29 +++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/packages/tui/src/prompts/search/read-single.ts b/packages/tui/src/prompts/search/read-single.ts index ecd7e0c8..97cd091b 100644 --- a/packages/tui/src/prompts/search/read-single.ts +++ b/packages/tui/src/prompts/search/read-single.ts @@ -20,9 +20,15 @@ export const readSearchChoice = async (options: SearchPromptOptions, attem return options.default; } - const choice = findChoice(choices, query) ?? choices.find((candidate) => !candidate.disabled); + const matched = findChoice(choices, query); - return choice?.value ?? choices[0]?.value; + if (matched?.disabled) { + return undefined; + } + + const choice = matched ?? choices.find((candidate) => !candidate.disabled); + + return choice?.value; } let state = { cursor: 0, value: '' }; diff --git a/packages/tui/tests/search.test.ts b/packages/tui/tests/search.test.ts index 7ee62ed8..d6a2a0fd 100644 --- a/packages/tui/tests/search.test.ts +++ b/packages/tui/tests/search.test.ts @@ -244,6 +244,35 @@ describe('search prompt', () => { expect(result).toBe('green'); }); + + it('rejects disabled exact line-mode matches and retries', async () => { + const output = createMemoryOutput(); + const answers = ['Red', 'Green']; + + const result = await withPromptEnvironment( + { + input: { + async readLine(): Promise { + return answers.shift() ?? ''; + }, + }, + output, + error: output, + interactive: true, + }, + () => + search({ + message: 'Favorite color?', + options: [ + { label: 'Red', value: 'red', disabled: true }, + { label: 'Green', value: 'green' }, + ], + }), + ); + + expect(result).toBe('green'); + expect(output.text()).toContain('Please select a valid option.'); + }); }); describe('multisearch prompt', () => { From 7687618e8f98fe997c834f5a5b7b041b738038d4 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 17:56:36 +0800 Subject: [PATCH 097/675] Map shifted tab events --- packages/tui/src/key.ts | 4 ++++ packages/tui/tests/key.test.ts | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/packages/tui/src/key.ts b/packages/tui/src/key.ts index 740ea5fb..6fbb56b8 100644 --- a/packages/tui/src/key.ts +++ b/packages/tui/src/key.ts @@ -77,6 +77,10 @@ export const keyFromEvent = (event: KeyboardEventLike): KeyName | string => { return Key.optionBackspace; } + if (event.shift && event.name?.toLowerCase() === 'tab') { + return Key.shiftTab; + } + if (event.name) { const key = namedKeyMap[event.name.toLowerCase()]; diff --git a/packages/tui/tests/key.test.ts b/packages/tui/tests/key.test.ts index 906cfee5..93093236 100644 --- a/packages/tui/tests/key.test.ts +++ b/packages/tui/tests/key.test.ts @@ -26,4 +26,8 @@ describe('keyboard mapping', () => { it('maps option backspace events', () => { expect(keyFromEvent({ meta: true, name: 'backspace' })).toBe(Key.optionBackspace); }); + + it('maps shifted tab events', () => { + expect(keyFromEvent({ name: 'tab', shift: true })).toBe(Key.shiftTab); + }); }); From be78b5dcd48e1a5a50f3db2f74d542a27cc68e96 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 17:59:45 +0800 Subject: [PATCH 098/675] Handle choice prompt cancellation --- .../tui/src/prompts/select/read-multiple.ts | 6 ++++ .../tui/src/prompts/select/read-selected.ts | 12 +++++++ packages/tui/tests/choices.test.ts | 34 +++++++++++++++++++ 3 files changed, 52 insertions(+) diff --git a/packages/tui/src/prompts/select/read-multiple.ts b/packages/tui/src/prompts/select/read-multiple.ts index ceda64f9..cec19550 100644 --- a/packages/tui/src/prompts/select/read-multiple.ts +++ b/packages/tui/src/prompts/select/read-multiple.ts @@ -54,6 +54,12 @@ export const readMultipleChoices = async ( return [...marked].map((index) => choices[index]?.value).filter((value): value is T => value !== undefined); } + if (key === Key.ctrlC) { + environment.error.write('Cancelled.\n'); + + return [...marked].map((index) => choices[index]?.value).filter((value): value is T => value !== undefined); + } + if (key.includes(',')) { return choicesFromCommaSeparated(choices, key); } diff --git a/packages/tui/src/prompts/select/read-selected.ts b/packages/tui/src/prompts/select/read-selected.ts index f030d6d4..cf1a7ae2 100644 --- a/packages/tui/src/prompts/select/read-selected.ts +++ b/packages/tui/src/prompts/select/read-selected.ts @@ -45,6 +45,18 @@ export const readSelectedChoice = async (message: string, choices: Array { expect(result).toBe('FIRST'); }); + it('cancels select prompts with the current highlighted option', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.ctrlC]), + output, + error: output, + interactive: true, + }, + () => select({ message: 'Pick one', options: ['first', 'second'] }), + ); + + expect(result).toBe('second'); + expect(output.text()).toContain('Cancelled.'); + }); + it('toggles multiselect choices with the space bar', async () => { const output = createMemoryOutput(); @@ -401,4 +418,21 @@ describe('choice prompts', () => { expect(result).toEqual(['second', 'first']); }); + + it('cancels multiselect prompts with the current marked options', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.space, Key.down, Key.ctrlC]), + output, + error: output, + interactive: true, + }, + () => multiselect({ message: 'Pick many', options: ['first', 'second'] }), + ); + + expect(result).toEqual(['first']); + expect(output.text()).toContain('Cancelled.'); + }); }); From ad1074b6ba2e8ff66d1368b01b5278de4f76dd74 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 18:03:28 +0800 Subject: [PATCH 099/675] Bound task stable summaries --- packages/tui/src/status/task.ts | 16 +++++++++++++--- packages/tui/tests/task.test.ts | 21 +++++++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/packages/tui/src/status/task.ts b/packages/tui/src/status/task.ts index df28053f..2993a732 100644 --- a/packages/tui/src/status/task.ts +++ b/packages/tui/src/status/task.ts @@ -26,9 +26,11 @@ export class Logger { #partialBuffer = ''; #partialStartIndex: number | null = null; private readonly limit: number; + private readonly stableLimit: number; constructor(limit: number, label: string, subLabel = '') { this.limit = parseLogLimit(limit, 10); + this.stableLimit = parseLogLimit(limit, 10); this.labelValue = label; this.subLabelValue = subLabel; } @@ -77,15 +79,23 @@ export class Logger { } success(message: string): void { - this.stableMessages.push({ message, type: 'success' }); + this.stable('success', message); } warning(message: string): void { - this.stableMessages.push({ message, type: 'warning' }); + this.stable('warning', message); } error(message: string): void { - this.stableMessages.push({ message, type: 'error' }); + this.stable('error', message); + } + + private stable(type: StableMessage['type'], message: string): void { + this.stableMessages.push({ message, type }); + + while (this.stableMessages.length > this.stableLimit) { + this.stableMessages.shift(); + } } } diff --git a/packages/tui/tests/task.test.ts b/packages/tui/tests/task.test.ts index fdb12987..12a8ff3a 100644 --- a/packages/tui/tests/task.test.ts +++ b/packages/tui/tests/task.test.ts @@ -81,6 +81,27 @@ describe('task helper', () => { expect(output.text()).toContain('error: failed optional step'); }); + it('bounds stable task summaries with the task log limit', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + await task( + 'Running...', + (logger) => { + logger.success('first'); + logger.warning('second'); + logger.error('third'); + }, + 2, + true, + ); + }); + + expect(output.text()).not.toContain('success: first'); + expect(output.text()).toContain('warning: second'); + expect(output.text()).toContain('error: third'); + }); + it('omits stable task summaries by default', async () => { const output = createMemoryOutput(); From a723675f2948e01476ee4a9e6ec70a139d48feb5 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 18:05:34 +0800 Subject: [PATCH 100/675] Normalize task log line endings --- packages/tui/src/status/task.ts | 20 ++++++++++++-------- packages/tui/tests/task.test.ts | 25 +++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 8 deletions(-) diff --git a/packages/tui/src/status/task.ts b/packages/tui/src/status/task.ts index 2993a732..f58df71d 100644 --- a/packages/tui/src/status/task.ts +++ b/packages/tui/src/status/task.ts @@ -36,13 +36,7 @@ export class Logger { } line(message: string): void { - for (const line of message.split(/\r?\n/u).filter((value) => value.length > 0)) { - this.lines.push(line.replaceAll(cursorReset, '').replaceAll(eraseLine, '')); - } - - while (this.lines.length > this.limit) { - this.lines.shift(); - } + this.writeLines(message.trimEnd()); } log(message: string): void { @@ -65,7 +59,7 @@ export class Logger { } this.lines.splice(this.#partialStartIndex); - this.line(this.#partialBuffer); + this.writeLines(this.#partialBuffer); this.#partialStartIndex = Math.min(this.#partialStartIndex, this.lines.length); } @@ -97,6 +91,16 @@ export class Logger { this.stableMessages.shift(); } } + + private writeLines(message: string): void { + for (const line of message.split(/\r?\n/u).filter((value) => value.length > 0)) { + this.lines.push(line.replaceAll(cursorReset, '').replaceAll(eraseLine, '')); + } + + while (this.lines.length > this.limit) { + this.lines.shift(); + } + } } export async function task(definition: TaskDefinition): Promise; diff --git a/packages/tui/tests/task.test.ts b/packages/tui/tests/task.test.ts index 12a8ff3a..397bb9db 100644 --- a/packages/tui/tests/task.test.ts +++ b/packages/tui/tests/task.test.ts @@ -58,6 +58,19 @@ describe('task helper', () => { expect(output.text()).toContain('beforeafter'); }); + it('trims trailing whitespace from completed log lines', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + await task('Running...', (logger) => { + logger.line('trailing '); + }); + }); + + expect(output.text()).toContain('trailing\n'); + expect(output.text()).not.toContain('trailing \n'); + }); + it('keeps stable task summaries when requested', async () => { const output = createMemoryOutput(); @@ -132,6 +145,18 @@ describe('task helper', () => { expect(output.text()).not.toContain('Downloading \n'); }); + it('preserves trailing spaces while partial output is still accumulating', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + await task('Running...', (logger) => { + logger.partial('Downloading '); + }); + }); + + expect(output.text()).toContain('Downloading \n'); + }); + it('starts a new partial line after committing the previous partial', async () => { const output = createMemoryOutput(); From 398a4b08c81a24f1b941f4ac7b41e938245cf464 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 18:07:46 +0800 Subject: [PATCH 101/675] Use typed editing for data table search --- packages/tui/src/output/data-table.ts | 28 ++++++---------- packages/tui/tests/table.test.ts | 48 +++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 17 deletions(-) diff --git a/packages/tui/src/output/data-table.ts b/packages/tui/src/output/data-table.ts index 3534f7fe..e269202d 100644 --- a/packages/tui/src/output/data-table.ts +++ b/packages/tui/src/output/data-table.ts @@ -3,6 +3,7 @@ import { Key, oneOf } from '#tui/key'; import { parseOptionalScrollSize, parseScrollSize } from '#tui/concerns/validators/scroll'; import { promptUntilValid, PromptValidationError } from '#tui/prompt'; import { renderTable } from '#tui/theme'; +import { applyTypedKey } from '#tui/typed-value'; import { isDataObjectRow } from '#tui/output/validators/data-table'; import type { DataTablePromptOptions, DataTableRow, TableCell } from '#tui/types'; @@ -86,10 +87,6 @@ const rowWindow = (total: number, selected: number, scroll?: number): { end: num return { end: start + size, start }; }; -const isPrintable = (key: string): boolean => { - return [...key].every((character) => (character.codePointAt(0) ?? 0) >= 32); -}; - export const datatable = async (options: DataTablePromptOptions): Promise => { const headers = options.headers ?? derivedHeaders(options.rows); @@ -98,10 +95,12 @@ export const datatable = async (options: DataTablePromptOptions) let selected = 0; let mode: 'browse' | 'search' = 'browse'; - let query = ''; + let query = { cursor: 0, value: '' }; const visibleRows = (): Array> => { - return options.rows.map((row, index) => ({ index, row })).filter(({ row }) => options.filter?.(query, row) ?? rowLabel(headers, row).toLowerCase().includes(query.toLowerCase())); + return options.rows + .map((row, index) => ({ index, row })) + .filter(({ row }) => options.filter?.(query.value, row) ?? rowLabel(headers, row).toLowerCase().includes(query.value.toLowerCase())); }; const render = (): void => { @@ -117,7 +116,7 @@ export const datatable = async (options: DataTablePromptOptions) return [index === selected ? '›' : ' ', ...rowCells(headers, row)]; }); - const querySuffix = mode === 'search' || query.length > 0 ? ` ${query}` : ''; + const querySuffix = mode === 'search' || query.value.length > 0 ? ` ${query.value}` : ''; environment.output.write(`${options.message}${querySuffix}\n`); environment.output.write(`${renderTable(['', ...headers], renderedRows)}\n`); @@ -155,21 +154,16 @@ export const datatable = async (options: DataTablePromptOptions) if (key === Key.escape) { mode = 'browse'; - query = ''; + query = { cursor: 0, value: '' }; selected = 0; render(); continue; } - if (key === Key.backspace || key === Key.ctrlH) { - query = query.slice(0, -1); - selected = 0; - render(); - continue; - } + const next = applyTypedKey(query, key); - if (isPrintable(key)) { - query += key; + if (next.value !== query.value || next.cursor !== query.cursor) { + query = { cursor: next.cursor, value: next.value }; selected = 0; render(); continue; @@ -178,7 +172,7 @@ export const datatable = async (options: DataTablePromptOptions) if (key === '/') { mode = 'search'; - query = ''; + query = { cursor: 0, value: '' }; selected = 0; render(); continue; diff --git a/packages/tui/tests/table.test.ts b/packages/tui/tests/table.test.ts index d62b6cfc..157aa7a0 100644 --- a/packages/tui/tests/table.test.ts +++ b/packages/tui/tests/table.test.ts @@ -138,6 +138,54 @@ describe('data table prompt', () => { expect(result).toBe('beta'); }); + it('edits Unicode data table search queries without corrupting input', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['/', '😀', Key.backspace, 'B', Key.enter, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + datatable({ + message: 'Pick project', + rows: [ + { Name: 'Alpha', value: 'alpha' }, + { Name: 'Beta', value: 'beta' }, + ], + }), + ); + + expect(result).toBe('beta'); + expect(output.text()).toContain('Pick project B'); + }); + + it('supports cursor edits inside data table search queries', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['/', 'B', 't', Key.left, 'e', Key.enter, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + datatable({ + message: 'Pick project', + rows: [ + { Name: 'Alpha', value: 'alpha' }, + { Name: 'Beta', value: 'beta' }, + ], + }), + ); + + expect(result).toBe('beta'); + expect(output.text()).toContain('Pick project Bet'); + }); + it('falls back to row indexes when rows have no explicit value', async () => { const output = createMemoryOutput(); From 8515582b47dbb9d9e4f1aff4cecc7bd3551dc8d5 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 18:09:37 +0800 Subject: [PATCH 102/675] Handle data table cancellation --- packages/tui/src/output/data-table.ts | 12 +++++++ packages/tui/tests/table.test.ts | 48 +++++++++++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/packages/tui/src/output/data-table.ts b/packages/tui/src/output/data-table.ts index e269202d..a03c5978 100644 --- a/packages/tui/src/output/data-table.ts +++ b/packages/tui/src/output/data-table.ts @@ -144,6 +144,18 @@ export const datatable = async (options: DataTablePromptOptions) const rows = visibleRows(); + if (key === Key.ctrlC) { + const selectedRow = rows[selected]; + + environment.error.write('Cancelled.\n'); + + if (!selectedRow) { + throw new PromptValidationError('Please select a valid row.'); + } + + return rowValue(selectedRow.row, selectedRow.index); + } + if (mode === 'search') { if (key === Key.enter) { mode = 'browse'; diff --git a/packages/tui/tests/table.test.ts b/packages/tui/tests/table.test.ts index 157aa7a0..50a545f6 100644 --- a/packages/tui/tests/table.test.ts +++ b/packages/tui/tests/table.test.ts @@ -138,6 +138,54 @@ describe('data table prompt', () => { expect(result).toBe('beta'); }); + it('cancels data table prompts with the current selected row', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.ctrlC]), + output, + error: output, + interactive: true, + }, + () => + datatable({ + message: 'Pick project', + rows: [ + { Name: 'Alpha', value: 'alpha' }, + { Name: 'Beta', value: 'beta' }, + ], + }), + ); + + expect(result).toBe('beta'); + expect(output.text()).toContain('Cancelled.'); + }); + + it('cancels data table search with the current filtered row', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['/', 'B', Key.ctrlC]), + output, + error: output, + interactive: true, + }, + () => + datatable({ + message: 'Pick project', + rows: [ + { Name: 'Alpha', value: 'alpha' }, + { Name: 'Beta', value: 'beta' }, + ], + }), + ); + + expect(result).toBe('beta'); + expect(output.text()).toContain('Cancelled.'); + }); + it('edits Unicode data table search queries without corrupting input', async () => { const output = createMemoryOutput(); From e3cd57c6673c477fa352c0719f812f517cf44e7d Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 18:11:44 +0800 Subject: [PATCH 103/675] Align autocomplete navigation aliases --- .../src/prompts/suggest/read-autocomplete.ts | 4 +-- packages/tui/tests/suggest.test.ts | 32 +++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/packages/tui/src/prompts/suggest/read-autocomplete.ts b/packages/tui/src/prompts/suggest/read-autocomplete.ts index b401f54a..d1ea8da2 100644 --- a/packages/tui/src/prompts/suggest/read-autocomplete.ts +++ b/packages/tui/src/prompts/suggest/read-autocomplete.ts @@ -33,7 +33,7 @@ export const readAutocompleteValue = async (options: SuggestOptions): Promise { expect(result).toBe('Black'); }); + it('cycles autocomplete matches with control navigation keys', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['b', Key.ctrlN, Key.ctrlN, Key.ctrlP, Key.tab, Key.enter]), + output, + error: output, + interactive: true, + }, + () => autocomplete('Favorite color?', ['Blue', 'Black', 'Blurple']), + ); + + expect(result).toBe('Black'); + }); + + it('cycles autocomplete matches backwards with shift tab', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['b', Key.shiftTab, Key.tab, Key.enter]), + output, + error: output, + interactive: true, + }, + () => autocomplete('Favorite color?', ['Blue', 'Black', 'Blurple']), + ); + + expect(result).toBe('Blurple'); + }); + it('supports page autocomplete navigation before accepting completion', async () => { const output = createMemoryOutput(); From 10f196a176e13df020ffe7451dbad236ca5052a3 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 18:14:22 +0800 Subject: [PATCH 104/675] Pad table cells by visible width --- packages/tui/src/theme.ts | 4 +++- packages/tui/tests/table.test.ts | 34 ++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/packages/tui/src/theme.ts b/packages/tui/src/theme.ts index 968763aa..8fc6f828 100644 --- a/packages/tui/src/theme.ts +++ b/packages/tui/src/theme.ts @@ -37,8 +37,10 @@ export const renderTable = (headers: string[], rows: string[][]): string => { return Math.max(visibleWidth(header), ...rows.map((row) => visibleWidth(row[index] ?? ''))); }); + const padVisible = (value: string, width: number): string => `${value}${' '.repeat(Math.max(0, width - visibleWidth(value)))}`; + const renderRow = (columns: string[]): string => { - return `| ${columns.map((column, index) => column.padEnd(widths[index] ?? 0)).join(' | ')} |`; + return `| ${columns.map((column, index) => padVisible(column, widths[index] ?? 0)).join(' | ')} |`; }; const divider = `| ${widths.map((width) => '-'.repeat(width)).join(' | ')} |`; diff --git a/packages/tui/tests/table.test.ts b/packages/tui/tests/table.test.ts index 50a545f6..565a5b82 100644 --- a/packages/tui/tests/table.test.ts +++ b/packages/tui/tests/table.test.ts @@ -29,6 +29,40 @@ describe('table output', () => { expect(output.text()).toContain('| Prompts | TypeScript |'); }); + it('pads ANSI-styled table cells by visible width', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + table( + ['Name', 'Runtime'], + [ + ['\u001B[31mRed\u001B[39m', 'Node'], + ['Blue', 'OpenTUI'], + ], + ); + }); + + expect(output.text()).toContain('\u001B[31mRed\u001B[39m | Node'); + expect(output.text()).toContain('| Blue | OpenTUI |'); + }); + + it('pads wide Unicode table cells by visible width', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + table( + ['Name', 'Runtime'], + [ + ['東京', 'Node'], + ['Paris', 'OpenTUI'], + ], + ); + }); + + expect(output.text()).toContain('| 東京 | Node'); + expect(output.text()).toContain('| Paris | OpenTUI |'); + }); + it('rejects invalid table runtime shapes through the validator layer', async () => { const output = createMemoryOutput(); From 11795ee53116cf9d9086c1e1158ac472a0aec557 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 18:16:18 +0800 Subject: [PATCH 105/675] Align number step navigation aliases --- packages/tui/src/prompts/number/input.ts | 4 ++-- packages/tui/tests/number.test.ts | 17 +++++++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/tui/src/prompts/number/input.ts b/packages/tui/src/prompts/number/input.ts index 446540c3..5bc25f1a 100644 --- a/packages/tui/src/prompts/number/input.ts +++ b/packages/tui/src/prompts/number/input.ts @@ -68,14 +68,14 @@ export const readNumberValue = async (message: string, options: NumberInputOptio return state.value; } - if (key === Key.up || key === Key.upArrow) { + if (key === Key.up || key === Key.upArrow || key === Key.ctrlP) { state.value = steppedValue(state.value, 1, options); state.cursor = state.value.length; renderNumberValue(message, state.value, options); continue; } - if (key === Key.down || key === Key.downArrow) { + if (key === Key.down || key === Key.downArrow || key === Key.ctrlN) { state.value = steppedValue(state.value, -1, options); state.cursor = state.value.length; renderNumberValue(message, state.value, options); diff --git a/packages/tui/tests/number.test.ts b/packages/tui/tests/number.test.ts index 6541dd65..94aa4fd3 100644 --- a/packages/tui/tests/number.test.ts +++ b/packages/tui/tests/number.test.ts @@ -161,6 +161,23 @@ describe('number prompt', () => { expect(output.text()).toContain('? Count 1'); }); + it('increments and decrements with control navigation keys', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.ctrlP, Key.ctrlP, Key.ctrlN, Key.enter]), + output, + error: output, + interactive: true, + }, + () => number({ message: 'Count', step: 2 }), + ); + + expect(result).toBe(1); + expect(output.text()).toContain('? Count 1'); + }); + it('increments and decrements decimal values with fractional steps', async () => { const output = createMemoryOutput(); From 8ced164802897eb9bbff452eb699eef973647032 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 18:18:24 +0800 Subject: [PATCH 106/675] Preserve multisearch selections on cancel --- packages/tui/src/prompts/search/read-multi.ts | 6 ++++++ packages/tui/tests/search.test.ts | 17 +++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/packages/tui/src/prompts/search/read-multi.ts b/packages/tui/src/prompts/search/read-multi.ts index 6b2c6ec0..535b450e 100644 --- a/packages/tui/src/prompts/search/read-multi.ts +++ b/packages/tui/src/prompts/search/read-multi.ts @@ -70,6 +70,12 @@ export const readMultiSearchChoices = async (options: MultiSearchPromptOption return [...selected.keys()]; } + if (key === Key.ctrlC) { + environment.error.write('Cancelled.\n'); + + return [...selected.keys()]; + } + if (key === Key.down || key === Key.downArrow || key === Key.tab) { choices = await resolveSearchChoices(options.options, state.value); diff --git a/packages/tui/tests/search.test.ts b/packages/tui/tests/search.test.ts index d6a2a0fd..7367d8e6 100644 --- a/packages/tui/tests/search.test.ts +++ b/packages/tui/tests/search.test.ts @@ -358,6 +358,23 @@ describe('multisearch prompt', () => { expect(output.text()).toContain('1 selected (1 hidden)'); }); + it('cancels multisearch prompts with the current selected values', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.space, 'B', Key.ctrlC]), + output, + error: output, + interactive: true, + }, + () => multisearch({ message: 'Favorite colors?', options: colors }), + ); + + expect(result).toEqual(['red']); + expect(output.text()).toContain('Cancelled.'); + }); + it('toggles all current multisearch results with ctrl-a', async () => { const output = createMemoryOutput(); From ae8e4a9199901114fc0ecbebd4cc91d5465c1bde Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 18:20:34 +0800 Subject: [PATCH 107/675] Preserve search highlight on cancel --- packages/tui/src/prompts/search/read-single.ts | 12 ++++++++++++ packages/tui/tests/search.test.ts | 17 +++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/packages/tui/src/prompts/search/read-single.ts b/packages/tui/src/prompts/search/read-single.ts index 97cd091b..3643aba0 100644 --- a/packages/tui/src/prompts/search/read-single.ts +++ b/packages/tui/src/prompts/search/read-single.ts @@ -46,6 +46,18 @@ export const readSearchChoice = async (options: SearchPromptOptions, attem return options.default; } + if (key === Key.ctrlC) { + environment.error.write('Cancelled.\n'); + + if (highlighted !== null) { + const choice = choices[highlighted]; + + return choice?.disabled ? options.default : (choice?.value ?? options.default); + } + + return options.default; + } + if (key === Key.down || key === Key.downArrow || key === Key.ctrlN || key === Key.tab) { choices = await resolveSearchChoices(options.options, state.value); diff --git a/packages/tui/tests/search.test.ts b/packages/tui/tests/search.test.ts index 7367d8e6..72383920 100644 --- a/packages/tui/tests/search.test.ts +++ b/packages/tui/tests/search.test.ts @@ -188,6 +188,23 @@ describe('search prompt', () => { expect(result).toBe('red'); }); + it('cancels search prompts with the current highlighted result', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.ctrlC]), + output, + error: output, + interactive: true, + }, + () => search({ message: 'Favorite color?', options: colors }), + ); + + expect(result).toBe('red'); + expect(output.text()).toContain('Cancelled.'); + }); + it('rejects optional single search prompts', async () => { const options = { message: 'Favorite color?', From aa1a4d5f3dea2ab51adea2a27f4e6c87e9227b36 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 18:24:38 +0800 Subject: [PATCH 108/675] Trim line mode choice answers --- packages/tui/src/concerns/choices.ts | 8 ++-- packages/tui/src/prompts/search/read-multi.ts | 4 +- .../tui/src/prompts/search/read-single.ts | 4 +- packages/tui/tests/choices.test.ts | 20 ++++++++++ packages/tui/tests/search.test.ts | 40 +++++++++++++++++++ 5 files changed, 69 insertions(+), 7 deletions(-) diff --git a/packages/tui/src/concerns/choices.ts b/packages/tui/src/concerns/choices.ts index c7c18fd3..07bd6554 100644 --- a/packages/tui/src/concerns/choices.ts +++ b/packages/tui/src/concerns/choices.ts @@ -30,13 +30,15 @@ export const normalizeSearchChoices = (options: Array> | Recor }; export const findChoice = (choices: Array>, answer: string): Choice | undefined => { - if (/^\d+$/u.test(answer)) { - const index = Number.parseInt(answer, 10); + const normalizedAnswer = answer.trim(); + + if (/^\d+$/u.test(normalizedAnswer)) { + const index = Number.parseInt(normalizedAnswer, 10); return choices[index - 1]; } - return choices.find((choice) => choice.label === answer || String(choice.value) === answer); + return choices.find((choice) => choice.label === normalizedAnswer || String(choice.value) === normalizedAnswer); }; export const firstEnabledIndex = (choices: Array>): number => { diff --git a/packages/tui/src/prompts/search/read-multi.ts b/packages/tui/src/prompts/search/read-multi.ts index 535b450e..d021fc50 100644 --- a/packages/tui/src/prompts/search/read-multi.ts +++ b/packages/tui/src/prompts/search/read-multi.ts @@ -12,11 +12,11 @@ export const readMultiSearchChoices = async (options: MultiSearchPromptOption const environment = promptEnvironment(); if (!environment.input.readKey) { - const query = await ask(options.message, options.hint); + const query = (await ask(options.message, options.hint)).trim(); const choices = await resolveSearchChoices(options.options, query); - if (query.trim() === '' && options.default !== undefined) { + if (query === '' && options.default !== undefined) { return options.default; } diff --git a/packages/tui/src/prompts/search/read-single.ts b/packages/tui/src/prompts/search/read-single.ts index 3643aba0..04116874 100644 --- a/packages/tui/src/prompts/search/read-single.ts +++ b/packages/tui/src/prompts/search/read-single.ts @@ -12,11 +12,11 @@ export const readSearchChoice = async (options: SearchPromptOptions, attem const environment = promptEnvironment(); if (!environment.input.readKey) { - const query = await ask(options.message, options.hint); + const query = (await ask(options.message, options.hint)).trim(); const choices = await resolveSearchChoices(options.options, query); - if (query.trim() === '' && options.default !== undefined) { + if (query === '' && options.default !== undefined) { return options.default; } diff --git a/packages/tui/tests/choices.test.ts b/packages/tui/tests/choices.test.ts index 35ea379f..7bf75fb4 100644 --- a/packages/tui/tests/choices.test.ts +++ b/packages/tui/tests/choices.test.ts @@ -245,6 +245,26 @@ describe('choice prompts', () => { expect(result).toBe('FIRST'); }); + it('trims line-mode select answers before matching choices', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: { + async readLine(): Promise { + return ' 2 '; + }, + }, + output, + error: output, + interactive: true, + }, + () => select({ message: 'Pick one', options: ['first', 'second'] }), + ); + + expect(result).toBe('second'); + }); + it('cancels select prompts with the current highlighted option', async () => { const output = createMemoryOutput(); diff --git a/packages/tui/tests/search.test.ts b/packages/tui/tests/search.test.ts index 72383920..54af887a 100644 --- a/packages/tui/tests/search.test.ts +++ b/packages/tui/tests/search.test.ts @@ -262,6 +262,26 @@ describe('search prompt', () => { expect(result).toBe('green'); }); + it('trims line-mode search answers before matching choices', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: { + async readLine(): Promise { + return ' Green '; + }, + }, + output, + error: output, + interactive: true, + }, + () => search({ message: 'Favorite color?', options: colors }), + ); + + expect(result).toBe('green'); + }); + it('rejects disabled exact line-mode matches and retries', async () => { const output = createMemoryOutput(); const answers = ['Red', 'Green']; @@ -536,4 +556,24 @@ describe('multisearch prompt', () => { expect(result).toEqual(['blue', 'green', 'red']); }); + + it('trims line-mode multisearch answers before matching choices', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: { + async readLine(): Promise { + return ' Green '; + }, + }, + output, + error: output, + interactive: true, + }, + () => multisearch({ message: 'Favorite colors?', options: colors }), + ); + + expect(result).toEqual(['green']); + }); }); From 1d2d2902dcb186d9ca666b2fcbac9f4a96582ed3 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 18:27:09 +0800 Subject: [PATCH 109/675] Align multi choice end navigation --- packages/tui/src/prompts/search/read-multi.ts | 1 + .../tui/src/prompts/select/read-multiple.ts | 2 +- packages/tui/tests/choices.test.ts | 16 ++++++++++++++++ packages/tui/tests/search.test.ts | 17 +++++++++++++++++ 4 files changed, 35 insertions(+), 1 deletion(-) diff --git a/packages/tui/src/prompts/search/read-multi.ts b/packages/tui/src/prompts/search/read-multi.ts index d021fc50..cf6df477 100644 --- a/packages/tui/src/prompts/search/read-multi.ts +++ b/packages/tui/src/prompts/search/read-multi.ts @@ -129,6 +129,7 @@ export const readMultiSearchChoices = async (options: MultiSearchPromptOption } if (key === Key.ctrlE && highlighted !== null) { + highlighted = lastEnabledIndex(displayedChoices()); render(); continue; } diff --git a/packages/tui/src/prompts/select/read-multiple.ts b/packages/tui/src/prompts/select/read-multiple.ts index cec19550..d09e0c88 100644 --- a/packages/tui/src/prompts/select/read-multiple.ts +++ b/packages/tui/src/prompts/select/read-multiple.ts @@ -109,7 +109,7 @@ export const readMultipleChoices = async ( continue; } - if (oneOf([Key.end], key)) { + if (oneOf([Key.end, Key.ctrlE], key)) { selected = lastEnabledChoiceIndex(choices); renderMultipleChoices(message, choices, selected, marked, scroll, info); continue; diff --git a/packages/tui/tests/choices.test.ts b/packages/tui/tests/choices.test.ts index 7bf75fb4..3b3d1f53 100644 --- a/packages/tui/tests/choices.test.ts +++ b/packages/tui/tests/choices.test.ts @@ -401,6 +401,22 @@ describe('choice prompts', () => { expect(result).toEqual(['third', 'first']); }); + it('supports end-of-list control navigation for multiselect prompts', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.ctrlE, Key.space, Key.enter]), + output, + error: output, + interactive: true, + }, + () => multiselect({ message: 'Pick many', options: ['first', 'second', 'third'] }), + ); + + expect(result).toEqual(['third']); + }); + it('combines multiselect info with selected counts', async () => { const output = createMemoryOutput(); diff --git a/packages/tui/tests/search.test.ts b/packages/tui/tests/search.test.ts index 54af887a..30176f7a 100644 --- a/packages/tui/tests/search.test.ts +++ b/packages/tui/tests/search.test.ts @@ -493,6 +493,23 @@ describe('multisearch prompt', () => { expect(output.text()).toContain('Selected: Blue, Red'); }); + it('supports end-of-list control navigation for multisearch prompts', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.ctrlE, Key.space, Key.enter]), + output, + error: output, + interactive: true, + }, + () => multisearch({ message: 'Favorite colors?', options: colors }), + ); + + expect(result).toEqual(['blue']); + expect(output.text()).toContain('Selected: Blue'); + }); + it('supports page multisearch navigation keys', async () => { const output = createMemoryOutput(); From b7476aff2c6f40c35e0b83eaaec81fe108b9dd0f Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 18:29:17 +0800 Subject: [PATCH 110/675] Align data table tab navigation --- packages/tui/src/output/data-table.ts | 4 ++-- packages/tui/tests/table.test.ts | 21 +++++++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/packages/tui/src/output/data-table.ts b/packages/tui/src/output/data-table.ts index a03c5978..365723a8 100644 --- a/packages/tui/src/output/data-table.ts +++ b/packages/tui/src/output/data-table.ts @@ -190,13 +190,13 @@ export const datatable = async (options: DataTablePromptOptions) continue; } - if (key === Key.down || key === Key.downArrow || key === Key.ctrlN) { + if (key === Key.down || key === Key.downArrow || key === Key.ctrlN || key === Key.tab) { selected = rows.length === 0 ? 0 : (selected + 1) % rows.length; render(); continue; } - if (key === Key.up || key === Key.upArrow || key === Key.ctrlP) { + if (key === Key.up || key === Key.upArrow || key === Key.ctrlP || key === Key.shiftTab) { selected = rows.length === 0 ? 0 : (selected - 1 + rows.length) % rows.length; render(); continue; diff --git a/packages/tui/tests/table.test.ts b/packages/tui/tests/table.test.ts index 565a5b82..bcaf796b 100644 --- a/packages/tui/tests/table.test.ts +++ b/packages/tui/tests/table.test.ts @@ -356,6 +356,27 @@ describe('data table prompt', () => { expect(endHomeResult).toBe(0); }); + it('supports tab and reverse-tab data table navigation keys', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.tab, Key.tab, Key.shiftTab, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + datatable({ + message: 'Pick row', + headers: ['Name'], + rows: [['First'], ['Second'], ['Third']], + }), + ); + + expect(result).toBe(1); + }); + it('normalizes fractional scroll sizes for data table page navigation', async () => { const output = createMemoryOutput(); From e4b8660e7df96c98d6ede9104173878083917a59 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 18:32:30 +0800 Subject: [PATCH 111/675] Avoid empty wide-character wrap lines --- packages/tui/src/strings.ts | 5 ++++- packages/tui/tests/strings.test.ts | 4 ++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/tui/src/strings.ts b/packages/tui/src/strings.ts index 5b860531..7c700953 100644 --- a/packages/tui/src/strings.ts +++ b/packages/tui/src/strings.ts @@ -55,7 +55,10 @@ export const wrap = (value: string, width: number): string[] => { for (const char of word) { if (visibleWidth(`${chunk}${char}`) > width) { - lines.push(chunk); + if (chunk.length > 0) { + lines.push(chunk); + } + chunk = ''; } diff --git a/packages/tui/tests/strings.test.ts b/packages/tui/tests/strings.test.ts index a520155f..751132f2 100644 --- a/packages/tui/tests/strings.test.ts +++ b/packages/tui/tests/strings.test.ts @@ -14,6 +14,10 @@ describe('string utilities', () => { expect(wrap('Ollin Prompts', 7)).toEqual(['Ollin', 'Prompts']); }); + it('wraps wide characters without emitting empty lines', () => { + expect(wrap('😀', 1)).toEqual(['😀']); + }); + it('strips ANSI text', () => { expect(parseAnsiText('\u001B[32mDone\u001B[39m')).toBe('Done'); }); From 1bc2bdc22b10df17600b6f8f617e9715c3f75d3a Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 18:35:50 +0800 Subject: [PATCH 112/675] Render missing table cells --- packages/tui/src/theme.ts | 2 +- packages/tui/tests/table.test.ts | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/tui/src/theme.ts b/packages/tui/src/theme.ts index 8fc6f828..11404672 100644 --- a/packages/tui/src/theme.ts +++ b/packages/tui/src/theme.ts @@ -40,7 +40,7 @@ export const renderTable = (headers: string[], rows: string[][]): string => { const padVisible = (value: string, width: number): string => `${value}${' '.repeat(Math.max(0, width - visibleWidth(value)))}`; const renderRow = (columns: string[]): string => { - return `| ${columns.map((column, index) => padVisible(column, widths[index] ?? 0)).join(' | ')} |`; + return `| ${widths.map((width, index) => padVisible(columns[index] ?? '', width)).join(' | ')} |`; }; const divider = `| ${widths.map((width) => '-'.repeat(width)).join(' | ')} |`; diff --git a/packages/tui/tests/table.test.ts b/packages/tui/tests/table.test.ts index bcaf796b..8d31200c 100644 --- a/packages/tui/tests/table.test.ts +++ b/packages/tui/tests/table.test.ts @@ -63,6 +63,17 @@ describe('table output', () => { expect(output.text()).toContain('| Paris | OpenTUI |'); }); + it('renders missing table cells as empty columns', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + table(['Name', 'Runtime'], [['Ollin'], ['Prompts', 'TypeScript']]); + }); + + expect(output.text()).toContain('| Ollin | |'); + expect(output.text()).toContain('| Prompts | TypeScript |'); + }); + it('rejects invalid table runtime shapes through the validator layer', async () => { const output = createMemoryOutput(); From a00edced2d34809b810fe0ae1b52318a7624e446 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 18:39:11 +0800 Subject: [PATCH 113/675] Match multisearch control key behavior --- packages/tui/src/prompts/search/read-multi.ts | 2 -- packages/tui/tests/search.test.ts | 6 +++--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/tui/src/prompts/search/read-multi.ts b/packages/tui/src/prompts/search/read-multi.ts index cf6df477..d71df421 100644 --- a/packages/tui/src/prompts/search/read-multi.ts +++ b/packages/tui/src/prompts/search/read-multi.ts @@ -129,8 +129,6 @@ export const readMultiSearchChoices = async (options: MultiSearchPromptOption } if (key === Key.ctrlE && highlighted !== null) { - highlighted = lastEnabledIndex(displayedChoices()); - render(); continue; } diff --git a/packages/tui/tests/search.test.ts b/packages/tui/tests/search.test.ts index 30176f7a..fe6e8c60 100644 --- a/packages/tui/tests/search.test.ts +++ b/packages/tui/tests/search.test.ts @@ -493,7 +493,7 @@ describe('multisearch prompt', () => { expect(output.text()).toContain('Selected: Blue, Red'); }); - it('supports end-of-list control navigation for multisearch prompts', async () => { + it('ignores end-of-line control navigation while multisearch results are highlighted', async () => { const output = createMemoryOutput(); const result = await withPromptEnvironment( @@ -506,8 +506,8 @@ describe('multisearch prompt', () => { () => multisearch({ message: 'Favorite colors?', options: colors }), ); - expect(result).toEqual(['blue']); - expect(output.text()).toContain('Selected: Blue'); + expect(result).toEqual(['red']); + expect(output.text()).toContain('Selected: Red'); }); it('supports page multisearch navigation keys', async () => { From 54382a313bdda37a4109bf0387f2ad323628701b Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 18:42:04 +0800 Subject: [PATCH 114/675] Match autocomplete navigation keys --- .../src/prompts/suggest/read-autocomplete.ts | 21 ++----------------- packages/tui/tests/suggest.test.ts | 16 +++++++------- 2 files changed, 10 insertions(+), 27 deletions(-) diff --git a/packages/tui/src/prompts/suggest/read-autocomplete.ts b/packages/tui/src/prompts/suggest/read-autocomplete.ts index d1ea8da2..125b9526 100644 --- a/packages/tui/src/prompts/suggest/read-autocomplete.ts +++ b/packages/tui/src/prompts/suggest/read-autocomplete.ts @@ -4,7 +4,6 @@ import { ask } from '#tui/prompt'; import { applyTypedKey } from '#tui/typed-value'; import { renderAutocomplete } from '#tui/prompts/suggest/render-autocomplete'; import { resolveSuggestions } from '#tui/prompts/suggest/resolve'; -import { pageIndex } from '#tui/prompts/select/navigation'; import type { SuggestOptions } from '#tui/prompts/suggest/options'; const characterLength = (value: string): number => [...value].length; @@ -33,7 +32,7 @@ export const readAutocompleteValue = async (options: SuggestOptions): Promise= characterLength(state.value)) { matches = await resolveSuggestions(options.options, state.value); diff --git a/packages/tui/tests/suggest.test.ts b/packages/tui/tests/suggest.test.ts index 7b5db4ae..1b6524de 100644 --- a/packages/tui/tests/suggest.test.ts +++ b/packages/tui/tests/suggest.test.ts @@ -183,12 +183,12 @@ describe('autocomplete prompt', () => { expect(result).toBe('Black'); }); - it('cycles autocomplete matches with control navigation keys', async () => { + it('does not cycle autocomplete matches with control navigation keys', async () => { const output = createMemoryOutput(); const result = await withPromptEnvironment( { - input: createScriptedInput(['b', Key.ctrlN, Key.ctrlN, Key.ctrlP, Key.tab, Key.enter]), + input: createScriptedInput(['b', Key.ctrlN, Key.tab, Key.enter]), output, error: output, interactive: true, @@ -196,10 +196,10 @@ describe('autocomplete prompt', () => { () => autocomplete('Favorite color?', ['Blue', 'Black', 'Blurple']), ); - expect(result).toBe('Black'); + expect(result).toBe('Blue'); }); - it('cycles autocomplete matches backwards with shift tab', async () => { + it('does not cycle autocomplete matches with shift tab', async () => { const output = createMemoryOutput(); const result = await withPromptEnvironment( @@ -212,10 +212,10 @@ describe('autocomplete prompt', () => { () => autocomplete('Favorite color?', ['Blue', 'Black', 'Blurple']), ); - expect(result).toBe('Blurple'); + expect(result).toBe('Blue'); }); - it('supports page autocomplete navigation before accepting completion', async () => { + it('does not cycle autocomplete matches with page navigation keys', async () => { const output = createMemoryOutput(); const result = await withPromptEnvironment( @@ -228,7 +228,7 @@ describe('autocomplete prompt', () => { () => autocomplete({ message: 'Favorite color?', options: ['Blue', 'Black', 'Blurple'], scroll: 2 }), ); - expect(result).toBe('Blurple'); - expect(output.text()).toContain('Favorite color? Blurple'); + expect(result).toBe('Blue'); + expect(output.text()).toContain('Favorite color? Blue'); }); }); From 7e2ec92f0324ab418d2d7e7446928be0d453b77f Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 18:45:12 +0800 Subject: [PATCH 115/675] Match multiselect control key behavior --- packages/tui/src/prompts/select/read-multiple.ts | 2 +- packages/tui/tests/choices.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/tui/src/prompts/select/read-multiple.ts b/packages/tui/src/prompts/select/read-multiple.ts index d09e0c88..cec19550 100644 --- a/packages/tui/src/prompts/select/read-multiple.ts +++ b/packages/tui/src/prompts/select/read-multiple.ts @@ -109,7 +109,7 @@ export const readMultipleChoices = async ( continue; } - if (oneOf([Key.end, Key.ctrlE], key)) { + if (oneOf([Key.end], key)) { selected = lastEnabledChoiceIndex(choices); renderMultipleChoices(message, choices, selected, marked, scroll, info); continue; diff --git a/packages/tui/tests/choices.test.ts b/packages/tui/tests/choices.test.ts index 3b3d1f53..78fb2cbd 100644 --- a/packages/tui/tests/choices.test.ts +++ b/packages/tui/tests/choices.test.ts @@ -401,7 +401,7 @@ describe('choice prompts', () => { expect(result).toEqual(['third', 'first']); }); - it('supports end-of-list control navigation for multiselect prompts', async () => { + it('ignores end-of-line control navigation for multiselect prompts', async () => { const output = createMemoryOutput(); const result = await withPromptEnvironment( @@ -414,7 +414,7 @@ describe('choice prompts', () => { () => multiselect({ message: 'Pick many', options: ['first', 'second', 'third'] }), ); - expect(result).toEqual(['third']); + expect(result).toEqual(['first']); }); it('combines multiselect info with selected counts', async () => { From d58559566efabe7b9fff9ed912adde7e618f0bc1 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 18:49:01 +0800 Subject: [PATCH 116/675] Support keyed choice options --- packages/tui/src/concerns/choices.ts | 28 ++++++----- .../tui/src/concerns/validators/choice.ts | 8 ++++ packages/tui/src/form/builder/prompts.ts | 10 ++-- packages/tui/src/types.ts | 10 ++-- packages/tui/tests/choices.test.ts | 48 +++++++++++++++++++ 5 files changed, 82 insertions(+), 22 deletions(-) diff --git a/packages/tui/src/concerns/choices.ts b/packages/tui/src/concerns/choices.ts index 07bd6554..8e6d4463 100644 --- a/packages/tui/src/concerns/choices.ts +++ b/packages/tui/src/concerns/choices.ts @@ -1,9 +1,20 @@ import { promptEnvironment } from '#tui/environment'; -import { parseChoice } from '#tui/concerns/validators/choice'; +import { parseChoice, parseChoiceRecord } from '#tui/concerns/validators/choice'; import { parseOptionalScrollSize } from '#tui/concerns/validators/scroll'; -import type { Choice, ChoiceInput } from '#tui/types'; +import type { Choice, ChoiceOptions } from '#tui/types'; + +const normalizeChoiceRecordKey = (key: string): string | number => (/^-?\d+$/u.test(key) ? Number.parseInt(key, 10) : key); + +export const normalizeChoices = (options: ChoiceOptions): Array> => { + if (!Array.isArray(options)) { + const parsed = parseChoiceRecord(options); + + return Object.entries(parsed ?? {}).map(([value, label]) => ({ + label, + value: normalizeChoiceRecordKey(value) as T, + })); + } -export const normalizeChoices = (options: Array>): Array> => { return options.map((choice) => { const parsed = parseChoice(choice); @@ -18,16 +29,7 @@ export const normalizeChoices = (options: Array>): Array(options: Array> | Record): Array> => { - if (Array.isArray(options)) { - return normalizeChoices(options); - } - - return Object.entries(options).map(([value, label]) => ({ - label, - value: value as T, - })); -}; +export const normalizeSearchChoices = (options: ChoiceOptions): Array> => normalizeChoices(options); export const findChoice = (choices: Array>, answer: string): Choice | undefined => { const normalizedAnswer = answer.trim(); diff --git a/packages/tui/src/concerns/validators/choice.ts b/packages/tui/src/concerns/validators/choice.ts index 230a51c8..8fc1408c 100644 --- a/packages/tui/src/concerns/validators/choice.ts +++ b/packages/tui/src/concerns/validators/choice.ts @@ -10,8 +10,16 @@ const choiceSchema = z }) .passthrough(); +const choiceRecordSchema = z.record(z.string(), z.string()); + export const parseChoice = (value: unknown): Choice | null => { const parsed = choiceSchema.safeParse(value); return parsed.success ? (parsed.data as Choice) : null; }; + +export const parseChoiceRecord = (value: unknown): Record | null => { + const parsed = choiceRecordSchema.safeParse(value); + + return parsed.success ? parsed.data : null; +}; diff --git a/packages/tui/src/form/builder/prompts.ts b/packages/tui/src/form/builder/prompts.ts index 9e8854ff..d4505479 100644 --- a/packages/tui/src/form/builder/prompts.ts +++ b/packages/tui/src/form/builder/prompts.ts @@ -2,7 +2,7 @@ import { autocomplete, confirm, multiselect, multisearch, pause, search, select, import { number, password, text, textarea } from '#tui/prompts/basic'; import { previousArray, previousNumber, previousString } from '#tui/form/builder/previous'; import type { FormBuilder } from '#tui/form/builder/index'; -import type { ChoiceInput, MaybePromise, MultiSearchPromptOptions, SearchPromptOptions, TextPromptOptions } from '#tui/types'; +import type { ChoiceOptions, MaybePromise, MultiSearchPromptOptions, SearchPromptOptions, TextPromptOptions } from '#tui/types'; import type { SuggestOptions } from '#tui/prompts/choices'; export type PromptBuilderMethods = { @@ -33,7 +33,7 @@ export type PromptBuilderMethods = { multiselect( this: FormBuilder, label: string, - options: Array>, + options: ChoiceOptions, defaultValue?: T[], scroll?: number, required?: boolean | string, @@ -70,7 +70,7 @@ export type PromptBuilderMethods = { select( this: FormBuilder, label: string, - options: Array>, + options: ChoiceOptions, defaultValue?: T, scroll?: number, validate?: (value: T) => MaybePromise, @@ -128,7 +128,7 @@ export const promptBuilderMethods: PromptBuilderMethods & ThisType }, multiselect( label: string, - options: Array>, + options: ChoiceOptions, defaultValue: T[] = [], scroll = 5, required: boolean | string = false, @@ -151,7 +151,7 @@ export const promptBuilderMethods: PromptBuilderMethods & ThisType search(options: SearchPromptOptions, name?: string) { return this.add(() => search(options), name); }, - select(label: string, options: Array>, defaultValue?: T, scroll = 5, validate = undefined, hint = '', required: boolean | string = true, name?: string, transform = undefined) { + select(label: string, options: ChoiceOptions, defaultValue?: T, scroll = 5, validate = undefined, hint = '', required: boolean | string = true, name?: string, transform = undefined) { return this.add((_, previous) => select({ message: label, options, default: previous === undefined ? defaultValue : (previous as T), scroll, validate, hint, required, transform }), name); }, suggest(label, options, defaultValue = '', scroll = 5, required = false, validate = undefined, hint = '', name, transform = undefined) { diff --git a/packages/tui/src/types.ts b/packages/tui/src/types.ts index e48605ae..948a015a 100644 --- a/packages/tui/src/types.ts +++ b/packages/tui/src/types.ts @@ -39,6 +39,8 @@ export type Choice = { export type ChoiceInput = Choice | T; +export type ChoiceOptions = Array> | Record; + export type PromptInfo = string | ((value: T | null) => string | null | undefined); export type TextPromptOptions = BasePromptOptions & { @@ -71,7 +73,7 @@ export type ConfirmPromptOptions = BasePromptOptions & { export type SelectPromptOptions = BasePromptOptions & { label?: string; - options: Array>; + options: ChoiceOptions; scroll?: number; info?: PromptInfo; transform?: (value: T) => MaybePromise; @@ -79,7 +81,7 @@ export type SelectPromptOptions = BasePromptOptions & { export type MultiSelectPromptOptions = BasePromptOptions & { label?: string; - options: Array>; + options: ChoiceOptions; scroll?: number; info?: PromptInfo; transform?: (value: T[]) => MaybePromise; @@ -87,7 +89,7 @@ export type MultiSelectPromptOptions = BasePromptOptions & { export type SearchPromptOptions = Omit, 'required'> & { label?: string; - options: Array> | Record | ((query: string) => MaybePromise> | Record>); + options: ChoiceOptions | ((query: string) => MaybePromise>); placeholder?: string; required?: true | string; scroll?: number; @@ -97,7 +99,7 @@ export type SearchPromptOptions = Omit, 'required'> & { export type MultiSearchPromptOptions = BasePromptOptions & { label?: string; - options: Array> | Record | ((query: string) => MaybePromise> | Record>); + options: ChoiceOptions | ((query: string) => MaybePromise>); placeholder?: string; scroll?: number; info?: PromptInfo; diff --git a/packages/tui/tests/choices.test.ts b/packages/tui/tests/choices.test.ts index 78fb2cbd..a15c87f4 100644 --- a/packages/tui/tests/choices.test.ts +++ b/packages/tui/tests/choices.test.ts @@ -97,6 +97,38 @@ describe('choice prompts', () => { expect(output.text()).toContain('Pick one'); }); + it('returns keys from keyed select options', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.enter]), + output, + error: output, + interactive: true, + }, + () => select({ message: 'Pick one', options: { first: 'First', second: 'Second' } }), + ); + + expect(result).toBe('second'); + }); + + it('returns numeric keys from numeric keyed select options', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.enter]), + output, + error: output, + interactive: true, + }, + () => select({ message: 'Pick one', options: { 1: 'First', 2: 'Second' } }), + ); + + expect(result).toBe(2); + }); + it('starts select prompts on the default value', async () => { const output = createMemoryOutput(); @@ -300,6 +332,22 @@ describe('choice prompts', () => { expect(output.text()).toContain('Selected: first, second'); }); + it('returns keys from keyed multiselect options', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.space, Key.down, Key.space, Key.enter]), + output, + error: output, + interactive: true, + }, + () => multiselect({ message: 'Pick many', options: { first: 'First', second: 'Second', third: 'Third' } }), + ); + + expect(result).toEqual(['second', 'third']); + }); + it('starts multiselect prompts with default selected values', async () => { const output = createMemoryOutput(); From 2b8f4270eff8ea4c2c66e7d13bffe3e383a72222 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 18:51:55 +0800 Subject: [PATCH 117/675] Normalize array prompt defaults --- packages/tui/src/prompts/search/index.ts | 8 +++--- packages/tui/src/prompts/select/index.ts | 7 +++--- packages/tui/tests/choices.test.ts | 32 +++++++++++++++++++++++- packages/tui/tests/search.test.ts | 32 +++++++++++++++++++++++- 4 files changed, 71 insertions(+), 8 deletions(-) diff --git a/packages/tui/src/prompts/search/index.ts b/packages/tui/src/prompts/search/index.ts index 4f52d7f5..f31f7ac5 100644 --- a/packages/tui/src/prompts/search/index.ts +++ b/packages/tui/src/prompts/search/index.ts @@ -19,9 +19,11 @@ export const search = async (options: SearchPromptOptions): Promise => }; export const multisearch = async (options: MultiSearchPromptOptions): Promise => { - return promptUntilValid(options, async () => { - const selected = await readMultiSearchChoices(options); + const promptOptions = { ...options, default: options.default ?? [] }; - return options.transform ? options.transform(selected) : selected; + return promptUntilValid(promptOptions, async () => { + const selected = await readMultiSearchChoices(promptOptions); + + return promptOptions.transform ? promptOptions.transform(selected) : selected; }); }; diff --git a/packages/tui/src/prompts/select/index.ts b/packages/tui/src/prompts/select/index.ts index 52f9c3f9..08cb8fa4 100644 --- a/packages/tui/src/prompts/select/index.ts +++ b/packages/tui/src/prompts/select/index.ts @@ -23,11 +23,12 @@ export const select = async (options: SelectPromptOptions): Promise => }; export const multiselect = async (options: MultiSelectPromptOptions): Promise => { + const promptOptions = { ...options, default: options.default ?? [] }; const choices = normalizeChoices(options.options); - return promptUntilValid(options, async () => { - const selected = await readMultipleChoices(options.message, choices, options.default, options.hint, options.scroll, options.info); + return promptUntilValid(promptOptions, async () => { + const selected = await readMultipleChoices(promptOptions.message, choices, promptOptions.default, promptOptions.hint, promptOptions.scroll, promptOptions.info); - return options.transform ? options.transform(selected) : selected; + return promptOptions.transform ? promptOptions.transform(selected) : selected; }); }; diff --git a/packages/tui/tests/choices.test.ts b/packages/tui/tests/choices.test.ts index a15c87f4..a0410f62 100644 --- a/packages/tui/tests/choices.test.ts +++ b/packages/tui/tests/choices.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { confirm, createMemoryOutput, createScriptedInput, Key, multiselect, select, withPromptEnvironment } from '#tui/index'; +import { confirm, createMemoryOutput, createScriptedInput, Key, multiselect, PromptValidationError, select, withPromptEnvironment } from '#tui/index'; describe('choice prompts', () => { it('confirms with direct y and n keys', async () => { @@ -365,6 +365,36 @@ describe('choice prompts', () => { expect(output.text()).toContain('Selected: second'); }); + it('returns an empty array for non-interactive multiselect prompts without defaults', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + output, + error: output, + interactive: false, + }, + () => multiselect({ message: 'Pick many', options: ['first', 'second'] }), + ); + + expect(result).toEqual([]); + }); + + it('rejects required non-interactive multiselect prompts without defaults', async () => { + const output = createMemoryOutput(); + + await expect( + withPromptEnvironment( + { + output, + error: output, + interactive: false, + }, + () => multiselect({ message: 'Pick many', options: ['first', 'second'], required: true }), + ), + ).rejects.toThrow(PromptValidationError); + }); + it('requires multiselect choices when configured', async () => { const output = createMemoryOutput(); diff --git a/packages/tui/tests/search.test.ts b/packages/tui/tests/search.test.ts index fe6e8c60..f7473dbf 100644 --- a/packages/tui/tests/search.test.ts +++ b/packages/tui/tests/search.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { createMemoryOutput, createScriptedInput, Key, multisearch, search, withPromptEnvironment } from '#tui/index'; +import { createMemoryOutput, createScriptedInput, Key, multisearch, PromptValidationError, search, withPromptEnvironment } from '#tui/index'; import type { SearchPromptOptions } from '#tui/index'; const colors = (value: string): Record => { @@ -362,6 +362,36 @@ describe('multisearch prompt', () => { expect(output.text()).toContain('Selected: Green'); }); + it('returns an empty array for non-interactive multisearch prompts without defaults', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + output, + error: output, + interactive: false, + }, + () => multisearch({ message: 'Favorite colors?', options: colors }), + ); + + expect(result).toEqual([]); + }); + + it('rejects required non-interactive multisearch prompts without defaults', async () => { + const output = createMemoryOutput(); + + await expect( + withPromptEnvironment( + { + output, + error: output, + interactive: false, + }, + () => multisearch({ message: 'Favorite colors?', options: colors, required: true }), + ), + ).rejects.toThrow(PromptValidationError); + }); + it('renders multisearch info for the highlighted result', async () => { const output = createMemoryOutput(); From 02fd154500ddaa280c49f8a5e48ef23008573784 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 18:55:57 +0800 Subject: [PATCH 118/675] Align number prompt integer behavior --- packages/tui/src/prompts/number/input.ts | 8 ++--- .../src/prompts/number/validators/value.ts | 5 ++- packages/tui/tests/number.test.ts | 33 +++++++++---------- 3 files changed, 22 insertions(+), 24 deletions(-) diff --git a/packages/tui/src/prompts/number/input.ts b/packages/tui/src/prompts/number/input.ts index 5bc25f1a..6dc1e546 100644 --- a/packages/tui/src/prompts/number/input.ts +++ b/packages/tui/src/prompts/number/input.ts @@ -22,7 +22,7 @@ const clamp = (value: number, min?: number, max?: number): number => { }; const steppedValue = (value: string, direction: 1 | -1, options: NumberInputOptions): string => { - const step = options.step !== undefined && options.step > 0 ? options.step : 1; + const step = options.step !== undefined && options.step > 0 ? Math.max(1, Math.trunc(options.step)) : 1; if (value === '') { return String(direction === 1 ? (options.min ?? 1) : (options.max ?? 0)); @@ -32,7 +32,7 @@ const steppedValue = (value: string, direction: 1 | -1, options: NumberInputOpti return value; } - return String(clamp(Number(value) + step * direction, options.min, options.max)); + return String(clamp(Math.trunc(Number(value)) + step * direction, options.min, options.max)); }; const renderNumberValue = (message: string, value: string, options: NumberInputOptions): void => { @@ -68,14 +68,14 @@ export const readNumberValue = async (message: string, options: NumberInputOptio return state.value; } - if (key === Key.up || key === Key.upArrow || key === Key.ctrlP) { + if (key === Key.up || key === Key.upArrow) { state.value = steppedValue(state.value, 1, options); state.cursor = state.value.length; renderNumberValue(message, state.value, options); continue; } - if (key === Key.down || key === Key.downArrow || key === Key.ctrlN) { + if (key === Key.down || key === Key.downArrow) { state.value = steppedValue(state.value, -1, options); state.cursor = state.value.length; renderNumberValue(message, state.value, options); diff --git a/packages/tui/src/prompts/number/validators/value.ts b/packages/tui/src/prompts/number/validators/value.ts index cb266ac0..a297e92e 100644 --- a/packages/tui/src/prompts/number/validators/value.ts +++ b/packages/tui/src/prompts/number/validators/value.ts @@ -9,7 +9,6 @@ export type NumberValidationResult = { }; const numericInputSchema = z.string(); -const integerPattern = /^[+-]?\d+$/u; const isNumeric = (value: string): boolean => value.trim() !== '' && Number.isFinite(Number(value)); @@ -21,7 +20,7 @@ export const parseNumberInput = (input: unknown, options: Pick { expect(output.text()).toContain('Must be a number'); }); - it('rejects decimal values for integer prompts', async () => { + it('casts decimal numeric values to integers', async () => { const output = createMemoryOutput(); const result = await withPromptEnvironment( { - input: createScriptedInput(['1', '.', '5', Key.enter, '2', Key.enter]), + input: createScriptedInput(['1', '.', '5', Key.enter]), output, error: output, interactive: true, @@ -33,8 +33,7 @@ describe('number prompt', () => { () => number({ message: 'Count', integer: true }), ); - expect(result).toBe(2); - expect(output.text()).toContain('Must be a number'); + expect(result).toBe(1); }); it('returns an empty string for optional empty input', async () => { @@ -104,7 +103,7 @@ describe('number prompt', () => { expect(output.text()).toContain('Required.'); }); - it('returns decimal input as a decimal value', async () => { + it('returns decimal input as an integer value', async () => { const output = createMemoryOutput(); const result = await withPromptEnvironment( @@ -117,12 +116,12 @@ describe('number prompt', () => { () => number({ message: 'Count' }), ); - expect(result).toBe(1.9); + expect(result).toBe(1); }); - it('parses decimals unless integer input is required', () => { - expect(parseNumberInput('1.9')).toEqual({ value: 1.9 }); - expect(parseNumberInput('1.9', { integer: true })).toEqual({ error: 'Must be a number' }); + it('parses numeric values as integers', () => { + expect(parseNumberInput('1.9')).toEqual({ value: 1 }); + expect(parseNumberInput('1.9', { integer: true })).toEqual({ value: 1 }); expect(parseNumberInput('1', { integer: true })).toEqual({ value: 1 }); }); @@ -161,7 +160,7 @@ describe('number prompt', () => { expect(output.text()).toContain('? Count 1'); }); - it('increments and decrements with control navigation keys', async () => { + it('does not increment or decrement with control navigation keys', async () => { const output = createMemoryOutput(); const result = await withPromptEnvironment( @@ -174,11 +173,11 @@ describe('number prompt', () => { () => number({ message: 'Count', step: 2 }), ); - expect(result).toBe(1); - expect(output.text()).toContain('? Count 1'); + expect(result).toBe(''); + expect(output.text()).not.toContain('? Count 1'); }); - it('increments and decrements decimal values with fractional steps', async () => { + it('increments and decrements decimal values with whole steps', async () => { const output = createMemoryOutput(); const result = await withPromptEnvironment( @@ -191,9 +190,9 @@ describe('number prompt', () => { () => number({ message: 'Amount', default: 1.5, step: 0.25 }), ); - expect(result).toBe(1.25); - expect(output.text()).toContain('? Amount 1.75'); - expect(output.text()).toContain('? Amount 1.25'); + expect(result).toBe(0); + expect(output.text()).toContain('? Amount 2'); + expect(output.text()).toContain('? Amount 0'); }); it('falls back to a whole step for invalid step sizes', async () => { @@ -209,7 +208,7 @@ describe('number prompt', () => { () => number({ message: 'Amount', default: 1.5, step: 0 }), ); - expect(result).toBe(2.5); + expect(result).toBe(2); }); it('renders default number values before input', async () => { From bc256f29623f8aa7ecd6c4321ccc3ba4e7d3738f Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 18:59:41 +0800 Subject: [PATCH 119/675] Normalize number prompt default --- packages/tui/src/prompts/number.ts | 5 +++-- packages/tui/tests/number.test.ts | 32 +++++++++++++++++++++++++++++- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/packages/tui/src/prompts/number.ts b/packages/tui/src/prompts/number.ts index e483c691..a2eafcbd 100644 --- a/packages/tui/src/prompts/number.ts +++ b/packages/tui/src/prompts/number.ts @@ -20,7 +20,7 @@ export function number( export async function number( message: string | NumberPromptOptions, placeholder = '', - defaultValue: number | string | undefined = undefined, + defaultValue: number | string = '', required: boolean | string = false, validate: NumberPromptOptions['validate'] = undefined, hint = '', @@ -28,7 +28,8 @@ export async function number( max: number | undefined = undefined, step: number | undefined = undefined, ): Promise { - const options: NumberPromptOptions = typeof message === 'string' ? { message, label: message, placeholder, default: defaultValue, required, validate, hint, min, max, step } : { ...message }; + const options: NumberPromptOptions = + typeof message === 'string' ? { message, label: message, placeholder, default: defaultValue, required, validate, hint, min, max, step } : { ...message, default: message.default ?? '' }; return promptUntilValid(options, async () => { const answer = await readNumberValue(options.message, { diff --git a/packages/tui/tests/number.test.ts b/packages/tui/tests/number.test.ts index 95960d7b..4327bc6d 100644 --- a/packages/tui/tests/number.test.ts +++ b/packages/tui/tests/number.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { createMemoryOutput, createScriptedInput, Key, number, withPromptEnvironment } from '#tui/index'; +import { createMemoryOutput, createScriptedInput, Key, number, PromptValidationError, withPromptEnvironment } from '#tui/index'; import { parseNumberInput } from '#tui/prompts/number/validators/value'; describe('number prompt', () => { @@ -228,6 +228,36 @@ describe('number prompt', () => { expect(output.text()).toContain('? Count 7'); }); + it('returns an empty string for non-interactive number prompts without defaults', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + output, + error: output, + interactive: false, + }, + () => number({ message: 'Count' }), + ); + + expect(result).toBe(''); + }); + + it('rejects required non-interactive number prompts without defaults', async () => { + const output = createMemoryOutput(); + + await expect( + withPromptEnvironment( + { + output, + error: output, + interactive: false, + }, + () => number({ message: 'Count', required: true }), + ), + ).rejects.toThrow(PromptValidationError); + }); + it('clamps arrow key changes to min and max', async () => { const output = createMemoryOutput(); From 055b46066997036f67b7c5186a2eb06953f60e67 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 19:02:34 +0800 Subject: [PATCH 120/675] Align multiline control navigation --- packages/tui/src/typed-value.ts | 4 ++-- packages/tui/tests/typed-value.test.ts | 12 ++++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/tui/src/typed-value.ts b/packages/tui/src/typed-value.ts index b232058b..9219d645 100644 --- a/packages/tui/src/typed-value.ts +++ b/packages/tui/src/typed-value.ts @@ -144,11 +144,11 @@ export const applyTypedKey = (state: TypedValueState, key: string, allowNewLine return { cursor: Math.min(value.length, cursor + 1), value: fromCharacters(value), submitted: false, cancelled: false }; } - if (allowNewLine && (key === Key.up || key === Key.upArrow || key === Key.ctrlP)) { + if (allowNewLine && (key === Key.up || key === Key.upArrow)) { return { cursor: moveLine(value, cursor, -1), value: fromCharacters(value), submitted: false, cancelled: false }; } - if (allowNewLine && (key === Key.down || key === Key.downArrow || key === Key.ctrlN)) { + if (allowNewLine && (key === Key.down || key === Key.downArrow)) { return { cursor: moveLine(value, cursor, 1), value: fromCharacters(value), submitted: false, cancelled: false }; } diff --git a/packages/tui/tests/typed-value.test.ts b/packages/tui/tests/typed-value.test.ts index 70653495..cf0d6cd2 100644 --- a/packages/tui/tests/typed-value.test.ts +++ b/packages/tui/tests/typed-value.test.ts @@ -99,6 +99,18 @@ describe('typed value editing', () => { }); }); + it('does not move between textarea lines with control navigation keys', () => { + expect(applyMultilineState('abc\nde\nfghi', 6, [Key.ctrlP])).toEqual({ + cursor: 6, + value: 'abc\nde\nfghi', + }); + + expect(applyMultilineState('abc\nde\nfghi', 2, [Key.ctrlN])).toEqual({ + cursor: 2, + value: 'abc\nde\nfghi', + }); + }); + it('moves to textarea line boundaries', () => { expect(applyMultilineState('abc\nde\nfghi', 5, [Key.home[0]])).toEqual({ cursor: 4, From ac4b5982bfd61f48097d3749f977929aa32388d3 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 19:04:58 +0800 Subject: [PATCH 121/675] Normalize confirm direct keys --- .../tui/src/prompts/select/read-confirm.ts | 6 +++-- packages/tui/tests/choices.test.ts | 27 +++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/packages/tui/src/prompts/select/read-confirm.ts b/packages/tui/src/prompts/select/read-confirm.ts index 1319b46d..8b4536c9 100644 --- a/packages/tui/src/prompts/select/read-confirm.ts +++ b/packages/tui/src/prompts/select/read-confirm.ts @@ -39,13 +39,15 @@ export const readConfirm = async (options: ConfirmPromptOptions): Promise { expect(declined).toBe(false); }); + it('confirms with uppercase direct y and n keys', async () => { + const output = createMemoryOutput(); + + const accepted = await withPromptEnvironment( + { + input: createScriptedInput(['Y', Key.enter]), + output, + error: output, + interactive: true, + }, + () => confirm('Continue?', false), + ); + + const declined = await withPromptEnvironment( + { + input: createScriptedInput(['N', Key.enter]), + output, + error: output, + interactive: true, + }, + () => confirm('Continue?'), + ); + + expect(accepted).toBe(true); + expect(declined).toBe(false); + }); + it('toggles confirm values with navigation keys', async () => { const output = createMemoryOutput(); From 51bfc2bfc36195f45cb49067e9cd0e0ea197325b Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 19:08:06 +0800 Subject: [PATCH 122/675] Align terminal clear sequence --- packages/tui/src/terminal.ts | 2 +- packages/tui/tests/terminal.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/tui/src/terminal.ts b/packages/tui/src/terminal.ts index a2028d14..ff6431d2 100644 --- a/packages/tui/src/terminal.ts +++ b/packages/tui/src/terminal.ts @@ -11,7 +11,7 @@ export const terminalSize = (): TerminalSize => ({ }); export const clearTerminal = (): void => { - promptEnvironment().output.write('\u001Bc'); + promptEnvironment().output.write('\u001B[H\u001B[J'); }; export const setTerminalTitle = (title: string): void => { diff --git a/packages/tui/tests/terminal.test.ts b/packages/tui/tests/terminal.test.ts index 38b0ee87..f50e2e6b 100644 --- a/packages/tui/tests/terminal.test.ts +++ b/packages/tui/tests/terminal.test.ts @@ -10,7 +10,7 @@ describe('terminal helpers', () => { expect(clear()).toBe(true); }); - expect(output.text()).toBe('\u001Bc\u001Bc'); + expect(output.text()).toBe('\u001B[H\u001B[J\u001B[H\u001B[J'); }); it('writes terminal title control sequences', async () => { From ab42051a26201073f992bff4329940006ab630ac Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 19:12:36 +0800 Subject: [PATCH 123/675] Support headerless static tables --- packages/tui/src/output/validators/table.ts | 8 +++++ packages/tui/src/theme.ts | 8 +++-- packages/tui/tests/table.test.ts | 33 +++++++++++++++++++++ 3 files changed, 46 insertions(+), 3 deletions(-) diff --git a/packages/tui/src/output/validators/table.ts b/packages/tui/src/output/validators/table.ts index 6c8a0f8f..25a0d58a 100644 --- a/packages/tui/src/output/validators/table.ts +++ b/packages/tui/src/output/validators/table.ts @@ -22,6 +22,14 @@ export const parseTableOptions = (headersOrOptions: unknown = [], rows: unknown return { headers, rows: tableOptions.data.rows }; } + if (rows === null) { + const rowOnlyOptions = tableRowsSchema.safeParse(headersOrOptions); + + if (rowOnlyOptions.success) { + return { headers: [], rows: rowOnlyOptions.data }; + } + } + return { headers: tableHeadersSchema.parse(headersOrOptions), rows: rows === null ? [] : tableRowsSchema.parse(rows), diff --git a/packages/tui/src/theme.ts b/packages/tui/src/theme.ts index 11404672..602e4e0b 100644 --- a/packages/tui/src/theme.ts +++ b/packages/tui/src/theme.ts @@ -33,8 +33,10 @@ export const renderChoices = (choices: Array>): string => { }; export const renderTable = (headers: string[], rows: string[][]): string => { - const widths = headers.map((header, index) => { - return Math.max(visibleWidth(header), ...rows.map((row) => visibleWidth(row[index] ?? ''))); + const columnCount = Math.max(headers.length, ...rows.map((row) => row.length)); + + const widths = Array.from({ length: columnCount }, (_, index) => { + return Math.max(visibleWidth(headers[index] ?? ''), ...rows.map((row) => visibleWidth(row[index] ?? ''))); }); const padVisible = (value: string, width: number): string => `${value}${' '.repeat(Math.max(0, width - visibleWidth(value)))}`; @@ -45,5 +47,5 @@ export const renderTable = (headers: string[], rows: string[][]): string => { const divider = `| ${widths.map((width) => '-'.repeat(width)).join(' | ')} |`; - return [renderRow(headers), divider, ...rows.map(renderRow)].join('\n'); + return headers.length > 0 ? [renderRow(headers), divider, ...rows.map(renderRow)].join('\n') : rows.map(renderRow).join('\n'); }; diff --git a/packages/tui/tests/table.test.ts b/packages/tui/tests/table.test.ts index 8d31200c..55d42a6a 100644 --- a/packages/tui/tests/table.test.ts +++ b/packages/tui/tests/table.test.ts @@ -74,6 +74,39 @@ describe('table output', () => { expect(output.text()).toContain('| Prompts | TypeScript |'); }); + it('renders object table rows in explicit header order', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + table({ + headers: ['Runtime', 'Name', 'Notes'], + rows: [ + { Name: 'Ollin', Runtime: 'OpenTUI' }, + { Name: 'Prompts', Runtime: 'TypeScript', Notes: 'Port' }, + ], + }); + }); + + expect(output.text()).toContain('| Runtime | Name | Notes |'); + expect(output.text()).toContain('| OpenTUI | Ollin | |'); + expect(output.text()).toContain('| TypeScript | Prompts | Port |'); + }); + + it('renders tables without headers from row-only input', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + table([ + ['Ollin', 'OpenTUI'], + ['Prompts', 'TypeScript'], + ]); + }); + + expect(output.text()).not.toContain('---'); + expect(output.text()).toContain('| Ollin | OpenTUI |'); + expect(output.text()).toContain('| Prompts | TypeScript |'); + }); + it('rejects invalid table runtime shapes through the validator layer', async () => { const output = createMemoryOutput(); From dc964cf8c6cd6dca2b9e839fea82665e2a91bf55 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 19:15:53 +0800 Subject: [PATCH 124/675] Split data table row concerns --- packages/tui/src/output/data-table.ts | 107 ++---------------- packages/tui/src/output/data-table/rows.ts | 61 ++++++++++ .../tui/src/output/data-table/selection.ts | 23 ++++ packages/tui/src/output/data-table/types.ts | 6 + packages/tui/tests/table.test.ts | 25 ++++ 5 files changed, 127 insertions(+), 95 deletions(-) create mode 100644 packages/tui/src/output/data-table/rows.ts create mode 100644 packages/tui/src/output/data-table/selection.ts create mode 100644 packages/tui/src/output/data-table/types.ts diff --git a/packages/tui/src/output/data-table.ts b/packages/tui/src/output/data-table.ts index 365723a8..2b70efb6 100644 --- a/packages/tui/src/output/data-table.ts +++ b/packages/tui/src/output/data-table.ts @@ -1,94 +1,15 @@ import { promptEnvironment } from '#tui/environment'; import { Key, oneOf } from '#tui/key'; -import { parseOptionalScrollSize, parseScrollSize } from '#tui/concerns/validators/scroll'; +import { parseScrollSize } from '#tui/concerns/validators/scroll'; import { promptUntilValid, PromptValidationError } from '#tui/prompt'; import { renderTable } from '#tui/theme'; import { applyTypedKey } from '#tui/typed-value'; -import { isDataObjectRow } from '#tui/output/validators/data-table'; -import type { DataTablePromptOptions, DataTableRow, TableCell } from '#tui/types'; - -type VisibleRow = { - index: number; - row: DataTableRow; -}; - -const stringify = (value: TableCell): string => { - return value === null || value === undefined ? '' : String(value); -}; - -const rowFields = (row: DataTableRow): Record => { - if (Array.isArray(row)) { - return Object.fromEntries(row.map((value, index) => [String(index), value])); - } - - if (isDataObjectRow(row)) { - return row.cells; - } - - return row; -}; - -const derivedHeaders = (rows: Array>): string[] => { - const first = rows[0]; - - if (!first) { - return []; - } - - if (Array.isArray(first)) { - return first.map((_, index) => String(index + 1)); - } - - return Object.keys(rowFields(first)).filter((key) => key !== 'value'); -}; - -const rowCells = (headers: string[], row: DataTableRow): string[] => { - if (Array.isArray(row)) { - return headers.length > 0 ? headers.map((_, index) => stringify(row[index])) : row.map(stringify); - } - - const fields = rowFields(row); - - return headers.map((header) => stringify(fields[header])); -}; - -const rowValue = (row: DataTableRow, index: number): T | number => { - if (isDataObjectRow(row) && row.value !== undefined) { - return row.value; - } - - if (!Array.isArray(row) && 'value' in row && row.value !== undefined) { - return row.value as T; - } - - return index; -}; - -const rowLabel = (headers: string[], row: DataTableRow): string => rowCells(headers, row).join(' '); - -const clampSelected = (selected: number, rows: Array>): number => { - if (rows.length === 0) { - return 0; - } - - return Math.min(selected, rows.length - 1); -}; - -const rowWindow = (total: number, selected: number, scroll?: number): { end: number; start: number } => { - const size = parseOptionalScrollSize(scroll); - - if (size === undefined || size >= total) { - return { end: total, start: 0 }; - } - - const before = Math.floor((size - 1) / 2); - const start = Math.max(0, Math.min(selected - before, total - size)); - - return { end: start + size, start }; -}; +import { dataTableRowCells, dataTableRowValue, deriveDataTableHeaders, visibleDataTableRows } from '#tui/output/data-table/rows'; +import { clampDataTableSelection, dataTableRowWindow } from '#tui/output/data-table/selection'; +import type { DataTablePromptOptions } from '#tui/types'; export const datatable = async (options: DataTablePromptOptions): Promise => { - const headers = options.headers ?? derivedHeaders(options.rows); + const headers = options.headers ?? deriveDataTableHeaders(options.rows); return promptUntilValid(options, async () => { const environment = promptEnvironment(); @@ -97,23 +18,19 @@ export const datatable = async (options: DataTablePromptOptions) let mode: 'browse' | 'search' = 'browse'; let query = { cursor: 0, value: '' }; - const visibleRows = (): Array> => { - return options.rows - .map((row, index) => ({ index, row })) - .filter(({ row }) => options.filter?.(query.value, row) ?? rowLabel(headers, row).toLowerCase().includes(query.value.toLowerCase())); - }; + const visibleRows = () => visibleDataTableRows(options, headers, query.value); const render = (): void => { const rows = visibleRows(); - selected = clampSelected(selected, rows); + selected = clampDataTableSelection(selected, rows); - const window = rowWindow(rows.length, selected, options.scroll); + const window = dataTableRowWindow(rows.length, selected, options.scroll); const renderedRows = rows.slice(window.start, window.end).map(({ row }, offset) => { const index = window.start + offset; - return [index === selected ? '›' : ' ', ...rowCells(headers, row)]; + return [index === selected ? '›' : ' ', ...dataTableRowCells(headers, row)]; }); const querySuffix = mode === 'search' || query.value.length > 0 ? ` ${query.value}` : ''; @@ -130,7 +47,7 @@ export const datatable = async (options: DataTablePromptOptions) throw new PromptValidationError('Please select a valid row.'); } - return rowValue(selectedRow.row, selectedRow.index); + return dataTableRowValue(selectedRow.row, selectedRow.index); } render(); @@ -153,7 +70,7 @@ export const datatable = async (options: DataTablePromptOptions) throw new PromptValidationError('Please select a valid row.'); } - return rowValue(selectedRow.row, selectedRow.index); + return dataTableRowValue(selectedRow.row, selectedRow.index); } if (mode === 'search') { @@ -233,7 +150,7 @@ export const datatable = async (options: DataTablePromptOptions) throw new PromptValidationError('Please select a valid row.'); } - return rowValue(selectedRow.row, selectedRow.index); + return dataTableRowValue(selectedRow.row, selectedRow.index); } } }); diff --git a/packages/tui/src/output/data-table/rows.ts b/packages/tui/src/output/data-table/rows.ts new file mode 100644 index 00000000..5ccb5cac --- /dev/null +++ b/packages/tui/src/output/data-table/rows.ts @@ -0,0 +1,61 @@ +import { isDataObjectRow } from '#tui/output/validators/data-table'; +import type { VisibleDataTableRow } from '#tui/output/data-table/types'; +import type { DataTablePromptOptions, DataTableRow, TableCell } from '#tui/types'; + +const stringify = (value: TableCell): string => { + return value === null || value === undefined ? '' : String(value); +}; + +export const dataTableRowFields = (row: DataTableRow): Record => { + if (Array.isArray(row)) { + return Object.fromEntries(row.map((value, index) => [String(index), value])); + } + + if (isDataObjectRow(row)) { + return row.cells; + } + + return row; +}; + +export const deriveDataTableHeaders = (rows: Array>): string[] => { + const first = rows[0]; + + if (!first) { + return []; + } + + if (Array.isArray(first)) { + return first.map((_, index) => String(index + 1)); + } + + return Object.keys(dataTableRowFields(first)).filter((key) => key !== 'value'); +}; + +export const dataTableRowCells = (headers: string[], row: DataTableRow): string[] => { + if (Array.isArray(row)) { + return headers.length > 0 ? headers.map((_, index) => stringify(row[index])) : row.map(stringify); + } + + const fields = dataTableRowFields(row); + + return headers.map((header) => stringify(fields[header])); +}; + +export const dataTableRowValue = (row: DataTableRow, index: number): T | number => { + if (isDataObjectRow(row) && row.value !== undefined) { + return row.value; + } + + if (!Array.isArray(row) && 'value' in row && row.value !== undefined) { + return row.value as T; + } + + return index; +}; + +export const dataTableRowLabel = (headers: string[], row: DataTableRow): string => dataTableRowCells(headers, row).join(' '); + +export const visibleDataTableRows = (options: DataTablePromptOptions, headers: string[], query: string): Array> => { + return options.rows.map((row, index) => ({ index, row })).filter(({ row }) => options.filter?.(query, row) ?? dataTableRowLabel(headers, row).toLowerCase().includes(query.toLowerCase())); +}; diff --git a/packages/tui/src/output/data-table/selection.ts b/packages/tui/src/output/data-table/selection.ts new file mode 100644 index 00000000..74bf0189 --- /dev/null +++ b/packages/tui/src/output/data-table/selection.ts @@ -0,0 +1,23 @@ +import { parseOptionalScrollSize } from '#tui/concerns/validators/scroll'; +import type { VisibleDataTableRow } from '#tui/output/data-table/types'; + +export const clampDataTableSelection = (selected: number, rows: Array>): number => { + if (rows.length === 0) { + return 0; + } + + return Math.min(selected, rows.length - 1); +}; + +export const dataTableRowWindow = (total: number, selected: number, scroll?: number): { end: number; start: number } => { + const size = parseOptionalScrollSize(scroll); + + if (size === undefined || size >= total) { + return { end: total, start: 0 }; + } + + const before = Math.floor((size - 1) / 2); + const start = Math.max(0, Math.min(selected - before, total - size)); + + return { end: start + size, start }; +}; diff --git a/packages/tui/src/output/data-table/types.ts b/packages/tui/src/output/data-table/types.ts new file mode 100644 index 00000000..dcb8f491 --- /dev/null +++ b/packages/tui/src/output/data-table/types.ts @@ -0,0 +1,6 @@ +import type { DataTableRow } from '#tui/types'; + +export type VisibleDataTableRow = { + index: number; + row: DataTableRow; +}; diff --git a/packages/tui/tests/table.test.ts b/packages/tui/tests/table.test.ts index 55d42a6a..5e6c80f1 100644 --- a/packages/tui/tests/table.test.ts +++ b/packages/tui/tests/table.test.ts @@ -170,6 +170,31 @@ describe('data table prompt', () => { expect(output.text()).toContain('Pick project B'); }); + it('uses custom data table filters for search mode', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['/', 'slow', Key.enter, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + datatable({ + message: 'Pick project', + filter: (query, row) => !Array.isArray(row) && row.Tag === query, + rows: [ + { Name: 'Alpha', Tag: 'fast', value: 'alpha' }, + { Name: 'Beta', Tag: 'slow', value: 'beta' }, + ], + }), + ); + + expect(result).toBe('beta'); + expect(output.text()).toContain('Pick project slow'); + }); + it('does not filter rows from printable keys in browse mode', async () => { const output = createMemoryOutput(); From ba1f94a4060edec840a62cca3c8d12ed48055636 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 19:18:44 +0800 Subject: [PATCH 125/675] Split task logger concerns --- packages/tui/src/status/task.ts | 95 +----------------------- packages/tui/src/status/task/logger.ts | 88 ++++++++++++++++++++++ packages/tui/src/status/task/messages.ts | 4 + packages/tui/src/status/task/sanitize.ts | 6 ++ packages/tui/tests/task.test.ts | 12 +++ 5 files changed, 112 insertions(+), 93 deletions(-) create mode 100644 packages/tui/src/status/task/logger.ts create mode 100644 packages/tui/src/status/task/messages.ts create mode 100644 packages/tui/src/status/task/sanitize.ts diff --git a/packages/tui/src/status/task.ts b/packages/tui/src/status/task.ts index f58df71d..33399f2a 100644 --- a/packages/tui/src/status/task.ts +++ b/packages/tui/src/status/task.ts @@ -1,10 +1,7 @@ import { promptEnvironment } from '#tui/environment'; -import { parseLogLimit } from '#tui/status/validators/limit'; +import { Logger } from '#tui/status/task/logger'; import type { MaybePromise } from '#tui/types'; -const cursorReset = `${String.fromCharCode(27)}[1G`; -const eraseLine = `${String.fromCharCode(27)}[2K`; - export type TaskDefinition = { keepSummary?: boolean; subLabel?: string; @@ -13,95 +10,7 @@ export type TaskDefinition = { task: (logger: Logger) => MaybePromise; }; -type StableMessage = { - message: string; - type: 'error' | 'success' | 'warning'; -}; - -export class Logger { - labelValue: string; - readonly lines: string[] = []; - readonly stableMessages: StableMessage[] = []; - subLabelValue: string; - #partialBuffer = ''; - #partialStartIndex: number | null = null; - private readonly limit: number; - private readonly stableLimit: number; - - constructor(limit: number, label: string, subLabel = '') { - this.limit = parseLogLimit(limit, 10); - this.stableLimit = parseLogLimit(limit, 10); - this.labelValue = label; - this.subLabelValue = subLabel; - } - - line(message: string): void { - this.writeLines(message.trimEnd()); - } - - log(message: string): void { - this.line(message); - } - - label(message: string): void { - this.labelValue = message; - } - - subLabel(message: string): void { - this.subLabelValue = message; - } - - partial(chunk: string): void { - this.#partialBuffer += chunk; - - if (this.#partialStartIndex === null) { - this.#partialStartIndex = this.lines.length; - } - - this.lines.splice(this.#partialStartIndex); - this.writeLines(this.#partialBuffer); - this.#partialStartIndex = Math.min(this.#partialStartIndex, this.lines.length); - } - - commitPartial(): void { - this.#partialBuffer = ''; - this.#partialStartIndex = null; - } - - 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: StableMessage['type'], message: string): void { - this.stableMessages.push({ message, type }); - - while (this.stableMessages.length > this.stableLimit) { - this.stableMessages.shift(); - } - } - - private writeLines(message: string): void { - for (const line of message.split(/\r?\n/u).filter((value) => value.length > 0)) { - this.lines.push(line.replaceAll(cursorReset, '').replaceAll(eraseLine, '')); - } - - while (this.lines.length > this.limit) { - this.lines.shift(); - } - } -} +export { Logger }; export async function task(definition: TaskDefinition): Promise; diff --git a/packages/tui/src/status/task/logger.ts b/packages/tui/src/status/task/logger.ts new file mode 100644 index 00000000..ca6a707a --- /dev/null +++ b/packages/tui/src/status/task/logger.ts @@ -0,0 +1,88 @@ +import { parseLogLimit } from '#tui/status/validators/limit'; +import { sanitizeTaskLine } from '#tui/status/task/sanitize'; +import type { StableTaskMessage } from '#tui/status/task/messages'; + +export class Logger { + labelValue: string; + readonly lines: string[] = []; + readonly stableMessages: StableTaskMessage[] = []; + subLabelValue: string; + #partialBuffer = ''; + #partialStartIndex: number | null = null; + private readonly limit: number; + private readonly stableLimit: number; + + constructor(limit: number, label: string, subLabel = '') { + this.limit = parseLogLimit(limit, 10); + this.stableLimit = parseLogLimit(limit, 10); + this.labelValue = label; + this.subLabelValue = subLabel; + } + + line(message: string): void { + this.writeLines(message.trimEnd()); + } + + log(message: string): void { + this.line(message); + } + + label(message: string): void { + this.labelValue = message; + } + + subLabel(message: string): void { + this.subLabelValue = message; + } + + partial(chunk: string): void { + this.#partialBuffer += chunk; + + if (this.#partialStartIndex === null) { + this.#partialStartIndex = this.lines.length; + } + + this.lines.splice(this.#partialStartIndex); + this.writeLines(this.#partialBuffer); + this.#partialStartIndex = Math.min(this.#partialStartIndex, this.lines.length); + } + + commitPartial(): void { + this.#partialBuffer = ''; + this.#partialStartIndex = null; + } + + 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.stableMessages.push({ message, type }); + + while (this.stableMessages.length > this.stableLimit) { + this.stableMessages.shift(); + } + } + + private writeLines(message: string): void { + for (const line of message.split(/\r?\n/u).filter((value) => value.length > 0)) { + this.lines.push(sanitizeTaskLine(line)); + } + + while (this.lines.length > this.limit) { + this.lines.shift(); + } + } +} diff --git a/packages/tui/src/status/task/messages.ts b/packages/tui/src/status/task/messages.ts new file mode 100644 index 00000000..f1b47a15 --- /dev/null +++ b/packages/tui/src/status/task/messages.ts @@ -0,0 +1,4 @@ +export type StableTaskMessage = { + message: string; + type: 'error' | 'success' | 'warning'; +}; diff --git a/packages/tui/src/status/task/sanitize.ts b/packages/tui/src/status/task/sanitize.ts new file mode 100644 index 00000000..109a30dd --- /dev/null +++ b/packages/tui/src/status/task/sanitize.ts @@ -0,0 +1,6 @@ +const cursorReset = `${String.fromCharCode(27)}[1G`; +const eraseLine = `${String.fromCharCode(27)}[2K`; + +export const sanitizeTaskLine = (line: string): string => { + return line.replaceAll(cursorReset, '').replaceAll(eraseLine, '').replaceAll('\r', ''); +}; diff --git a/packages/tui/tests/task.test.ts b/packages/tui/tests/task.test.ts index 397bb9db..cf3a1426 100644 --- a/packages/tui/tests/task.test.ts +++ b/packages/tui/tests/task.test.ts @@ -58,6 +58,18 @@ describe('task helper', () => { expect(output.text()).toContain('beforeafter'); }); + it('strips carriage returns from task log lines', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + await task('Running...', (logger) => { + logger.log('before\rafter'); + }); + }); + + expect(output.text()).toContain('beforeafter'); + }); + it('trims trailing whitespace from completed log lines', async () => { const output = createMemoryOutput(); From 1604dff276816b89110964d02a854e1b6bc54ab9 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 19:21:52 +0800 Subject: [PATCH 126/675] Centralize choice answer parsing --- packages/tui/src/concerns/choices.ts | 10 ++--- .../src/concerns/validators/choice-answer.ts | 24 ++++++++++ packages/tui/src/prompts/select/navigation.ts | 3 +- packages/tui/tests/choices.test.ts | 44 +++++++++++++++++++ 4 files changed, 74 insertions(+), 7 deletions(-) create mode 100644 packages/tui/src/concerns/validators/choice-answer.ts diff --git a/packages/tui/src/concerns/choices.ts b/packages/tui/src/concerns/choices.ts index 8e6d4463..a3dc92f7 100644 --- a/packages/tui/src/concerns/choices.ts +++ b/packages/tui/src/concerns/choices.ts @@ -1,17 +1,16 @@ import { promptEnvironment } from '#tui/environment'; +import { parseChoiceAnswerIndex, parseChoiceRecordKey } from '#tui/concerns/validators/choice-answer'; import { parseChoice, parseChoiceRecord } from '#tui/concerns/validators/choice'; import { parseOptionalScrollSize } from '#tui/concerns/validators/scroll'; import type { Choice, ChoiceOptions } from '#tui/types'; -const normalizeChoiceRecordKey = (key: string): string | number => (/^-?\d+$/u.test(key) ? Number.parseInt(key, 10) : key); - export const normalizeChoices = (options: ChoiceOptions): Array> => { if (!Array.isArray(options)) { const parsed = parseChoiceRecord(options); return Object.entries(parsed ?? {}).map(([value, label]) => ({ label, - value: normalizeChoiceRecordKey(value) as T, + value: parseChoiceRecordKey(value) as T, })); } @@ -33,10 +32,9 @@ export const normalizeSearchChoices = (options: ChoiceOptions): Array(choices: Array>, answer: string): Choice | undefined => { const normalizedAnswer = answer.trim(); + const index = parseChoiceAnswerIndex(normalizedAnswer); - if (/^\d+$/u.test(normalizedAnswer)) { - const index = Number.parseInt(normalizedAnswer, 10); - + if (!Number.isNaN(index)) { return choices[index - 1]; } diff --git a/packages/tui/src/concerns/validators/choice-answer.ts b/packages/tui/src/concerns/validators/choice-answer.ts new file mode 100644 index 00000000..104c4393 --- /dev/null +++ b/packages/tui/src/concerns/validators/choice-answer.ts @@ -0,0 +1,24 @@ +import { z } from 'zod'; + +const choiceAnswerIndexSchema = z + .string() + .trim() + .regex(/^\d+$/u) + .transform((value) => Number.parseInt(value, 10)); + +const choiceRecordKeySchema = z + .string() + .regex(/^-?\d+$/u) + .transform((value) => Number.parseInt(value, 10)); + +export const parseChoiceAnswerIndex = (answer: string): number => { + const parsed = choiceAnswerIndexSchema.safeParse(answer); + + return parsed.success ? parsed.data : Number.NaN; +}; + +export const parseChoiceRecordKey = (key: string): string | number => { + const parsed = choiceRecordKeySchema.safeParse(key); + + return parsed.success ? parsed.data : key; +}; diff --git a/packages/tui/src/prompts/select/navigation.ts b/packages/tui/src/prompts/select/navigation.ts index bd13c7c9..d8e220e2 100644 --- a/packages/tui/src/prompts/select/navigation.ts +++ b/packages/tui/src/prompts/select/navigation.ts @@ -1,8 +1,9 @@ +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 => (/^\d+$/u.test(key) ? Number.parseInt(key, 10) : Number.NaN); +export const parseChoiceIndex = (key: string): number => 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'; diff --git a/packages/tui/tests/choices.test.ts b/packages/tui/tests/choices.test.ts index 519b5b4f..8dcc66cb 100644 --- a/packages/tui/tests/choices.test.ts +++ b/packages/tui/tests/choices.test.ts @@ -324,6 +324,28 @@ describe('choice prompts', () => { expect(result).toBe('second'); }); + it('rejects partial numeric line-mode select answers and retries', async () => { + const output = createMemoryOutput(); + const answers = ['1abc', '2']; + + const result = await withPromptEnvironment( + { + input: { + async readLine(): Promise { + return answers.shift() ?? ''; + }, + }, + output, + error: output, + interactive: true, + }, + () => select({ message: 'Pick one', options: ['first', 'second'] }), + ); + + expect(result).toBe('second'); + expect(output.text()).toContain('Please select a valid option.'); + }); + it('cancels select prompts with the current highlighted option', async () => { const output = createMemoryOutput(); @@ -407,6 +429,28 @@ describe('choice prompts', () => { expect(result).toEqual([]); }); + it('rejects invalid comma-separated line-mode multiselect answers and retries', async () => { + const output = createMemoryOutput(); + const answers = ['1,missing', '1,2']; + + const result = await withPromptEnvironment( + { + input: { + async readLine(): Promise { + return answers.shift() ?? ''; + }, + }, + output, + error: output, + interactive: true, + }, + () => multiselect({ message: 'Pick many', options: ['first', 'second'] }), + ); + + expect(result).toEqual(['first', 'second']); + expect(output.text()).toContain('Please select valid options.'); + }); + it('rejects required non-interactive multiselect prompts without defaults', async () => { const output = createMemoryOutput(); From 0324ac70964ad4c3b63584ec96b56117a98933f5 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 19:24:53 +0800 Subject: [PATCH 127/675] Split typed value editing concerns --- packages/tui/src/typed-value.ts | 215 +-------------------- packages/tui/src/typed-value/characters.ts | 15 ++ packages/tui/src/typed-value/edit.ts | 107 ++++++++++ packages/tui/src/typed-value/lines.ts | 78 ++++++++ packages/tui/src/typed-value/render.ts | 10 + packages/tui/src/typed-value/types.ts | 17 ++ packages/tui/tests/typed-value.test.ts | 7 + 7 files changed, 240 insertions(+), 209 deletions(-) create mode 100644 packages/tui/src/typed-value/characters.ts create mode 100644 packages/tui/src/typed-value/edit.ts create mode 100644 packages/tui/src/typed-value/lines.ts create mode 100644 packages/tui/src/typed-value/render.ts create mode 100644 packages/tui/src/typed-value/types.ts diff --git a/packages/tui/src/typed-value.ts b/packages/tui/src/typed-value.ts index 9219d645..0fa2e8bb 100644 --- a/packages/tui/src/typed-value.ts +++ b/packages/tui/src/typed-value.ts @@ -1,212 +1,12 @@ import { promptEnvironment } from '#tui/environment'; -import { Key, oneOf } from '#tui/key'; import { PromptValidationError } from '#tui/prompt'; import { renderQuestion } from '#tui/theme'; +import { applyTypedKey, initialTypedValueState } from '#tui/typed-value/edit'; +import { renderTypedValue } from '#tui/typed-value/render'; +import type { TypedValueOptions, TypedValueState } from '#tui/typed-value/types'; -export type TypedValueState = { - cursor: number; - value: string; -}; - -type TypedValueOptions = { - allowNewLine?: boolean; - default?: string; - hint?: string; - placeholder?: string; - rows?: number; -}; - -const characters = (value: string): string[] => [...value]; -const fromCharacters = (value: string[]): string => value.join(''); -const characterLength = (value: string): number => characters(value).length; -const stringIndexToCharacterIndex = (value: string, index: number): number => characterLength(value.slice(0, index)); - -type LineRange = { - end: number; - start: number; -}; - -const isPrintable = (key: string): boolean => { - return [...key].every((character) => { - const code = character.codePointAt(0) ?? 0; - - return code >= 32 && code !== 127; - }); -}; - -const visibleLines = (value: string, cursor: number, rows: number | undefined): string => { - if (rows === undefined || rows <= 0) { - return value; - } - - const valueCharacters = characters(value); - const ranges = lineRanges(valueCharacters); - const line = currentLine(ranges, cursor); - const start = Math.max(0, line - rows + 1); - const end = start + rows; - - return ranges - .slice(start, end) - .map((range) => fromCharacters(valueCharacters.slice(range.start, range.end))) - .join('\n'); -}; - -const renderTypedValue = (message: string, state: TypedValueState, options: TypedValueOptions): void => { - const displayValue = state.value.length > 0 ? visibleLines(state.value, state.cursor, options.rows) : (options.placeholder ?? ''); - - promptEnvironment().output.write(`${renderQuestion(message, options.hint)}${displayValue}\n`); -}; - -const lineRanges = (value: string[]): LineRange[] => { - const ranges: LineRange[] = []; - - let start = 0; - - for (const [index, character] of value.entries()) { - if (character === '\n') { - ranges.push({ end: index, start }); - start = index + 1; - } - } - - ranges.push({ end: value.length, start }); - - return ranges; -}; - -const currentLine = (ranges: LineRange[], cursor: number): number => { - const index = ranges.findIndex((range) => cursor <= range.end); - - return index === -1 ? ranges.length - 1 : index; -}; - -const moveLine = (value: string[], cursor: number, direction: 1 | -1): number => { - const ranges = lineRanges(value); - 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; -}; - -const moveToLineBoundary = (value: string[], cursor: number, boundary: 'start' | 'end'): number => { - const ranges = lineRanges(value); - const range = ranges[currentLine(ranges, cursor)]; - - if (!range) { - return cursor; - } - - return boundary === 'start' ? range.start : range.end; -}; - -export const applyTypedKey = (state: TypedValueState, key: string, allowNewLine = false): TypedValueState & { submitted: boolean; cancelled: boolean } => { - const value = characters(state.value); - - let cursor = Math.max(0, Math.min(value.length, state.cursor)); - - if (key === Key.ctrlC) { - return { cursor, value: fromCharacters(value), submitted: false, cancelled: true }; - } - - if (key === Key.ctrlD && allowNewLine) { - return { cursor, value: fromCharacters(value), submitted: true, cancelled: false }; - } - - if (key === Key.enter) { - if (!allowNewLine) { - return { cursor, value: fromCharacters(value), submitted: true, cancelled: false }; - } - - value.splice(cursor, 0, '\n'); - cursor += 1; - - return { cursor, value: fromCharacters(value), submitted: false, cancelled: false }; - } - - if (key === Key.left || key === Key.leftArrow || key === Key.ctrlB) { - return { cursor: Math.max(0, cursor - 1), value: fromCharacters(value), submitted: false, cancelled: false }; - } - - if (key === Key.right || key === Key.rightArrow || key === Key.ctrlF) { - return { cursor: Math.min(value.length, cursor + 1), value: fromCharacters(value), submitted: false, cancelled: false }; - } - - if (allowNewLine && (key === Key.up || key === Key.upArrow)) { - return { cursor: moveLine(value, cursor, -1), value: fromCharacters(value), submitted: false, cancelled: false }; - } - - if (allowNewLine && (key === Key.down || key === Key.downArrow)) { - return { cursor: moveLine(value, cursor, 1), value: fromCharacters(value), submitted: false, cancelled: false }; - } - - if (oneOf([Key.home, Key.ctrlA], key)) { - const nextCursor = allowNewLine ? moveToLineBoundary(value, cursor, 'start') : 0; - - return { cursor: nextCursor, value: fromCharacters(value), submitted: false, cancelled: false }; - } - - if (oneOf([Key.end, Key.ctrlE], key)) { - const nextCursor = allowNewLine ? moveToLineBoundary(value, cursor, 'end') : value.length; - - return { cursor: nextCursor, value: fromCharacters(value), submitted: false, cancelled: false }; - } - - if (key === Key.delete) { - value.splice(cursor, 1); - - return { cursor, value: fromCharacters(value), submitted: false, cancelled: false }; - } - - if (key === Key.ctrlU) { - const start = allowNewLine ? moveToLineBoundary(value, cursor, 'start') : 0; - - value.splice(start, cursor - start); - - return { cursor: start, value: fromCharacters(value), submitted: false, cancelled: false }; - } - - if (key === Key.backspace || key === Key.ctrlH) { - if (cursor === 0) { - return { cursor, value: fromCharacters(value), submitted: false, cancelled: false }; - } - - value.splice(cursor - 1, 1); - - return { cursor: cursor - 1, value: fromCharacters(value), submitted: false, cancelled: false }; - } - - if (key === Key.optionBackspace) { - const before = fromCharacters(value.slice(0, cursor)); - const match = before.match(/(?:[\p{L}\p{M}\p{N}]+|[^\p{L}\p{M}\p{N}\s]+)\s*$/u); - const start = match?.index === undefined ? 0 : stringIndexToCharacterIndex(before, match.index); - - value.splice(start, cursor - start); - - return { cursor: start, value: fromCharacters(value), submitted: false, cancelled: false }; - } - - if (isPrintable(key)) { - for (const character of characters(key)) { - value.splice(cursor, 0, character); - cursor += 1; - } - } - - return { cursor, value: fromCharacters(value), submitted: false, cancelled: false }; -}; +export { applyTypedKey }; +export type { TypedValueOptions, TypedValueState }; export const readTypedValue = async (message: string, options: TypedValueOptions = {}): Promise => { const environment = promptEnvironment(); @@ -221,10 +21,7 @@ export const readTypedValue = async (message: string, options: TypedValueOptions return answer === '' && options.default !== undefined ? options.default : answer; } - let state: TypedValueState = { - cursor: characterLength(options.default ?? ''), - value: options.default ?? '', - }; + let state: TypedValueState = initialTypedValueState(options.default ?? ''); renderTypedValue(message, state, options); diff --git a/packages/tui/src/typed-value/characters.ts b/packages/tui/src/typed-value/characters.ts new file mode 100644 index 00000000..0f7e583a --- /dev/null +++ b/packages/tui/src/typed-value/characters.ts @@ -0,0 +1,15 @@ +export const characters = (value: string): string[] => [...value]; + +export const fromCharacters = (value: string[]): string => value.join(''); + +export const characterLength = (value: string): number => characters(value).length; + +export const stringIndexToCharacterIndex = (value: string, index: number): number => characterLength(value.slice(0, index)); + +export const isPrintable = (key: string): boolean => { + return [...key].every((character) => { + const code = character.codePointAt(0) ?? 0; + + return code >= 32 && code !== 127; + }); +}; diff --git a/packages/tui/src/typed-value/edit.ts b/packages/tui/src/typed-value/edit.ts new file mode 100644 index 00000000..6d9f384a --- /dev/null +++ b/packages/tui/src/typed-value/edit.ts @@ -0,0 +1,107 @@ +import { Key, oneOf } from '#tui/key'; +import { characterLength, characters, fromCharacters, isPrintable, stringIndexToCharacterIndex } from '#tui/typed-value/characters'; +import { moveLine, moveToLineBoundary } from '#tui/typed-value/lines'; +import type { AppliedTypedKey, TypedValueState } from '#tui/typed-value/types'; + +export const applyTypedKey = (state: TypedValueState, key: string, allowNewLine = false): AppliedTypedKey => { + const value = characters(state.value); + + let cursor = Math.max(0, Math.min(value.length, state.cursor)); + + if (key === Key.ctrlC) { + return { cursor, value: fromCharacters(value), submitted: false, cancelled: true }; + } + + if (key === Key.ctrlD && allowNewLine) { + return { cursor, value: fromCharacters(value), submitted: true, cancelled: false }; + } + + if (key === Key.enter) { + if (!allowNewLine) { + return { cursor, value: fromCharacters(value), submitted: true, cancelled: false }; + } + + value.splice(cursor, 0, '\n'); + cursor += 1; + + return { cursor, value: fromCharacters(value), submitted: false, cancelled: false }; + } + + if (key === Key.left || key === Key.leftArrow || key === Key.ctrlB) { + return { cursor: Math.max(0, cursor - 1), value: fromCharacters(value), submitted: false, cancelled: false }; + } + + if (key === Key.right || key === Key.rightArrow || key === Key.ctrlF) { + return { cursor: Math.min(value.length, cursor + 1), value: fromCharacters(value), submitted: false, cancelled: false }; + } + + if (allowNewLine && (key === Key.up || key === Key.upArrow)) { + return { cursor: moveLine(value, cursor, -1), value: fromCharacters(value), submitted: false, cancelled: false }; + } + + if (allowNewLine && (key === Key.down || key === Key.downArrow)) { + return { cursor: moveLine(value, cursor, 1), value: fromCharacters(value), submitted: false, cancelled: false }; + } + + if (oneOf([Key.home, Key.ctrlA], key)) { + const nextCursor = allowNewLine ? moveToLineBoundary(value, cursor, 'start') : 0; + + return { cursor: nextCursor, value: fromCharacters(value), submitted: false, cancelled: false }; + } + + if (oneOf([Key.end, Key.ctrlE], key)) { + const nextCursor = allowNewLine ? moveToLineBoundary(value, cursor, 'end') : value.length; + + return { cursor: nextCursor, value: fromCharacters(value), submitted: false, cancelled: false }; + } + + if (key === Key.delete) { + value.splice(cursor, 1); + + return { cursor, value: fromCharacters(value), submitted: false, cancelled: false }; + } + + if (key === Key.ctrlU) { + const start = allowNewLine ? moveToLineBoundary(value, cursor, 'start') : 0; + + value.splice(start, cursor - start); + + return { cursor: start, value: fromCharacters(value), submitted: false, cancelled: false }; + } + + if (key === Key.backspace || key === Key.ctrlH) { + if (cursor === 0) { + return { cursor, value: fromCharacters(value), submitted: false, cancelled: false }; + } + + value.splice(cursor - 1, 1); + + return { cursor: cursor - 1, value: fromCharacters(value), submitted: false, cancelled: false }; + } + + if (key === Key.optionBackspace) { + const before = fromCharacters(value.slice(0, cursor)); + const match = before.match(/(?:[\p{L}\p{M}\p{N}]+|[^\p{L}\p{M}\p{N}\s]+)\s*$/u); + const start = match?.index === undefined ? 0 : stringIndexToCharacterIndex(before, match.index); + + value.splice(start, cursor - start); + + return { cursor: start, value: fromCharacters(value), submitted: false, cancelled: false }; + } + + if (isPrintable(key)) { + for (const character of characters(key)) { + value.splice(cursor, 0, character); + cursor += 1; + } + } + + return { cursor, value: fromCharacters(value), submitted: false, cancelled: false }; +}; + +export const initialTypedValueState = (value = ''): TypedValueState => { + return { + cursor: characterLength(value), + value, + }; +}; diff --git a/packages/tui/src/typed-value/lines.ts b/packages/tui/src/typed-value/lines.ts new file mode 100644 index 00000000..8f988da4 --- /dev/null +++ b/packages/tui/src/typed-value/lines.ts @@ -0,0 +1,78 @@ +import { fromCharacters } from '#tui/typed-value/characters'; + +type LineRange = { + end: number; + start: number; +}; + +const lineRanges = (value: string[]): LineRange[] => { + const ranges: LineRange[] = []; + + let start = 0; + + for (const [index, character] of value.entries()) { + if (character === '\n') { + ranges.push({ end: index, start }); + start = index + 1; + } + } + + ranges.push({ end: value.length, start }); + + return ranges; +}; + +const currentLine = (ranges: LineRange[], cursor: number): number => { + const index = ranges.findIndex((range) => cursor <= range.end); + + return index === -1 ? ranges.length - 1 : index; +}; + +export const visibleLines = (value: string, cursor: number, rows: number | undefined): string => { + if (rows === undefined || rows <= 0) { + return value; + } + + const valueCharacters = [...value]; + const ranges = lineRanges(valueCharacters); + const line = currentLine(ranges, cursor); + const start = Math.max(0, line - rows + 1); + const end = start + rows; + + return ranges + .slice(start, end) + .map((range) => fromCharacters(valueCharacters.slice(range.start, range.end))) + .join('\n'); +}; + +export const moveLine = (value: string[], cursor: number, direction: 1 | -1): number => { + const ranges = lineRanges(value); + 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'): number => { + const ranges = lineRanges(value); + 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/render.ts b/packages/tui/src/typed-value/render.ts new file mode 100644 index 00000000..81daac42 --- /dev/null +++ b/packages/tui/src/typed-value/render.ts @@ -0,0 +1,10 @@ +import { promptEnvironment } from '#tui/environment'; +import { renderQuestion } from '#tui/theme'; +import { visibleLines } from '#tui/typed-value/lines'; +import type { TypedValueOptions, TypedValueState } from '#tui/typed-value/types'; + +export const renderTypedValue = (message: string, state: TypedValueState, options: TypedValueOptions): void => { + const displayValue = state.value.length > 0 ? visibleLines(state.value, state.cursor, options.rows) : (options.placeholder ?? ''); + + promptEnvironment().output.write(`${renderQuestion(message, options.hint)}${displayValue}\n`); +}; diff --git a/packages/tui/src/typed-value/types.ts b/packages/tui/src/typed-value/types.ts new file mode 100644 index 00000000..7c0e2343 --- /dev/null +++ b/packages/tui/src/typed-value/types.ts @@ -0,0 +1,17 @@ +export type TypedValueState = { + cursor: number; + value: string; +}; + +export type TypedValueOptions = { + allowNewLine?: boolean; + default?: string; + hint?: string; + placeholder?: string; + rows?: number; +}; + +export type AppliedTypedKey = TypedValueState & { + cancelled: boolean; + submitted: boolean; +}; diff --git a/packages/tui/tests/typed-value.test.ts b/packages/tui/tests/typed-value.test.ts index cf0d6cd2..a5f0b0f1 100644 --- a/packages/tui/tests/typed-value.test.ts +++ b/packages/tui/tests/typed-value.test.ts @@ -71,6 +71,13 @@ describe('typed value editing', () => { }); }); + it('deletes the previous punctuation group as a word boundary', () => { + expect(applyMultilineState('hello --', [...'hello --'].length, [Key.optionBackspace])).toEqual({ + cursor: 6, + value: 'hello ', + }); + }); + it('moves to the start and end of a line', () => { expect(apply(['A', 'r', Key.home[0], 'J', Key.end[0], 'c', 'h', 'e', 'r'])).toBe('JArcher'); }); From fd07c0147cb80fe88089e7e3797819a1f113160a Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 19:27:33 +0800 Subject: [PATCH 128/675] Split search selection concerns --- packages/tui/src/prompts/search/line-mode.ts | 43 +++++++++++++++++++ packages/tui/src/prompts/search/navigation.ts | 42 ++++++++++++++++++ packages/tui/src/prompts/search/read-multi.ts | 39 +++++------------ .../tui/src/prompts/search/read-single.ts | 41 +++++------------- packages/tui/tests/search.test.ts | 29 +++++++++++++ 5 files changed, 135 insertions(+), 59 deletions(-) create mode 100644 packages/tui/src/prompts/search/line-mode.ts create mode 100644 packages/tui/src/prompts/search/navigation.ts diff --git a/packages/tui/src/prompts/search/line-mode.ts b/packages/tui/src/prompts/search/line-mode.ts new file mode 100644 index 00000000..34fe7001 --- /dev/null +++ b/packages/tui/src/prompts/search/line-mode.ts @@ -0,0 +1,43 @@ +import { findChoice } from '#tui/concerns/choices'; +import { PromptValidationError } from '#tui/prompt'; +import { resolveSearchChoices } from '#tui/prompts/search/choices'; +import type { Choice, MultiSearchPromptOptions, SearchPromptOptions } from '#tui/types'; + +export const resolveLineSearchChoice = async (options: SearchPromptOptions, query: string): Promise => { + const choices = await resolveSearchChoices(options.options, query); + + if (query === '' && options.default !== undefined) { + return 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; + } + + const parts = query + .split(',') + .map((part) => part.trim()) + .filter((part) => part.length > 0); + + 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/navigation.ts b/packages/tui/src/prompts/search/navigation.ts new file mode 100644 index 00000000..ba03cd48 --- /dev/null +++ b/packages/tui/src/prompts/search/navigation.ts @@ -0,0 +1,42 @@ +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 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/read-multi.ts b/packages/tui/src/prompts/search/read-multi.ts index d71df421..f39f7ce5 100644 --- a/packages/tui/src/prompts/search/read-multi.ts +++ b/packages/tui/src/prompts/search/read-multi.ts @@ -1,11 +1,11 @@ import { promptEnvironment } from '#tui/environment'; import { Key, oneOf } from '#tui/key'; -import { ask, PromptValidationError } from '#tui/prompt'; +import { ask } from '#tui/prompt'; import { applyTypedKey } from '#tui/typed-value'; -import { findChoice, firstEnabledIndex, nextEnabledIndex } from '#tui/concerns/choices'; -import { lastEnabledIndex, resolveSearchChoices } from '#tui/prompts/search/choices'; +import { resolveSearchChoices } from '#tui/prompts/search/choices'; +import { resolveLineMultiSearchChoices } from '#tui/prompts/search/line-mode'; +import { firstSearchHighlight, lastSearchHighlight, nextSearchHighlight, pageSearchHighlight } from '#tui/prompts/search/navigation'; import { renderSearchChoices } from '#tui/prompts/search/render'; -import { pageEnabledChoiceIndex } from '#tui/prompts/select/navigation'; import type { Choice, MultiSearchPromptOptions } from '#tui/types'; export const readMultiSearchChoices = async (options: MultiSearchPromptOptions): Promise => { @@ -14,24 +14,7 @@ export const readMultiSearchChoices = async (options: MultiSearchPromptOption if (!environment.input.readKey) { const query = (await ask(options.message, options.hint)).trim(); - const choices = await resolveSearchChoices(options.options, query); - - if (query === '' && options.default !== undefined) { - return options.default; - } - - const parts = query - .split(',') - .map((part) => part.trim()) - .filter((part) => part.length > 0); - - 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); + return resolveLineMultiSearchChoices(options, query); } let state = { cursor: 0, value: '' }; @@ -81,7 +64,7 @@ export const readMultiSearchChoices = async (options: MultiSearchPromptOption const currentChoices = displayedChoices(); - highlighted = currentChoices.length === 0 ? null : highlighted === null ? firstEnabledIndex(currentChoices) : nextEnabledIndex(currentChoices, highlighted, 1); + highlighted = nextSearchHighlight(currentChoices, highlighted, 1); render(); continue; } @@ -91,7 +74,7 @@ export const readMultiSearchChoices = async (options: MultiSearchPromptOption const currentChoices = displayedChoices(); - highlighted = currentChoices.length === 0 ? null : highlighted === null ? lastEnabledIndex(currentChoices) : nextEnabledIndex(currentChoices, highlighted, -1); + highlighted = nextSearchHighlight(currentChoices, highlighted, -1); render(); continue; } @@ -101,7 +84,7 @@ export const readMultiSearchChoices = async (options: MultiSearchPromptOption const currentChoices = displayedChoices(); - highlighted = currentChoices.length === 0 ? null : highlighted === null ? firstEnabledIndex(currentChoices) : pageEnabledChoiceIndex(currentChoices, highlighted, 1, options.scroll); + highlighted = pageSearchHighlight(currentChoices, highlighted, 1, options.scroll); render(); continue; } @@ -111,19 +94,19 @@ export const readMultiSearchChoices = async (options: MultiSearchPromptOption const currentChoices = displayedChoices(); - highlighted = currentChoices.length === 0 ? null : highlighted === null ? lastEnabledIndex(currentChoices) : pageEnabledChoiceIndex(currentChoices, highlighted, -1, options.scroll); + highlighted = pageSearchHighlight(currentChoices, highlighted, -1, options.scroll); render(); continue; } if (oneOf([Key.home], key) && highlighted !== null) { - highlighted = firstEnabledIndex(displayedChoices()); + highlighted = firstSearchHighlight(displayedChoices()); render(); continue; } if (oneOf([Key.end], key) && highlighted !== null) { - highlighted = lastEnabledIndex(displayedChoices()); + highlighted = lastSearchHighlight(displayedChoices()); render(); continue; } diff --git a/packages/tui/src/prompts/search/read-single.ts b/packages/tui/src/prompts/search/read-single.ts index 04116874..ddf35242 100644 --- a/packages/tui/src/prompts/search/read-single.ts +++ b/packages/tui/src/prompts/search/read-single.ts @@ -2,10 +2,10 @@ import { promptEnvironment } from '#tui/environment'; import { Key, oneOf } from '#tui/key'; import { ask } from '#tui/prompt'; import { applyTypedKey } from '#tui/typed-value'; -import { findChoice, firstEnabledIndex, nextEnabledIndex } from '#tui/concerns/choices'; -import { lastEnabledIndex, resolveSearchChoices } from '#tui/prompts/search/choices'; +import { resolveSearchChoices } from '#tui/prompts/search/choices'; +import { resolveLineSearchChoice } from '#tui/prompts/search/line-mode'; +import { firstSearchHighlight, lastSearchHighlight, nextRetriedSearchHighlight, nextSearchHighlight, pageSearchHighlight } from '#tui/prompts/search/navigation'; import { renderSearchChoices } from '#tui/prompts/search/render'; -import { pageEnabledChoiceIndex } from '#tui/prompts/select/navigation'; import type { SearchPromptOptions } from '#tui/types'; export const readSearchChoice = async (options: SearchPromptOptions, attempt = 0): Promise => { @@ -14,21 +14,7 @@ export const readSearchChoice = async (options: SearchPromptOptions, attem if (!environment.input.readKey) { const query = (await ask(options.message, options.hint)).trim(); - const choices = await resolveSearchChoices(options.options, query); - - if (query === '' && options.default !== undefined) { - return options.default; - } - - const matched = findChoice(choices, query); - - if (matched?.disabled) { - return undefined; - } - - const choice = matched ?? choices.find((candidate) => !candidate.disabled); - - return choice?.value; + return resolveLineSearchChoice(options, query); } let state = { cursor: 0, value: '' }; @@ -61,14 +47,7 @@ export const readSearchChoice = async (options: SearchPromptOptions, attem if (key === Key.down || key === Key.downArrow || key === Key.ctrlN || key === Key.tab) { choices = await resolveSearchChoices(options.options, state.value); - highlighted = - choices.length === 0 - ? null - : highlighted === null - ? attempt > 0 - ? nextEnabledIndex(choices, firstEnabledIndex(choices), 1) - : firstEnabledIndex(choices) - : nextEnabledIndex(choices, highlighted, 1); + highlighted = nextRetriedSearchHighlight(choices, highlighted, attempt); renderSearchChoices(options.message, state.value, choices, highlighted, new Set(), [], options.scroll, options.info); continue; } @@ -76,7 +55,7 @@ export const readSearchChoice = async (options: SearchPromptOptions, attem if (key === Key.up || key === Key.upArrow || key === Key.ctrlP || key === Key.shiftTab) { choices = await resolveSearchChoices(options.options, state.value); - highlighted = choices.length === 0 ? null : highlighted === null ? lastEnabledIndex(choices) : nextEnabledIndex(choices, highlighted, -1); + highlighted = nextSearchHighlight(choices, highlighted, -1); renderSearchChoices(options.message, state.value, choices, highlighted, new Set(), [], options.scroll, options.info); continue; } @@ -84,7 +63,7 @@ export const readSearchChoice = async (options: SearchPromptOptions, attem if (key === Key.pageDown) { choices = await resolveSearchChoices(options.options, state.value); - highlighted = choices.length === 0 ? null : highlighted === null ? firstEnabledIndex(choices) : pageEnabledChoiceIndex(choices, highlighted, 1, options.scroll); + highlighted = pageSearchHighlight(choices, highlighted, 1, options.scroll); renderSearchChoices(options.message, state.value, choices, highlighted, new Set(), [], options.scroll, options.info); continue; } @@ -92,19 +71,19 @@ export const readSearchChoice = async (options: SearchPromptOptions, attem if (key === Key.pageUp) { choices = await resolveSearchChoices(options.options, state.value); - highlighted = choices.length === 0 ? null : highlighted === null ? lastEnabledIndex(choices) : pageEnabledChoiceIndex(choices, highlighted, -1, options.scroll); + highlighted = pageSearchHighlight(choices, highlighted, -1, options.scroll); renderSearchChoices(options.message, state.value, choices, highlighted, new Set(), [], options.scroll, options.info); continue; } if (oneOf([Key.home, Key.ctrlA], key) && highlighted !== null) { - highlighted = firstEnabledIndex(choices); + highlighted = firstSearchHighlight(choices); renderSearchChoices(options.message, state.value, choices, highlighted, new Set(), [], options.scroll, options.info); continue; } if (oneOf([Key.end, Key.ctrlE], key) && highlighted !== null) { - highlighted = lastEnabledIndex(choices); + highlighted = lastSearchHighlight(choices); renderSearchChoices(options.message, state.value, choices, highlighted, new Set(), [], options.scroll, options.info); continue; } diff --git a/packages/tui/tests/search.test.ts b/packages/tui/tests/search.test.ts index f7473dbf..f6a33818 100644 --- a/packages/tui/tests/search.test.ts +++ b/packages/tui/tests/search.test.ts @@ -377,6 +377,35 @@ describe('multisearch prompt', () => { expect(result).toEqual([]); }); + it('rejects disabled line-mode multisearch choices and retries', async () => { + const output = createMemoryOutput(); + const answers = ['Red', 'Green']; + + const result = await withPromptEnvironment( + { + input: { + async readLine(): Promise { + return answers.shift() ?? ''; + }, + }, + output, + error: output, + interactive: true, + }, + () => + multisearch({ + message: 'Favorite colors?', + options: [ + { label: 'Red', value: 'red', disabled: true }, + { label: 'Green', value: 'green' }, + ], + }), + ); + + expect(result).toEqual(['green']); + expect(output.text()).toContain('Please select valid options.'); + }); + it('rejects required non-interactive multisearch prompts without defaults', async () => { const output = createMemoryOutput(); From 0c721286fd729e3b720ff4a518be8a7c5e38526e Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 19:29:42 +0800 Subject: [PATCH 129/675] Split multi choice selection concerns --- packages/tui/src/prompts/select/multiple.ts | 51 +++++++++++++++++++ .../tui/src/prompts/select/read-multiple.ts | 47 ++++------------- packages/tui/tests/choices.test.ts | 26 ++++++++++ 3 files changed, 86 insertions(+), 38 deletions(-) create mode 100644 packages/tui/src/prompts/select/multiple.ts diff --git a/packages/tui/src/prompts/select/multiple.ts b/packages/tui/src/prompts/select/multiple.ts new file mode 100644 index 00000000..105260c2 --- /dev/null +++ b/packages/tui/src/prompts/select/multiple.ts @@ -0,0 +1,51 @@ +import { findChoice } from '#tui/concerns/choices'; +import { PromptValidationError } from '#tui/prompt'; +import type { Choice } from '#tui/types'; + +export const choicesFromCommaSeparated = (choices: Array>, answer: string): T[] => { + const parts = answer + .split(',') + .map((part) => part.trim()) + .filter((part) => part.length > 0); + + const selected = parts.map((part) => findChoice(choices, part)).filter((choice): choice is Choice => choice !== undefined && !choice.disabled); + + if (selected.length !== parts.length) { + throw new PromptValidationError('Please select valid options.'); + } + + return selected.map((choice) => choice.value); +}; + +export const markedChoiceIndexes = (choices: Array>, defaults: T[] = []): Set => { + const selectedValues = new Set(defaults); + + return new Set(choices.flatMap((choice, index) => (selectedValues.has(choice.value) ? [index] : []))); +}; + +export const markedChoiceValues = (choices: Array>, marked: Set): T[] => { + return [...marked].map((index) => choices[index]?.value).filter((value): value is T => value !== undefined); +}; + +export const toggleMarkedChoice = (choices: Array>, marked: Set, index: number): Set => { + const next = new Set(marked); + + if (next.has(index)) { + next.delete(index); + } else if (!choices[index]?.disabled) { + next.add(index); + } + + return next; +}; + +export const toggleAllEnabledChoices = (choices: Array>, marked: Set): Set => { + const enabledIndexes = choices.flatMap((choice, index) => (choice.disabled ? [] : [index])); + const allEnabledMarked = enabledIndexes.every((index) => marked.has(index)); + + if (allEnabledMarked) { + return new Set(); + } + + return new Set(enabledIndexes); +}; diff --git a/packages/tui/src/prompts/select/read-multiple.ts b/packages/tui/src/prompts/select/read-multiple.ts index cec19550..12597e7d 100644 --- a/packages/tui/src/prompts/select/read-multiple.ts +++ b/packages/tui/src/prompts/select/read-multiple.ts @@ -1,27 +1,13 @@ import { promptEnvironment } from '#tui/environment'; import { Key, oneOf } from '#tui/key'; -import { ask, PromptValidationError } from '#tui/prompt'; +import { ask } from '#tui/prompt'; import { renderChoices } from '#tui/theme'; -import { findChoice, firstEnabledIndex, nextEnabledIndex } from '#tui/concerns/choices'; +import { firstEnabledIndex, nextEnabledIndex } from '#tui/concerns/choices'; +import { choicesFromCommaSeparated, markedChoiceIndexes, markedChoiceValues, toggleAllEnabledChoices, toggleMarkedChoice } from '#tui/prompts/select/multiple'; import { lastEnabledChoiceIndex, nextChoiceKeys, pageEnabledChoiceIndex, parseChoiceIndex, previousChoiceKeys } from '#tui/prompts/select/navigation'; import { renderMultipleChoices } from '#tui/prompts/select/render'; import type { Choice, MultiSelectPromptOptions } from '#tui/types'; -const choicesFromCommaSeparated = (choices: Array>, answer: string): T[] => { - const parts = answer - .split(',') - .map((part) => part.trim()) - .filter((part) => part.length > 0); - - const selected = parts.map((part) => findChoice(choices, part)).filter((choice): choice is Choice => choice !== undefined && !choice.disabled); - - if (selected.length !== parts.length) { - throw new PromptValidationError('Please select valid options.'); - } - - return selected.map((choice) => choice.value); -}; - export const readMultipleChoices = async ( message: string, choices: Array>, @@ -31,7 +17,6 @@ export const readMultipleChoices = async ( info?: MultiSelectPromptOptions['info'], ): Promise => { const environment = promptEnvironment(); - const selectedValues = new Set(defaults); if (!environment.input.readKey) { const rendered = renderChoices(choices); @@ -43,7 +28,7 @@ export const readMultipleChoices = async ( let selected = firstEnabledIndex(choices); - const marked = new Set(choices.flatMap((choice, index) => (selectedValues.has(choice.value) ? [index] : []))); + let marked = markedChoiceIndexes(choices, defaults); renderMultipleChoices(message, choices, selected, marked, scroll, info); @@ -51,13 +36,13 @@ export const readMultipleChoices = async ( const key = await environment.input.readKey(); if (key === null) { - return [...marked].map((index) => choices[index]?.value).filter((value): value is T => value !== undefined); + return markedChoiceValues(choices, marked); } if (key === Key.ctrlC) { environment.error.write('Cancelled.\n'); - return [...marked].map((index) => choices[index]?.value).filter((value): value is T => value !== undefined); + return markedChoiceValues(choices, marked); } if (key.includes(',')) { @@ -116,35 +101,21 @@ export const readMultipleChoices = async ( } if (key === Key.ctrlA) { - if (marked.size === choices.filter((choice) => !choice.disabled).length) { - marked.clear(); - } else { - marked.clear(); - - for (const [index, choice] of choices.entries()) { - if (!choice.disabled) { - marked.add(index); - } - } - } + marked = toggleAllEnabledChoices(choices, marked); renderMultipleChoices(message, choices, selected, marked, scroll, info); continue; } if (key === Key.space) { - if (marked.has(selected)) { - marked.delete(selected); - } else if (!choices[selected]?.disabled) { - marked.add(selected); - } + marked = toggleMarkedChoice(choices, marked, selected); renderMultipleChoices(message, choices, selected, marked, scroll, info); continue; } if (key === Key.enter) { - return [...marked].map((index) => choices[index]?.value).filter((value): value is T => value !== undefined); + return markedChoiceValues(choices, marked); } } }; diff --git a/packages/tui/tests/choices.test.ts b/packages/tui/tests/choices.test.ts index 8dcc66cb..849dc388 100644 --- a/packages/tui/tests/choices.test.ts +++ b/packages/tui/tests/choices.test.ts @@ -500,6 +500,32 @@ describe('choice prompts', () => { expect(output.text()).toContain('Selected: first, second, third'); }); + it('toggles only enabled multiselect choices with ctrl-a', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.ctrlA, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + multiselect({ + message: 'Pick many', + options: [ + { label: 'first', value: 'first' }, + { label: 'second', value: 'second', disabled: true }, + { label: 'third', value: 'third' }, + ], + }), + ); + + expect(result).toEqual(['first', 'third']); + expect(output.text()).toContain('Selected: first, third'); + expect(output.text()).not.toContain('Selected: first, second, third'); + }); + it('renders multiselect info for the highlighted option', async () => { const output = createMemoryOutput(); From d61e2fe830e12de5f52b3ab0cdd7f3b824c22e08 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 19:32:53 +0800 Subject: [PATCH 130/675] Split form prompt builder concerns --- packages/tui/src/form/builder/prompts.ts | 167 +----------------- .../tui/src/form/builder/prompts/basic.ts | 19 ++ .../tui/src/form/builder/prompts/choices.ts | 42 +++++ .../tui/src/form/builder/prompts/types.ts | 114 ++++++++++++ packages/tui/tests/form.test.ts | 17 ++ 5 files changed, 198 insertions(+), 161 deletions(-) create mode 100644 packages/tui/src/form/builder/prompts/basic.ts create mode 100644 packages/tui/src/form/builder/prompts/choices.ts create mode 100644 packages/tui/src/form/builder/prompts/types.ts diff --git a/packages/tui/src/form/builder/prompts.ts b/packages/tui/src/form/builder/prompts.ts index d4505479..31c017af 100644 --- a/packages/tui/src/form/builder/prompts.ts +++ b/packages/tui/src/form/builder/prompts.ts @@ -1,166 +1,11 @@ -import { autocomplete, confirm, multiselect, multisearch, pause, search, select, suggest } from '#tui/prompts/choices'; -import { number, password, text, textarea } from '#tui/prompts/basic'; -import { previousArray, previousNumber, previousString } from '#tui/form/builder/previous'; +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 { ChoiceOptions, MaybePromise, MultiSearchPromptOptions, SearchPromptOptions, TextPromptOptions } from '#tui/types'; -import type { SuggestOptions } from '#tui/prompts/choices'; +import type { PromptBuilderMethods } from '#tui/form/builder/prompts/types'; -export type PromptBuilderMethods = { - autocomplete( - this: FormBuilder, - label: string, - options: SuggestOptions['options'], - defaultValue?: string, - required?: boolean | string, - validate?: TextPromptOptions['validate'], - hint?: string, - name?: string, - transform?: TextPromptOptions['transform'], - ): 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; - multisearch(this: FormBuilder, options: MultiSearchPromptOptions, 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, - ): 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, - ): FormBuilder; - password( - this: FormBuilder, - label: string, - placeholder?: string, - required?: boolean | string, - validate?: TextPromptOptions['validate'], - hint?: string, - name?: string, - transform?: TextPromptOptions['transform'], - ): FormBuilder; - pause(this: FormBuilder, message?: string, name?: string): FormBuilder; - search(this: FormBuilder, options: SearchPromptOptions, 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, - ): FormBuilder; - suggest( - this: FormBuilder, - label: string, - options: SuggestOptions['options'], - defaultValue?: string, - scroll?: number, - required?: boolean | string, - validate?: TextPromptOptions['validate'], - hint?: string, - name?: string, - transform?: TextPromptOptions['transform'], - ): 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, - label: string, - placeholder?: string, - defaultValue?: string, - required?: boolean | string, - validate?: TextPromptOptions['validate'], - hint?: string, - rows?: number, - name?: string, - transform?: TextPromptOptions['transform'], - ): FormBuilder; -}; +export type { PromptBuilderMethods }; export const promptBuilderMethods: PromptBuilderMethods & ThisType = { - autocomplete(label, options, defaultValue = '', required = false, validate = undefined, hint = '', name, transform = undefined) { - return this.add((_, previous) => autocomplete({ message: label, label, options, default: previousString(previous, defaultValue), required, validate, hint, transform }), name); - }, - confirm(label, defaultValue = true, yes = 'Yes', no = 'No', required = false, validate = undefined, hint = '', name, transform = undefined) { - return this.add(() => confirm(label, defaultValue, yes, no, required, validate, hint, transform), name); - }, - multisearch(options: MultiSearchPromptOptions, name?: string) { - return this.add(() => multisearch(options), name); - }, - multiselect( - label: string, - options: ChoiceOptions, - defaultValue: T[] = [], - scroll = 5, - required: boolean | string = false, - validate = undefined, - hint = 'Use the space bar to select options.', - name?: string, - transform = undefined, - ) { - return this.add((_, previous) => multiselect({ message: label, options, default: previousArray(previous, defaultValue), scroll, required, validate, hint, transform }), name); - }, - number(label, placeholder = '', defaultValue = 0, required = false, validate = undefined, hint = '', min, max, step, name) { - return this.add((_, previous) => number(label, placeholder, previousNumber(previous, defaultValue), required, validate, hint, min, max, step), name); - }, - password(label, placeholder = '', required = false, validate = undefined, hint = '', name, transform = undefined) { - return this.add(() => password(label, placeholder, required, validate, hint, transform), name); - }, - pause(message = 'Press enter to continue...', name?: string) { - return this.add(() => pause(message), name); - }, - search(options: SearchPromptOptions, name?: string) { - return this.add(() => search(options), name); - }, - select(label: string, options: ChoiceOptions, defaultValue?: T, scroll = 5, validate = undefined, hint = '', required: boolean | string = true, name?: string, transform = undefined) { - return this.add((_, previous) => select({ message: label, options, default: previous === undefined ? defaultValue : (previous as T), scroll, validate, hint, required, transform }), name); - }, - suggest(label, options, defaultValue = '', scroll = 5, required = false, validate = undefined, hint = '', name, transform = undefined) { - return this.add((_, previous) => suggest({ message: label, label, options, default: previousString(previous, defaultValue), scroll, required, validate, hint, transform }), name); - }, - text(label, placeholder = '', defaultValue = '', required = false, validate = undefined, hint = '', name, transform = undefined) { - return this.add((_, previous) => text(label, placeholder, previousString(previous, defaultValue), required, validate, hint, transform), name); - }, - textarea(label, placeholder = '', defaultValue = '', required = false, validate = undefined, hint = '', rows = 5, name, transform = undefined) { - return this.add((_, previous) => textarea(label, placeholder, previousString(previous, defaultValue), required, validate, hint, rows, transform), name); - }, + ...basicPromptBuilderMethods, + ...choicePromptBuilderMethods, }; diff --git a/packages/tui/src/form/builder/prompts/basic.ts b/packages/tui/src/form/builder/prompts/basic.ts new file mode 100644 index 00000000..690b8031 --- /dev/null +++ b/packages/tui/src/form/builder/prompts/basic.ts @@ -0,0 +1,19 @@ +import { number, password, text, textarea } from '#tui/prompts/basic'; +import { previousNumber, previousString } from '#tui/form/builder/previous'; +import type { FormBuilder } from '#tui/form/builder/index'; +import type { PromptBuilderMethods } from '#tui/form/builder/prompts/types'; + +export const basicPromptBuilderMethods: Pick & ThisType = { + number(label, placeholder = '', defaultValue = 0, required = false, validate = undefined, hint = '', min, max, step, name) { + return this.add((_, previous) => number(label, placeholder, previousNumber(previous, defaultValue), required, validate, hint, min, max, step), name); + }, + password(label, placeholder = '', required = false, validate = undefined, hint = '', name, transform = undefined) { + return this.add(() => password(label, placeholder, required, validate, hint, transform), name); + }, + text(label, placeholder = '', defaultValue = '', required = false, validate = undefined, hint = '', name, transform = undefined) { + return this.add((_, previous) => text(label, placeholder, previousString(previous, defaultValue), required, validate, hint, transform), name); + }, + textarea(label, placeholder = '', defaultValue = '', required = false, validate = undefined, hint = '', rows = 5, name, transform = undefined) { + return this.add((_, previous) => textarea(label, placeholder, previousString(previous, defaultValue), required, validate, hint, rows, transform), name); + }, +}; diff --git a/packages/tui/src/form/builder/prompts/choices.ts b/packages/tui/src/form/builder/prompts/choices.ts new file mode 100644 index 00000000..633ec1b6 --- /dev/null +++ b/packages/tui/src/form/builder/prompts/choices.ts @@ -0,0 +1,42 @@ +import { autocomplete, confirm, multiselect, multisearch, pause, search, select, suggest } from '#tui/prompts/choices'; +import { previousArray, previousString } from '#tui/form/builder/previous'; +import type { FormBuilder } from '#tui/form/builder/index'; +import type { PromptBuilderMethods } from '#tui/form/builder/prompts/types'; +import type { ChoiceOptions, MultiSearchPromptOptions, SearchPromptOptions } from '#tui/types'; + +export const choicePromptBuilderMethods: Omit & ThisType = { + autocomplete(label, options, defaultValue = '', required = false, validate = undefined, hint = '', name, transform = undefined) { + return this.add((_, previous) => autocomplete({ message: label, label, options, default: previousString(previous, defaultValue), required, validate, hint, transform }), name); + }, + confirm(label, defaultValue = true, yes = 'Yes', no = 'No', required = false, validate = undefined, hint = '', name, transform = undefined) { + return this.add(() => confirm(label, defaultValue, yes, no, required, validate, hint, transform), name); + }, + multisearch(options: MultiSearchPromptOptions, name?: string) { + return this.add(() => multisearch(options), name); + }, + multiselect( + label: string, + options: ChoiceOptions, + defaultValue: T[] = [], + scroll = 5, + required: boolean | string = false, + validate = undefined, + hint = 'Use the space bar to select options.', + name?: string, + transform = undefined, + ) { + return this.add((_, previous) => multiselect({ message: label, options, default: previousArray(previous, defaultValue), scroll, required, validate, hint, transform }), name); + }, + pause(message = 'Press enter to continue...', name?: string) { + return this.add(() => pause(message), name); + }, + search(options: SearchPromptOptions, name?: string) { + return this.add(() => search(options), name); + }, + select(label: string, options: ChoiceOptions, defaultValue?: T, scroll = 5, validate = undefined, hint = '', required: boolean | string = true, name?: string, transform = undefined) { + return this.add((_, previous) => select({ message: label, options, default: previous === undefined ? defaultValue : (previous as T), scroll, validate, hint, required, transform }), name); + }, + suggest(label, options, defaultValue = '', scroll = 5, required = false, validate = undefined, hint = '', name, transform = undefined) { + return this.add((_, previous) => suggest({ message: label, label, options, default: previousString(previous, defaultValue), scroll, required, validate, hint, transform }), name); + }, +}; diff --git a/packages/tui/src/form/builder/prompts/types.ts b/packages/tui/src/form/builder/prompts/types.ts new file mode 100644 index 00000000..d9450366 --- /dev/null +++ b/packages/tui/src/form/builder/prompts/types.ts @@ -0,0 +1,114 @@ +import type { FormBuilder } from '#tui/form/builder/index'; +import type { SuggestOptions } from '#tui/prompts/choices'; +import type { ChoiceOptions, MaybePromise, MultiSearchPromptOptions, SearchPromptOptions, TextPromptOptions } from '#tui/types'; + +export type PromptBuilderMethods = { + autocomplete( + this: FormBuilder, + label: string, + options: SuggestOptions['options'], + defaultValue?: string, + required?: boolean | string, + validate?: TextPromptOptions['validate'], + hint?: string, + name?: string, + transform?: TextPromptOptions['transform'], + ): 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; + multisearch(this: FormBuilder, options: MultiSearchPromptOptions, 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, + ): 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, + ): FormBuilder; + password( + this: FormBuilder, + label: string, + placeholder?: string, + required?: boolean | string, + validate?: TextPromptOptions['validate'], + hint?: string, + name?: string, + transform?: TextPromptOptions['transform'], + ): FormBuilder; + pause(this: FormBuilder, message?: string, name?: string): FormBuilder; + search(this: FormBuilder, options: SearchPromptOptions, 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, + ): FormBuilder; + suggest( + this: FormBuilder, + label: string, + options: SuggestOptions['options'], + defaultValue?: string, + scroll?: number, + required?: boolean | string, + validate?: TextPromptOptions['validate'], + hint?: string, + name?: string, + transform?: TextPromptOptions['transform'], + ): 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, + 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/tests/form.test.ts b/packages/tui/tests/form.test.ts index 9a730afb..8ce532c9 100644 --- a/packages/tui/tests/form.test.ts +++ b/packages/tui/tests/form.test.ts @@ -175,6 +175,23 @@ describe('form builder', () => { expect(responses.many).toEqual(['red']); }); + it('runs password and textarea form steps', async () => { + const output = createMemoryOutput(); + + const responses = await withPromptEnvironment( + { + input: createScriptedInput(['s', 'e', 'c', 'r', 'e', 't', Key.enter, 'L', '1', Key.enter, 'L', '2', Key.ctrlD]), + output, + error: output, + interactive: true, + }, + () => form().password('Secret', '', false, undefined, '', 'secret').textarea('Body', '', '', false, undefined, '', 3, 'body').submit(), + ); + + expect(responses.secret).toBe('secret'); + expect(responses.body).toBe('L1\nL2'); + }); + it('runs task, pause, and stream form steps', async () => { const output = createMemoryOutput(); From d33c4470053352862f17e627d8ffdc9db8ee8066 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 19:35:01 +0800 Subject: [PATCH 131/675] Split data table render navigation --- packages/tui/src/output/data-table.ts | 42 ++++++++----------- .../tui/src/output/data-table/navigation.ts | 21 ++++++++++ packages/tui/src/output/data-table/render.ts | 34 +++++++++++++++ packages/tui/tests/table.test.ts | 21 ++++++++++ 4 files changed, 93 insertions(+), 25 deletions(-) create mode 100644 packages/tui/src/output/data-table/navigation.ts create mode 100644 packages/tui/src/output/data-table/render.ts diff --git a/packages/tui/src/output/data-table.ts b/packages/tui/src/output/data-table.ts index 2b70efb6..62dae1c0 100644 --- a/packages/tui/src/output/data-table.ts +++ b/packages/tui/src/output/data-table.ts @@ -1,11 +1,10 @@ import { promptEnvironment } from '#tui/environment'; import { Key, oneOf } from '#tui/key'; -import { parseScrollSize } from '#tui/concerns/validators/scroll'; import { promptUntilValid, PromptValidationError } from '#tui/prompt'; -import { renderTable } from '#tui/theme'; import { applyTypedKey } from '#tui/typed-value'; -import { dataTableRowCells, dataTableRowValue, deriveDataTableHeaders, visibleDataTableRows } from '#tui/output/data-table/rows'; -import { clampDataTableSelection, dataTableRowWindow } from '#tui/output/data-table/selection'; +import { firstDataTableSelection, lastDataTableSelection, nextDataTableSelection, pageDataTableSelection, previousDataTableSelection } from '#tui/output/data-table/navigation'; +import { dataTableRowValue, deriveDataTableHeaders, visibleDataTableRows } from '#tui/output/data-table/rows'; +import { renderDataTableFrame } from '#tui/output/data-table/render'; import type { DataTablePromptOptions } from '#tui/types'; export const datatable = async (options: DataTablePromptOptions): Promise => { @@ -21,22 +20,15 @@ export const datatable = async (options: DataTablePromptOptions) const visibleRows = () => visibleDataTableRows(options, headers, query.value); const render = (): void => { - const rows = visibleRows(); - - selected = clampDataTableSelection(selected, rows); - - const window = dataTableRowWindow(rows.length, selected, options.scroll); - - const renderedRows = rows.slice(window.start, window.end).map(({ row }, offset) => { - const index = window.start + offset; - - return [index === selected ? '›' : ' ', ...dataTableRowCells(headers, row)]; + selected = renderDataTableFrame({ + headers, + message: options.message, + mode, + query: query.value, + rows: visibleRows(), + scroll: options.scroll, + selected, }); - - const querySuffix = mode === 'search' || query.value.length > 0 ? ` ${query.value}` : ''; - - environment.output.write(`${options.message}${querySuffix}\n`); - environment.output.write(`${renderTable(['', ...headers], renderedRows)}\n`); }; if (!environment.input.readKey) { @@ -108,37 +100,37 @@ export const datatable = async (options: DataTablePromptOptions) } if (key === Key.down || key === Key.downArrow || key === Key.ctrlN || key === Key.tab) { - selected = rows.length === 0 ? 0 : (selected + 1) % rows.length; + selected = nextDataTableSelection(selected, rows.length); render(); continue; } if (key === Key.up || key === Key.upArrow || key === Key.ctrlP || key === Key.shiftTab) { - selected = rows.length === 0 ? 0 : (selected - 1 + rows.length) % rows.length; + selected = previousDataTableSelection(selected, rows.length); render(); continue; } if (key === Key.pageDown) { - selected = rows.length === 0 ? 0 : Math.min(rows.length - 1, selected + parseScrollSize(options.scroll, 10)); + selected = pageDataTableSelection(selected, rows.length, 1, options.scroll); render(); continue; } if (key === Key.pageUp) { - selected = Math.max(0, selected - parseScrollSize(options.scroll, 10)); + selected = pageDataTableSelection(selected, rows.length, -1, options.scroll); render(); continue; } if (oneOf([Key.home, Key.ctrlA], key)) { - selected = 0; + selected = firstDataTableSelection(); render(); continue; } if (oneOf([Key.end, Key.ctrlE], key)) { - selected = Math.max(0, rows.length - 1); + selected = lastDataTableSelection(rows.length); render(); continue; } diff --git a/packages/tui/src/output/data-table/navigation.ts b/packages/tui/src/output/data-table/navigation.ts new file mode 100644 index 00000000..16aae87c --- /dev/null +++ b/packages/tui/src/output/data-table/navigation.ts @@ -0,0 +1,21 @@ +import { parseScrollSize } from '#tui/concerns/validators/scroll'; + +export const nextDataTableSelection = (selected: number, total: number): number => { + return total === 0 ? 0 : (selected + 1) % total; +}; + +export const previousDataTableSelection = (selected: number, total: number): number => { + return total === 0 ? 0 : (selected - 1 + total) % total; +}; + +export const pageDataTableSelection = (selected: number, total: number, direction: 1 | -1, scroll?: number): number => { + if (total === 0) { + return 0; + } + + return Math.max(0, Math.min(total - 1, selected + parseScrollSize(scroll, 10) * direction)); +}; + +export const firstDataTableSelection = (): number => 0; + +export const lastDataTableSelection = (total: number): number => Math.max(0, total - 1); diff --git a/packages/tui/src/output/data-table/render.ts b/packages/tui/src/output/data-table/render.ts new file mode 100644 index 00000000..3914deda --- /dev/null +++ b/packages/tui/src/output/data-table/render.ts @@ -0,0 +1,34 @@ +import { promptEnvironment } from '#tui/environment'; +import { renderTable } from '#tui/theme'; +import { dataTableRowCells } from '#tui/output/data-table/rows'; +import { clampDataTableSelection, dataTableRowWindow } from '#tui/output/data-table/selection'; +import type { VisibleDataTableRow } from '#tui/output/data-table/types'; + +type RenderDataTableFrameOptions = { + headers: string[]; + message: string; + mode: 'browse' | 'search'; + query: string; + rows: Array>; + scroll?: number; + selected: number; +}; + +export const renderDataTableFrame = (options: RenderDataTableFrameOptions): number => { + const environment = promptEnvironment(); + const selected = clampDataTableSelection(options.selected, options.rows); + const window = dataTableRowWindow(options.rows.length, selected, options.scroll); + + const renderedRows = options.rows.slice(window.start, window.end).map(({ row }, offset) => { + const index = window.start + offset; + + return [index === selected ? '›' : ' ', ...dataTableRowCells(options.headers, row)]; + }); + + const querySuffix = options.mode === 'search' || options.query.length > 0 ? ` ${options.query}` : ''; + + environment.output.write(`${options.message}${querySuffix}\n`); + environment.output.write(`${renderTable(['', ...options.headers], renderedRows)}\n`); + + return selected; +}; diff --git a/packages/tui/tests/table.test.ts b/packages/tui/tests/table.test.ts index 5e6c80f1..7630d8b2 100644 --- a/packages/tui/tests/table.test.ts +++ b/packages/tui/tests/table.test.ts @@ -446,6 +446,27 @@ describe('data table prompt', () => { expect(result).toBe(1); }); + it('wraps data table navigation from the first row to the last row', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.up, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + datatable({ + message: 'Pick row', + headers: ['Name'], + rows: [['First'], ['Second'], ['Third']], + }), + ); + + expect(result).toBe(2); + }); + it('normalizes fractional scroll sizes for data table page navigation', async () => { const output = createMemoryOutput(); From fc1e11657269fafb62752fc0469a3a5b3ec8dd2e Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 19:37:11 +0800 Subject: [PATCH 132/675] Split suggest navigation concerns --- .../tui/src/prompts/suggest/navigation.ts | 35 +++++++++++++++++++ .../src/prompts/suggest/read-autocomplete.ts | 8 ++--- packages/tui/src/prompts/suggest/read.ts | 19 +++++----- packages/tui/tests/suggest.test.ts | 4 +++ 4 files changed, 52 insertions(+), 14 deletions(-) create mode 100644 packages/tui/src/prompts/suggest/navigation.ts diff --git a/packages/tui/src/prompts/suggest/navigation.ts b/packages/tui/src/prompts/suggest/navigation.ts new file mode 100644 index 00000000..0394f6a4 --- /dev/null +++ b/packages/tui/src/prompts/suggest/navigation.ts @@ -0,0 +1,35 @@ +import { pageIndex } from '#tui/prompts/select/navigation'; + +export const nextSuggestionHighlight = (matches: string[], highlighted: number | null, direction: 1 | -1): number | null => { + if (matches.length === 0) { + return null; + } + + const current = highlighted ?? (direction === 1 ? -1 : matches.length); + + return (current + direction + matches.length) % matches.length; +}; + +export const pageSuggestionHighlight = (matches: string[], highlighted: number | null, direction: 1 | -1, scroll?: number): number | null => { + if (matches.length === 0) { + return null; + } + + if (highlighted === null) { + return direction === 1 ? 0 : Math.max(0, matches.length - 1); + } + + return pageIndex(matches.length, highlighted, direction, scroll); +}; + +export const firstSuggestionHighlight = (matches: string[]): number | null => { + return matches.length === 0 ? null : 0; +}; + +export const lastSuggestionHighlight = (matches: string[]): number | null => { + return matches.length === 0 ? null : matches.length - 1; +}; + +export const nextAutocompleteHighlight = (matches: string[], highlighted: number, direction: 1 | -1): number => { + return matches.length === 0 ? 0 : (highlighted + direction + matches.length) % matches.length; +}; diff --git a/packages/tui/src/prompts/suggest/read-autocomplete.ts b/packages/tui/src/prompts/suggest/read-autocomplete.ts index 125b9526..28ed5db6 100644 --- a/packages/tui/src/prompts/suggest/read-autocomplete.ts +++ b/packages/tui/src/prompts/suggest/read-autocomplete.ts @@ -4,10 +4,10 @@ import { ask } from '#tui/prompt'; import { applyTypedKey } from '#tui/typed-value'; import { renderAutocomplete } from '#tui/prompts/suggest/render-autocomplete'; import { resolveSuggestions } from '#tui/prompts/suggest/resolve'; +import { characterLength } from '#tui/typed-value/characters'; +import { nextAutocompleteHighlight } from '#tui/prompts/suggest/navigation'; import type { SuggestOptions } from '#tui/prompts/suggest/options'; -const characterLength = (value: string): number => [...value].length; - export const readAutocompleteValue = async (options: SuggestOptions): Promise => { const environment = promptEnvironment(); @@ -35,7 +35,7 @@ export const readAutocompleteValue = async (options: SuggestOptions): Promise [...value].length; - export const readSuggestionValue = async (options: SuggestOptions): Promise => { const environment = promptEnvironment(); @@ -36,7 +35,7 @@ export const readSuggestionValue = async (options: SuggestOptions): Promise { await expectSuggestion(['b', Key.down, Key.pageDown, Key.pageUp, Key.pageDown, Key.enter], ['Red', 'Blue', 'Black', 'Blurple'], 'Blurple'); }); + it('supports home and end suggestion navigation keys', async () => { + await expectSuggestion(['b', Key.down, Key.end[0], Key.home[0], Key.enter], ['Red', 'Blue', 'Black', 'Blurple'], 'Blue'); + }); + it('supports callback options', async () => { await expectSuggestion(['e', 'e', Key.down, Key.enter], (value) => ['Red', 'Green', 'Blue'].filter((option) => option.toLowerCase().includes(value.toLowerCase())), 'Green'); }); From d6c858eb8fe0c337fa8cbe39a98fe8928c71758d Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 19:39:01 +0800 Subject: [PATCH 133/675] Split progress rendering concerns --- packages/tui/src/status/progress.ts | 11 ++++------- packages/tui/src/status/progress/render.ts | 19 +++++++++++++++++++ packages/tui/src/status/progress/steps.ts | 5 +++++ packages/tui/tests/progress.test.ts | 12 ++++++++++++ 4 files changed, 40 insertions(+), 7 deletions(-) create mode 100644 packages/tui/src/status/progress/render.ts create mode 100644 packages/tui/src/status/progress/steps.ts diff --git a/packages/tui/src/status/progress.ts b/packages/tui/src/status/progress.ts index 6696e1e3..417aab02 100644 --- a/packages/tui/src/status/progress.ts +++ b/packages/tui/src/status/progress.ts @@ -1,5 +1,7 @@ import { promptEnvironment } from '#tui/environment'; import { parseProgressStep, parseProgressTotal } from '#tui/status/validators/progress'; +import { renderProgressFrame } from '#tui/status/progress/render'; +import { progressValues } from '#tui/status/progress/steps'; import type { MaybePromise } from '#tui/types'; export class Progress { @@ -53,12 +55,7 @@ export class Progress { } render(): void { - const width = 20; - const filled = Math.ceil(width * this.percentage()); - const bar = `${'█'.repeat(filled)}${' '.repeat(width - filled)}`; - const hint = this.#hint ? ` ${this.#hint}` : ''; - - promptEnvironment().output.write(`${this.#label}: ${bar} ${this.#current} / ${this.total}${hint}\n`); + promptEnvironment().output.write(renderProgressFrame({ current: this.#current, hint: this.#hint, label: this.#label, total: this.total })); } } @@ -82,7 +79,7 @@ export function progress( throw new Error('Progress steps must be an iterable or a number.'); } - const values = typeof steps === 'number' ? Array.from({ length: parseProgressTotal(steps) }, (_, index) => index) : Array.from(steps); + const values = progressValues(steps); const bar = new Progress(values.length, labelOrTotal, hint); if (!callback) { diff --git a/packages/tui/src/status/progress/render.ts b/packages/tui/src/status/progress/render.ts new file mode 100644 index 00000000..2c7a4150 --- /dev/null +++ b/packages/tui/src/status/progress/render.ts @@ -0,0 +1,19 @@ +export type ProgressFrameOptions = { + current: number; + hint: string; + label: string; + total: number; + width?: number; +}; + +export const progressPercentage = (current: number, total: number): number => { + return current / total; +}; + +export const renderProgressFrame = ({ current, hint, label, total, width = 20 }: ProgressFrameOptions): string => { + const filled = Math.ceil(width * progressPercentage(current, total)); + const bar = `${'█'.repeat(filled)}${' '.repeat(width - filled)}`; + const suffix = hint ? ` ${hint}` : ''; + + return `${label}: ${bar} ${current} / ${total}${suffix}\n`; +}; diff --git a/packages/tui/src/status/progress/steps.ts b/packages/tui/src/status/progress/steps.ts new file mode 100644 index 00000000..1c81562e --- /dev/null +++ b/packages/tui/src/status/progress/steps.ts @@ -0,0 +1,5 @@ +import { parseProgressTotal } from '#tui/status/validators/progress'; + +export const progressValues = (steps: Iterable | number): Array => { + return typeof steps === 'number' ? Array.from({ length: parseProgressTotal(steps) }, (_, index) => index) : Array.from(steps); +}; diff --git a/packages/tui/tests/progress.test.ts b/packages/tui/tests/progress.test.ts index 6a73a78c..d625c1a2 100644 --- a/packages/tui/tests/progress.test.ts +++ b/packages/tui/tests/progress.test.ts @@ -45,6 +45,18 @@ describe('progress helper', () => { expect(output.text()).toContain('1 / 100'); }); + it('renders a fully filled progress bar at completion', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + const bar = progress('Adding States', 2); + + bar.finish(); + }); + + expect(output.text()).toContain('████████████████████ 2 / 2'); + }); + it('returns a completion value separately from the current count', async () => { const output = createMemoryOutput(); From 5c442b708056a501b14f0d5d245f87c47e24b148 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 19:41:14 +0800 Subject: [PATCH 134/675] Split number input concerns --- packages/tui/src/prompts/number/input.ts | 44 +++-------------------- packages/tui/src/prompts/number/render.ts | 9 +++++ packages/tui/src/prompts/number/step.ts | 27 ++++++++++++++ packages/tui/src/prompts/number/types.ts | 8 +++++ packages/tui/tests/number.test.ts | 17 +++++++++ 5 files changed, 66 insertions(+), 39 deletions(-) create mode 100644 packages/tui/src/prompts/number/render.ts create mode 100644 packages/tui/src/prompts/number/step.ts create mode 100644 packages/tui/src/prompts/number/types.ts diff --git a/packages/tui/src/prompts/number/input.ts b/packages/tui/src/prompts/number/input.ts index 6dc1e546..cd729d5a 100644 --- a/packages/tui/src/prompts/number/input.ts +++ b/packages/tui/src/prompts/number/input.ts @@ -3,43 +3,9 @@ import { Key } from '#tui/key'; import { PromptValidationError } from '#tui/prompt'; import { renderQuestion } from '#tui/theme'; import { applyTypedKey } from '#tui/typed-value'; - -type NumberInputOptions = { - default?: number | string; - hint?: string; - max?: number; - min?: number; - placeholder?: string; - step?: number; -}; - -const numeric = (value: string): boolean => value.trim() !== '' && Number.isFinite(Number(value)); - -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); -}; - -const steppedValue = (value: string, direction: 1 | -1, options: NumberInputOptions): string => { - const step = options.step !== undefined && options.step > 0 ? Math.max(1, Math.trunc(options.step)) : 1; - - if (value === '') { - return String(direction === 1 ? (options.min ?? 1) : (options.max ?? 0)); - } - - if (!numeric(value)) { - return value; - } - - return String(clamp(Math.trunc(Number(value)) + step * direction, options.min, options.max)); -}; - -const renderNumberValue = (message: string, value: string, options: NumberInputOptions): void => { - const displayValue = value.length > 0 ? value : (options.placeholder ?? ''); - - promptEnvironment().output.write(`${renderQuestion(message, options.hint)}${displayValue}\n`); -}; +import { renderNumberValue } from '#tui/prompts/number/render'; +import { steppedNumberValue } from '#tui/prompts/number/step'; +import type { NumberInputOptions } from '#tui/prompts/number/types'; export const readNumberValue = async (message: string, options: NumberInputOptions = {}): Promise => { const environment = promptEnvironment(); @@ -69,14 +35,14 @@ export const readNumberValue = async (message: string, options: NumberInputOptio } if (key === Key.up || key === Key.upArrow) { - state.value = steppedValue(state.value, 1, options); + state.value = steppedNumberValue(state.value, 1, options); state.cursor = state.value.length; renderNumberValue(message, state.value, options); continue; } if (key === Key.down || key === Key.downArrow) { - state.value = steppedValue(state.value, -1, options); + state.value = steppedNumberValue(state.value, -1, options); state.cursor = state.value.length; renderNumberValue(message, state.value, options); continue; diff --git a/packages/tui/src/prompts/number/render.ts b/packages/tui/src/prompts/number/render.ts new file mode 100644 index 00000000..638d7b21 --- /dev/null +++ b/packages/tui/src/prompts/number/render.ts @@ -0,0 +1,9 @@ +import { promptEnvironment } from '#tui/environment'; +import { renderQuestion } from '#tui/theme'; +import type { NumberInputOptions } from '#tui/prompts/number/types'; + +export const renderNumberValue = (message: string, value: string, options: NumberInputOptions): void => { + const displayValue = value.length > 0 ? value : (options.placeholder ?? ''); + + promptEnvironment().output.write(`${renderQuestion(message, options.hint)}${displayValue}\n`); +}; diff --git a/packages/tui/src/prompts/number/step.ts b/packages/tui/src/prompts/number/step.ts new file mode 100644 index 00000000..84df98e6 --- /dev/null +++ b/packages/tui/src/prompts/number/step.ts @@ -0,0 +1,27 @@ +import type { NumberInputOptions } from '#tui/prompts/number/types'; + +const numeric = (value: string): boolean => value.trim() !== '' && Number.isFinite(Number(value)); + +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 step !== undefined && step > 0 ? Math.max(1, Math.trunc(step)) : 1; +}; + +export const steppedNumberValue = (value: string, direction: 1 | -1, options: NumberInputOptions): string => { + const step = numberStep(options.step); + + if (value === '') { + return String(direction === 1 ? (options.min ?? 1) : (options.max ?? 0)); + } + + if (!numeric(value)) { + return value; + } + + return String(clamp(Math.trunc(Number(value)) + step * direction, options.min, options.max)); +}; diff --git a/packages/tui/src/prompts/number/types.ts b/packages/tui/src/prompts/number/types.ts new file mode 100644 index 00000000..a9e2557d --- /dev/null +++ b/packages/tui/src/prompts/number/types.ts @@ -0,0 +1,8 @@ +export type NumberInputOptions = { + default?: number | string; + hint?: string; + max?: number; + min?: number; + placeholder?: string; + step?: number; +}; diff --git a/packages/tui/tests/number.test.ts b/packages/tui/tests/number.test.ts index 4327bc6d..4cb33c25 100644 --- a/packages/tui/tests/number.test.ts +++ b/packages/tui/tests/number.test.ts @@ -160,6 +160,23 @@ describe('number prompt', () => { expect(output.text()).toContain('? Count 1'); }); + it('uses the max value when decrementing from an empty number input', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.enter]), + output, + error: output, + interactive: true, + }, + () => number({ message: 'Count', max: 9 }), + ); + + expect(result).toBe(9); + expect(output.text()).toContain('? Count 9'); + }); + it('does not increment or decrement with control navigation keys', async () => { const output = createMemoryOutput(); From 64caa3b5e30c57ed73898ee1265920626435fe22 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 19:45:02 +0800 Subject: [PATCH 135/675] Split search selection state --- packages/tui/src/prompts/search/read-multi.ts | 51 +++------------ packages/tui/src/prompts/search/selection.ts | 62 +++++++++++++++++++ packages/tui/tests/search.test.ts | 23 +++++++ 3 files changed, 92 insertions(+), 44 deletions(-) create mode 100644 packages/tui/src/prompts/search/selection.ts diff --git a/packages/tui/src/prompts/search/read-multi.ts b/packages/tui/src/prompts/search/read-multi.ts index f39f7ce5..64a289bd 100644 --- a/packages/tui/src/prompts/search/read-multi.ts +++ b/packages/tui/src/prompts/search/read-multi.ts @@ -6,7 +6,8 @@ import { resolveSearchChoices } from '#tui/prompts/search/choices'; import { resolveLineMultiSearchChoices } from '#tui/prompts/search/line-mode'; import { firstSearchHighlight, lastSearchHighlight, nextSearchHighlight, pageSearchHighlight } from '#tui/prompts/search/navigation'; import { renderSearchChoices } from '#tui/prompts/search/render'; -import type { Choice, MultiSearchPromptOptions } from '#tui/types'; +import { createInitialSearchSelection, displayedSearchChoices, markedSearchChoiceIndexes, toggleSearchChoice, toggleSearchChoices } from '#tui/prompts/search/selection'; +import type { MultiSearchPromptOptions } from '#tui/types'; export const readMultiSearchChoices = async (options: MultiSearchPromptOptions): Promise => { const environment = promptEnvironment(); @@ -23,23 +24,12 @@ export const readMultiSearchChoices = async (options: MultiSearchPromptOption let highlighted: number | null = null; - const selected = initialSelectedValues(choices, options.default); - - const displayedChoices = (): Array> => { - if (state.value.trim() !== '') { - return choices; - } - - const selectedChoices = [...selected.entries()].filter(([value]) => !choices.some((choice) => Object.is(choice.value, value))).map(([value, label]) => ({ label, value })); - - return [...selectedChoices, ...choices]; - }; - - const selectableChoices = (): Array> => displayedChoices().filter((choice) => !choice.disabled); + const selected = createInitialSearchSelection(choices, options.default); + const displayedChoices = () => displayedSearchChoices(choices, selected, state.value); const render = (): void => { const currentChoices = displayedChoices(); - const marked = new Set(currentChoices.flatMap((choice, index) => (selected.has(choice.value) ? [index] : []))); + const marked = markedSearchChoiceIndexes(currentChoices, selected); renderSearchChoices(options.message, state.value, currentChoices, highlighted, marked, [...selected.values()], options.scroll, options.info, true); }; @@ -116,18 +106,7 @@ export const readMultiSearchChoices = async (options: MultiSearchPromptOption } if (key === Key.ctrlA && highlighted !== null) { - const currentChoices = selectableChoices(); - const allCurrentChoicesSelected = currentChoices.every((choice) => selected.has(choice.value)); - - if (allCurrentChoicesSelected) { - for (const choice of currentChoices) { - selected.delete(choice.value); - } - } else { - for (const choice of currentChoices) { - selected.set(choice.value, choice.label); - } - } + toggleSearchChoices(selected, displayedChoices()); render(); continue; @@ -137,11 +116,7 @@ export const readMultiSearchChoices = async (options: MultiSearchPromptOption const choice = displayedChoices()[highlighted]; if (choice && !choice.disabled) { - if (selected.has(choice.value as T)) { - selected.delete(choice.value as T); - } else { - selected.set(choice.value as T, choice.label); - } + toggleSearchChoice(selected, choice); } render(); @@ -164,15 +139,3 @@ export const readMultiSearchChoices = async (options: MultiSearchPromptOption render(); } }; - -const initialSelectedValues = (choices: Array>, defaults: T[] = []): Map => { - const selected = new Map(); - - for (const value of defaults) { - const choice = choices.find((candidate) => Object.is(candidate.value, value)); - - selected.set(value, choice?.label ?? String(value)); - } - - return selected; -}; diff --git a/packages/tui/src/prompts/search/selection.ts b/packages/tui/src/prompts/search/selection.ts new file mode 100644 index 00000000..3d036ae9 --- /dev/null +++ b/packages/tui/src/prompts/search/selection.ts @@ -0,0 +1,62 @@ +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) => Object.is(candidate.value, value)); + + selected.set(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) => Object.is(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) => (selected.has(choice.value) ? [index] : []))); +}; + +export const toggleSearchChoice = (selected: SearchSelection, choice: Choice): void => { + if (choice.disabled) { + return; + } + + if (selected.has(choice.value)) { + selected.delete(choice.value); + + 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.has(choice.value)); + + if (allSelected) { + for (const choice of selectable) { + selected.delete(choice.value); + } + + return; + } + + for (const choice of selectable) { + selected.set(choice.value, choice.label); + } +}; diff --git a/packages/tui/tests/search.test.ts b/packages/tui/tests/search.test.ts index f6a33818..88b551a3 100644 --- a/packages/tui/tests/search.test.ts +++ b/packages/tui/tests/search.test.ts @@ -454,6 +454,29 @@ describe('multisearch prompt', () => { expect(output.text()).toContain('1 selected (1 hidden)'); }); + it('keeps default multisearch labels while filtered out', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['B', Key.enter]), + output, + error: output, + interactive: true, + }, + () => + multisearch({ + message: 'Favorite colors?', + options: colors, + default: ['green'], + }), + ); + + expect(result).toEqual(['green']); + expect(output.text()).toContain('1 selected (1 hidden)'); + expect(output.text()).toContain('Selected: Green'); + }); + it('cancels multisearch prompts with the current selected values', async () => { const output = createMemoryOutput(); From 934efb61a3771886134bbbd425adf30e5020f645 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 19:47:03 +0800 Subject: [PATCH 136/675] Split data table key handling --- packages/tui/src/output/data-table.ts | 63 +++++++++++----------- packages/tui/src/output/data-table/keys.ts | 33 ++++++++++++ packages/tui/tests/table.test.ts | 21 ++++++++ 3 files changed, 84 insertions(+), 33 deletions(-) create mode 100644 packages/tui/src/output/data-table/keys.ts diff --git a/packages/tui/src/output/data-table.ts b/packages/tui/src/output/data-table.ts index 62dae1c0..db08f861 100644 --- a/packages/tui/src/output/data-table.ts +++ b/packages/tui/src/output/data-table.ts @@ -1,7 +1,8 @@ import { promptEnvironment } from '#tui/environment'; -import { Key, oneOf } from '#tui/key'; +import { Key } from '#tui/key'; import { promptUntilValid, PromptValidationError } from '#tui/prompt'; import { applyTypedKey } from '#tui/typed-value'; +import { dataTableNavigationAction, startsDataTableSearch } from '#tui/output/data-table/keys'; import { firstDataTableSelection, lastDataTableSelection, nextDataTableSelection, pageDataTableSelection, previousDataTableSelection } from '#tui/output/data-table/navigation'; import { dataTableRowValue, deriveDataTableHeaders, visibleDataTableRows } from '#tui/output/data-table/rows'; import { renderDataTableFrame } from '#tui/output/data-table/render'; @@ -91,7 +92,7 @@ export const datatable = async (options: DataTablePromptOptions) } } - if (key === '/') { + if (startsDataTableSearch(key)) { mode = 'search'; query = { cursor: 0, value: '' }; selected = 0; @@ -99,38 +100,10 @@ export const datatable = async (options: DataTablePromptOptions) continue; } - if (key === Key.down || key === Key.downArrow || key === Key.ctrlN || key === Key.tab) { - selected = nextDataTableSelection(selected, rows.length); - render(); - continue; - } - - if (key === Key.up || key === Key.upArrow || key === Key.ctrlP || key === Key.shiftTab) { - selected = previousDataTableSelection(selected, rows.length); - render(); - continue; - } - - if (key === Key.pageDown) { - selected = pageDataTableSelection(selected, rows.length, 1, options.scroll); - render(); - continue; - } - - if (key === Key.pageUp) { - selected = pageDataTableSelection(selected, rows.length, -1, options.scroll); - render(); - continue; - } + const action = dataTableNavigationAction(key); - if (oneOf([Key.home, Key.ctrlA], key)) { - selected = firstDataTableSelection(); - render(); - continue; - } - - if (oneOf([Key.end, Key.ctrlE], key)) { - selected = lastDataTableSelection(rows.length); + if (action !== null) { + selected = moveDataTableSelection(action, selected, rows.length, options.scroll); render(); continue; } @@ -147,3 +120,27 @@ export const datatable = async (options: DataTablePromptOptions) } }); }; + +const moveDataTableSelection = (action: NonNullable>, 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/keys.ts b/packages/tui/src/output/data-table/keys.ts new file mode 100644 index 00000000..c9280adc --- /dev/null +++ b/packages/tui/src/output/data-table/keys.ts @@ -0,0 +1,33 @@ +import { Key, oneOf } from '#tui/key'; + +export type DataTableNavigationAction = 'first' | 'last' | 'next' | 'page-next' | 'page-previous' | 'previous'; + +export const dataTableNavigationAction = (key: string): DataTableNavigationAction | null => { + if (key === Key.down || key === Key.downArrow || key === Key.ctrlN || key === Key.tab) { + return 'next'; + } + + if (key === Key.up || key === Key.upArrow || key === Key.ctrlP || key === Key.shiftTab) { + return 'previous'; + } + + if (key === Key.pageDown) { + return 'page-next'; + } + + if (key === Key.pageUp) { + return 'page-previous'; + } + + if (oneOf([Key.home, Key.ctrlA], key)) { + return 'first'; + } + + if (oneOf([Key.end, Key.ctrlE], key)) { + return 'last'; + } + + return null; +}; + +export const startsDataTableSearch = (key: string): boolean => key === '/'; diff --git a/packages/tui/tests/table.test.ts b/packages/tui/tests/table.test.ts index 7630d8b2..e7591279 100644 --- a/packages/tui/tests/table.test.ts +++ b/packages/tui/tests/table.test.ts @@ -446,6 +446,27 @@ describe('data table prompt', () => { expect(result).toBe(1); }); + it('supports control-key data table navigation', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.ctrlN, Key.ctrlN, Key.ctrlP, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + datatable({ + message: 'Pick row', + headers: ['Name'], + rows: [['First'], ['Second'], ['Third']], + }), + ); + + expect(result).toBe(1); + }); + it('wraps data table navigation from the first row to the last row', async () => { const output = createMemoryOutput(); From 4fcbaf5385ec86a26d28a3c14085af3b59a5c3cf Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 19:48:58 +0800 Subject: [PATCH 137/675] Split multi select key handling --- packages/tui/src/prompts/select/keys.ts | 62 +++++++++++++++++++ .../tui/src/prompts/select/read-multiple.ts | 41 +++--------- packages/tui/tests/choices.test.ts | 16 +++++ 3 files changed, 85 insertions(+), 34 deletions(-) create mode 100644 packages/tui/src/prompts/select/keys.ts diff --git a/packages/tui/src/prompts/select/keys.ts b/packages/tui/src/prompts/select/keys.ts new file mode 100644 index 00000000..36a12d9f --- /dev/null +++ b/packages/tui/src/prompts/select/keys.ts @@ -0,0 +1,62 @@ +import { Key, oneOf } from '#tui/key'; +import { firstEnabledIndex, nextEnabledIndex } from '#tui/concerns/choices'; +import { lastEnabledChoiceIndex, nextChoiceKeys, pageEnabledChoiceIndex, previousChoiceKeys } from '#tui/prompts/select/navigation'; +import type { Choice } from '#tui/types'; + +export type SelectNavigationAction = 'first' | 'last' | 'next' | 'page-next' | 'page-previous' | 'previous'; + +type SelectNavigationOptions = { + lineControls?: boolean; +}; + +export const selectNavigationAction = (key: string, options: SelectNavigationOptions = {}): SelectNavigationAction | null => { + if (nextChoiceKeys(key)) { + return 'next'; + } + + if (previousChoiceKeys(key)) { + return 'previous'; + } + + if (key === Key.pageDown) { + return 'page-next'; + } + + if (key === Key.pageUp) { + return 'page-previous'; + } + + if (oneOf(options.lineControls === true ? [Key.home, Key.ctrlA] : [Key.home], key)) { + return 'first'; + } + + if (oneOf(options.lineControls === true ? [Key.end, Key.ctrlE] : [Key.end], key)) { + return 'last'; + } + + return null; +}; + +export const moveSelectHighlight = (choices: Array>, selected: number, action: SelectNavigationAction, scroll?: number): number => { + if (action === 'next') { + return nextEnabledIndex(choices, selected, 1); + } + + if (action === 'previous') { + return nextEnabledIndex(choices, selected, -1); + } + + if (action === 'page-next') { + return pageEnabledChoiceIndex(choices, selected, 1, scroll); + } + + if (action === 'page-previous') { + return pageEnabledChoiceIndex(choices, selected, -1, scroll); + } + + if (action === 'first') { + return firstEnabledIndex(choices); + } + + return lastEnabledChoiceIndex(choices); +}; diff --git a/packages/tui/src/prompts/select/read-multiple.ts b/packages/tui/src/prompts/select/read-multiple.ts index 12597e7d..41da0c24 100644 --- a/packages/tui/src/prompts/select/read-multiple.ts +++ b/packages/tui/src/prompts/select/read-multiple.ts @@ -1,10 +1,11 @@ import { promptEnvironment } from '#tui/environment'; -import { Key, oneOf } from '#tui/key'; +import { Key } from '#tui/key'; import { ask } from '#tui/prompt'; import { renderChoices } from '#tui/theme'; -import { firstEnabledIndex, nextEnabledIndex } from '#tui/concerns/choices'; +import { firstEnabledIndex } from '#tui/concerns/choices'; +import { moveSelectHighlight, selectNavigationAction } from '#tui/prompts/select/keys'; import { choicesFromCommaSeparated, markedChoiceIndexes, markedChoiceValues, toggleAllEnabledChoices, toggleMarkedChoice } from '#tui/prompts/select/multiple'; -import { lastEnabledChoiceIndex, nextChoiceKeys, pageEnabledChoiceIndex, parseChoiceIndex, previousChoiceKeys } from '#tui/prompts/select/navigation'; +import { parseChoiceIndex } from '#tui/prompts/select/navigation'; import { renderMultipleChoices } from '#tui/prompts/select/render'; import type { Choice, MultiSelectPromptOptions } from '#tui/types'; @@ -64,38 +65,10 @@ export const readMultipleChoices = async ( continue; } - if (nextChoiceKeys(key)) { - selected = nextEnabledIndex(choices, selected, 1); - renderMultipleChoices(message, choices, selected, marked, scroll, info); - continue; - } - - if (previousChoiceKeys(key)) { - selected = nextEnabledIndex(choices, selected, -1); - renderMultipleChoices(message, choices, selected, marked, scroll, info); - continue; - } - - if (key === Key.pageDown) { - selected = pageEnabledChoiceIndex(choices, selected, 1, scroll); - renderMultipleChoices(message, choices, selected, marked, scroll, info); - continue; - } - - if (key === Key.pageUp) { - selected = pageEnabledChoiceIndex(choices, selected, -1, scroll); - renderMultipleChoices(message, choices, selected, marked, scroll, info); - continue; - } - - if (oneOf([Key.home], key)) { - selected = firstEnabledIndex(choices); - renderMultipleChoices(message, choices, selected, marked, scroll, info); - continue; - } + const action = selectNavigationAction(key); - if (oneOf([Key.end], key)) { - selected = lastEnabledChoiceIndex(choices); + if (action !== null) { + selected = moveSelectHighlight(choices, selected, action, scroll); renderMultipleChoices(message, choices, selected, marked, scroll, info); continue; } diff --git a/packages/tui/tests/choices.test.ts b/packages/tui/tests/choices.test.ts index 849dc388..e714e22e 100644 --- a/packages/tui/tests/choices.test.ts +++ b/packages/tui/tests/choices.test.ts @@ -576,6 +576,22 @@ describe('choice prompts', () => { expect(result).toEqual(['third', 'first']); }); + it('supports control-key multiselect navigation', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.ctrlN, Key.ctrlN, Key.ctrlP, Key.space, Key.enter]), + output, + error: output, + interactive: true, + }, + () => multiselect({ message: 'Pick many', options: ['first', 'second', 'third'] }), + ); + + expect(result).toEqual(['second']); + }); + it('ignores end-of-line control navigation for multiselect prompts', async () => { const output = createMemoryOutput(); From 4babc6327ddcbb26678009e632f89ce2abbcbaf0 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 19:51:01 +0800 Subject: [PATCH 138/675] Reuse select key handling --- .../tui/src/prompts/select/read-selected.ts | 41 ++++--------------- packages/tui/tests/choices.test.ts | 27 ++++++++++++ 2 files changed, 34 insertions(+), 34 deletions(-) diff --git a/packages/tui/src/prompts/select/read-selected.ts b/packages/tui/src/prompts/select/read-selected.ts index cf1a7ae2..d593d75c 100644 --- a/packages/tui/src/prompts/select/read-selected.ts +++ b/packages/tui/src/prompts/select/read-selected.ts @@ -1,9 +1,10 @@ import { promptEnvironment } from '#tui/environment'; -import { Key, oneOf } from '#tui/key'; +import { Key } from '#tui/key'; import { ask, PromptValidationError } from '#tui/prompt'; import { renderChoices } from '#tui/theme'; -import { findChoice, firstEnabledIndex, nextEnabledIndex } from '#tui/concerns/choices'; -import { lastEnabledChoiceIndex, nextChoiceKeys, pageEnabledChoiceIndex, parseChoiceIndex, previousChoiceKeys } from '#tui/prompts/select/navigation'; +import { findChoice, firstEnabledIndex } from '#tui/concerns/choices'; +import { moveSelectHighlight, selectNavigationAction } from '#tui/prompts/select/keys'; +import { parseChoiceIndex } from '#tui/prompts/select/navigation'; import { renderSelectedChoice } from '#tui/prompts/select/render'; import type { Choice, SelectPromptOptions } from '#tui/types'; @@ -63,38 +64,10 @@ export const readSelectedChoice = async (message: string, choices: Array { expect(result).toBe('first'); }); + it('supports control-line select navigation keys', async () => { + const output = createMemoryOutput(); + + const endResult = await withPromptEnvironment( + { + input: createScriptedInput([Key.ctrlE, Key.enter]), + output, + error: output, + interactive: true, + }, + () => select({ message: 'Pick one', options: ['first', 'second', 'third'] }), + ); + + const homeResult = await withPromptEnvironment( + { + input: createScriptedInput([Key.ctrlE, Key.ctrlA, Key.enter]), + output, + error: output, + interactive: true, + }, + () => select({ message: 'Pick one', options: ['first', 'second', 'third'] }), + ); + + expect(endResult).toBe('third'); + expect(homeResult).toBe('first'); + }); + it('supports page select navigation keys', async () => { const output = createMemoryOutput(); From 4682345ad8abc63a0f6cf2b344b066b30edbaadd Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 19:52:38 +0800 Subject: [PATCH 139/675] Move data table selection movement --- packages/tui/src/output/data-table.ts | 26 +------------ .../tui/src/output/data-table/navigation.ts | 25 +++++++++++++ packages/tui/tests/table.test.ts | 37 +++++++++++++++++++ 3 files changed, 63 insertions(+), 25 deletions(-) diff --git a/packages/tui/src/output/data-table.ts b/packages/tui/src/output/data-table.ts index db08f861..d8d56437 100644 --- a/packages/tui/src/output/data-table.ts +++ b/packages/tui/src/output/data-table.ts @@ -3,7 +3,7 @@ import { Key } from '#tui/key'; import { promptUntilValid, PromptValidationError } from '#tui/prompt'; import { applyTypedKey } from '#tui/typed-value'; import { dataTableNavigationAction, startsDataTableSearch } from '#tui/output/data-table/keys'; -import { firstDataTableSelection, lastDataTableSelection, nextDataTableSelection, pageDataTableSelection, previousDataTableSelection } from '#tui/output/data-table/navigation'; +import { moveDataTableSelection } from '#tui/output/data-table/navigation'; import { dataTableRowValue, deriveDataTableHeaders, visibleDataTableRows } from '#tui/output/data-table/rows'; import { renderDataTableFrame } from '#tui/output/data-table/render'; import type { DataTablePromptOptions } from '#tui/types'; @@ -120,27 +120,3 @@ export const datatable = async (options: DataTablePromptOptions) } }); }; - -const moveDataTableSelection = (action: NonNullable>, 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/navigation.ts b/packages/tui/src/output/data-table/navigation.ts index 16aae87c..8c8dfe54 100644 --- a/packages/tui/src/output/data-table/navigation.ts +++ b/packages/tui/src/output/data-table/navigation.ts @@ -1,4 +1,5 @@ import { parseScrollSize } from '#tui/concerns/validators/scroll'; +import type { DataTableNavigationAction } from '#tui/output/data-table/keys'; export const nextDataTableSelection = (selected: number, total: number): number => { return total === 0 ? 0 : (selected + 1) % total; @@ -19,3 +20,27 @@ export const pageDataTableSelection = (selected: number, total: number, directio export const firstDataTableSelection = (): number => 0; export const lastDataTableSelection = (total: number): number => Math.max(0, 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/tests/table.test.ts b/packages/tui/tests/table.test.ts index e7591279..f35d9c0b 100644 --- a/packages/tui/tests/table.test.ts +++ b/packages/tui/tests/table.test.ts @@ -467,6 +467,43 @@ describe('data table prompt', () => { expect(result).toBe(1); }); + it('supports control-line data table navigation keys', async () => { + const output = createMemoryOutput(); + + const endResult = await withPromptEnvironment( + { + input: createScriptedInput([Key.ctrlE, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + datatable({ + message: 'Pick row', + headers: ['Name'], + rows: [['First'], ['Second'], ['Third']], + }), + ); + + const homeResult = await withPromptEnvironment( + { + input: createScriptedInput([Key.ctrlE, Key.ctrlA, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + datatable({ + message: 'Pick row', + headers: ['Name'], + rows: [['First'], ['Second'], ['Third']], + }), + ); + + expect(endResult).toBe(2); + expect(homeResult).toBe(0); + }); + it('wraps data table navigation from the first row to the last row', async () => { const output = createMemoryOutput(); From 4d78b8327c5233aabd515c844b143a68cbe14bea Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 19:54:23 +0800 Subject: [PATCH 140/675] Split typed value deletion --- packages/tui/src/typed-value/delete.ts | 36 ++++++++++++++++++++++++++ packages/tui/src/typed-value/edit.ts | 27 +++++++------------ packages/tui/tests/typed-value.test.ts | 4 +++ 3 files changed, 49 insertions(+), 18 deletions(-) create mode 100644 packages/tui/src/typed-value/delete.ts diff --git a/packages/tui/src/typed-value/delete.ts b/packages/tui/src/typed-value/delete.ts new file mode 100644 index 00000000..580e5395 --- /dev/null +++ b/packages/tui/src/typed-value/delete.ts @@ -0,0 +1,36 @@ +import { fromCharacters, stringIndexToCharacterIndex } from '#tui/typed-value/characters'; +import { moveToLineBoundary } from '#tui/typed-value/lines'; + +export const deleteNextCharacter = (value: string[], cursor: number): number => { + value.splice(cursor, 1); + + return cursor; +}; + +export const deleteToLineStart = (value: string[], cursor: number, allowNewLine: boolean): number => { + const start = allowNewLine ? moveToLineBoundary(value, cursor, 'start') : 0; + + value.splice(start, cursor - start); + + return start; +}; + +export const deletePreviousCharacter = (value: string[], cursor: number): number => { + if (cursor === 0) { + return cursor; + } + + value.splice(cursor - 1, 1); + + return cursor - 1; +}; + +export const deletePreviousWord = (value: string[], cursor: number): number => { + const before = fromCharacters(value.slice(0, cursor)); + const match = before.match(/(?:[\p{L}\p{M}\p{N}]+|[^\p{L}\p{M}\p{N}\s]+)\s*$/u); + const start = match?.index === undefined ? 0 : stringIndexToCharacterIndex(before, match.index); + + value.splice(start, cursor - start); + + return start; +}; diff --git a/packages/tui/src/typed-value/edit.ts b/packages/tui/src/typed-value/edit.ts index 6d9f384a..22a0e93c 100644 --- a/packages/tui/src/typed-value/edit.ts +++ b/packages/tui/src/typed-value/edit.ts @@ -1,5 +1,6 @@ import { Key, oneOf } from '#tui/key'; -import { characterLength, characters, fromCharacters, isPrintable, stringIndexToCharacterIndex } from '#tui/typed-value/characters'; +import { characterLength, characters, fromCharacters, isPrintable } from '#tui/typed-value/characters'; +import { deleteNextCharacter, deletePreviousCharacter, deletePreviousWord, deleteToLineStart } from '#tui/typed-value/delete'; import { moveLine, moveToLineBoundary } from '#tui/typed-value/lines'; import type { AppliedTypedKey, TypedValueState } from '#tui/typed-value/types'; @@ -56,37 +57,27 @@ export const applyTypedKey = (state: TypedValueState, key: string, allowNewLine } if (key === Key.delete) { - value.splice(cursor, 1); + cursor = deleteNextCharacter(value, cursor); return { cursor, value: fromCharacters(value), submitted: false, cancelled: false }; } if (key === Key.ctrlU) { - const start = allowNewLine ? moveToLineBoundary(value, cursor, 'start') : 0; + cursor = deleteToLineStart(value, cursor, allowNewLine); - value.splice(start, cursor - start); - - return { cursor: start, value: fromCharacters(value), submitted: false, cancelled: false }; + return { cursor, value: fromCharacters(value), submitted: false, cancelled: false }; } if (key === Key.backspace || key === Key.ctrlH) { - if (cursor === 0) { - return { cursor, value: fromCharacters(value), submitted: false, cancelled: false }; - } - - value.splice(cursor - 1, 1); + cursor = deletePreviousCharacter(value, cursor); - return { cursor: cursor - 1, value: fromCharacters(value), submitted: false, cancelled: false }; + return { cursor, value: fromCharacters(value), submitted: false, cancelled: false }; } if (key === Key.optionBackspace) { - const before = fromCharacters(value.slice(0, cursor)); - const match = before.match(/(?:[\p{L}\p{M}\p{N}]+|[^\p{L}\p{M}\p{N}\s]+)\s*$/u); - const start = match?.index === undefined ? 0 : stringIndexToCharacterIndex(before, match.index); - - value.splice(start, cursor - start); + cursor = deletePreviousWord(value, cursor); - return { cursor: start, value: fromCharacters(value), submitted: false, cancelled: false }; + return { cursor, value: fromCharacters(value), submitted: false, cancelled: false }; } if (isPrintable(key)) { diff --git a/packages/tui/tests/typed-value.test.ts b/packages/tui/tests/typed-value.test.ts index a5f0b0f1..b16b684e 100644 --- a/packages/tui/tests/typed-value.test.ts +++ b/packages/tui/tests/typed-value.test.ts @@ -49,6 +49,10 @@ describe('typed value editing', () => { expect(apply(['J', 'e', 'z', Key.left, Key.delete, 's', 's'])).toBe('Jess'); }); + it('keeps delete at the end of input unchanged', () => { + expect(apply(['J', 'e', 's', 's', Key.delete])).toBe('Jess'); + }); + it('deletes typed input back to the line start', () => { expect(apply(['J', 'a', 'n', 'e', Key.left, Key.left, Key.ctrlU, 'D', 'o'])).toBe('Done'); }); From c828c13eed69b220e3cef8a155ea2bc82039193f Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 19:57:34 +0800 Subject: [PATCH 141/675] Split search key handling --- packages/tui/src/prompts/search/keys.ts | 70 +++++++++++++++++++ .../tui/src/prompts/search/read-single.ts | 46 ++---------- packages/tui/tests/search.test.ts | 27 +++++++ 3 files changed, 103 insertions(+), 40 deletions(-) create mode 100644 packages/tui/src/prompts/search/keys.ts diff --git a/packages/tui/src/prompts/search/keys.ts b/packages/tui/src/prompts/search/keys.ts new file mode 100644 index 00000000..0095a457 --- /dev/null +++ b/packages/tui/src/prompts/search/keys.ts @@ -0,0 +1,70 @@ +import { Key, oneOf } from '#tui/key'; +import { firstSearchHighlight, lastSearchHighlight, nextRetriedSearchHighlight, nextSearchHighlight, pageSearchHighlight } from '#tui/prompts/search/navigation'; +import type { Choice } from '#tui/types'; + +export type SearchNavigationAction = 'first' | 'last' | 'next' | 'page-next' | 'page-previous' | 'previous'; + +type SearchNavigationOptions = { + controlNavigation?: boolean; + lineControls?: boolean; +}; + +type MoveSearchHighlightOptions = { + attempt?: number; + retryFirst?: boolean; + scroll?: number; +}; + +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; +}; + +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/read-single.ts b/packages/tui/src/prompts/search/read-single.ts index ddf35242..e56d85b4 100644 --- a/packages/tui/src/prompts/search/read-single.ts +++ b/packages/tui/src/prompts/search/read-single.ts @@ -1,10 +1,10 @@ import { promptEnvironment } from '#tui/environment'; -import { Key, oneOf } from '#tui/key'; +import { Key } from '#tui/key'; import { ask } from '#tui/prompt'; import { applyTypedKey } from '#tui/typed-value'; import { resolveSearchChoices } from '#tui/prompts/search/choices'; +import { clearsSearchHighlight, moveSearchHighlight, searchNavigationAction } from '#tui/prompts/search/keys'; import { resolveLineSearchChoice } from '#tui/prompts/search/line-mode'; -import { firstSearchHighlight, lastSearchHighlight, nextRetriedSearchHighlight, nextSearchHighlight, pageSearchHighlight } from '#tui/prompts/search/navigation'; import { renderSearchChoices } from '#tui/prompts/search/render'; import type { SearchPromptOptions } from '#tui/types'; @@ -44,51 +44,17 @@ export const readSearchChoice = async (options: SearchPromptOptions, attem return options.default; } - if (key === Key.down || key === Key.downArrow || key === Key.ctrlN || key === Key.tab) { - choices = await resolveSearchChoices(options.options, state.value); + const action = searchNavigationAction(key, { controlNavigation: true, lineControls: true }); - highlighted = nextRetriedSearchHighlight(choices, highlighted, attempt); - renderSearchChoices(options.message, state.value, choices, highlighted, new Set(), [], options.scroll, options.info); - continue; - } - - if (key === Key.up || key === Key.upArrow || key === Key.ctrlP || key === Key.shiftTab) { + if (action !== null && (action !== 'first' || highlighted !== null) && (action !== 'last' || highlighted !== null)) { choices = await resolveSearchChoices(options.options, state.value); - highlighted = nextSearchHighlight(choices, highlighted, -1); - renderSearchChoices(options.message, state.value, choices, highlighted, new Set(), [], options.scroll, options.info); - continue; - } - - if (key === Key.pageDown) { - choices = await resolveSearchChoices(options.options, state.value); - - highlighted = pageSearchHighlight(choices, highlighted, 1, options.scroll); - renderSearchChoices(options.message, state.value, choices, highlighted, new Set(), [], options.scroll, options.info); - continue; - } - - if (key === Key.pageUp) { - choices = await resolveSearchChoices(options.options, state.value); - - highlighted = pageSearchHighlight(choices, highlighted, -1, options.scroll); - renderSearchChoices(options.message, state.value, choices, highlighted, new Set(), [], options.scroll, options.info); - continue; - } - - if (oneOf([Key.home, Key.ctrlA], key) && highlighted !== null) { - highlighted = firstSearchHighlight(choices); - renderSearchChoices(options.message, state.value, choices, highlighted, new Set(), [], options.scroll, options.info); - continue; - } - - if (oneOf([Key.end, Key.ctrlE], key) && highlighted !== null) { - highlighted = lastSearchHighlight(choices); + highlighted = moveSearchHighlight(choices, highlighted, action, { attempt, retryFirst: true, scroll: options.scroll }); renderSearchChoices(options.message, state.value, choices, highlighted, new Set(), [], options.scroll, options.info); continue; } - if (oneOf([Key.left, Key.leftArrow, Key.right, Key.rightArrow, Key.ctrlB, Key.ctrlF], key) && highlighted !== null) { + if (clearsSearchHighlight(key) && highlighted !== null) { highlighted = null; renderSearchChoices(options.message, state.value, choices, highlighted, new Set(), [], options.scroll, options.info); continue; diff --git a/packages/tui/tests/search.test.ts b/packages/tui/tests/search.test.ts index 88b551a3..dfeaf9de 100644 --- a/packages/tui/tests/search.test.ts +++ b/packages/tui/tests/search.test.ts @@ -117,6 +117,33 @@ describe('search prompt', () => { expect(result).toBe('three'); }); + it('supports control-key search navigation', async () => { + const output = createMemoryOutput(); + + const forwardResult = await withPromptEnvironment( + { + input: createScriptedInput([Key.ctrlN, Key.ctrlN, Key.enter]), + output, + error: output, + interactive: true, + }, + () => search({ message: 'Favorite color?', options: colors }), + ); + + const backwardResult = await withPromptEnvironment( + { + input: createScriptedInput([Key.ctrlP, Key.enter]), + output, + error: output, + interactive: true, + }, + () => search({ message: 'Favorite color?', options: colors }), + ); + + expect(forwardResult).toBe('green'); + expect(backwardResult).toBe('blue'); + }); + it('renders search info for the highlighted result', async () => { const output = createMemoryOutput(); From b89b55db6c436188f0b18d5d2bee7d23fb049a55 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 19:59:00 +0800 Subject: [PATCH 142/675] Reuse search key handling --- packages/tui/src/prompts/search/read-multi.ts | 50 ++----------------- packages/tui/tests/search.test.ts | 16 ++++++ 2 files changed, 21 insertions(+), 45 deletions(-) diff --git a/packages/tui/src/prompts/search/read-multi.ts b/packages/tui/src/prompts/search/read-multi.ts index 64a289bd..145830b0 100644 --- a/packages/tui/src/prompts/search/read-multi.ts +++ b/packages/tui/src/prompts/search/read-multi.ts @@ -1,10 +1,10 @@ import { promptEnvironment } from '#tui/environment'; -import { Key, oneOf } from '#tui/key'; +import { Key } from '#tui/key'; import { ask } from '#tui/prompt'; import { applyTypedKey } from '#tui/typed-value'; import { resolveSearchChoices } from '#tui/prompts/search/choices'; +import { moveSearchHighlight, searchNavigationAction } from '#tui/prompts/search/keys'; import { resolveLineMultiSearchChoices } from '#tui/prompts/search/line-mode'; -import { firstSearchHighlight, lastSearchHighlight, nextSearchHighlight, pageSearchHighlight } from '#tui/prompts/search/navigation'; import { renderSearchChoices } from '#tui/prompts/search/render'; import { createInitialSearchSelection, displayedSearchChoices, markedSearchChoiceIndexes, toggleSearchChoice, toggleSearchChoices } from '#tui/prompts/search/selection'; import type { MultiSearchPromptOptions } from '#tui/types'; @@ -49,54 +49,14 @@ export const readMultiSearchChoices = async (options: MultiSearchPromptOption return [...selected.keys()]; } - if (key === Key.down || key === Key.downArrow || key === Key.tab) { - choices = await resolveSearchChoices(options.options, state.value); - - const currentChoices = displayedChoices(); - - highlighted = nextSearchHighlight(currentChoices, highlighted, 1); - render(); - continue; - } - - if (key === Key.up || key === Key.upArrow || key === Key.shiftTab) { - choices = await resolveSearchChoices(options.options, state.value); - - const currentChoices = displayedChoices(); - - highlighted = nextSearchHighlight(currentChoices, highlighted, -1); - render(); - continue; - } + const action = searchNavigationAction(key); - if (key === Key.pageDown) { + if (action !== null && (action !== 'first' || highlighted !== null) && (action !== 'last' || highlighted !== null)) { choices = await resolveSearchChoices(options.options, state.value); const currentChoices = displayedChoices(); - highlighted = pageSearchHighlight(currentChoices, highlighted, 1, options.scroll); - render(); - continue; - } - - if (key === Key.pageUp) { - choices = await resolveSearchChoices(options.options, state.value); - - const currentChoices = displayedChoices(); - - highlighted = pageSearchHighlight(currentChoices, highlighted, -1, options.scroll); - render(); - continue; - } - - if (oneOf([Key.home], key) && highlighted !== null) { - highlighted = firstSearchHighlight(displayedChoices()); - render(); - continue; - } - - if (oneOf([Key.end], key) && highlighted !== null) { - highlighted = lastSearchHighlight(displayedChoices()); + highlighted = moveSearchHighlight(currentChoices, highlighted, action, { scroll: options.scroll }); render(); continue; } diff --git a/packages/tui/tests/search.test.ts b/packages/tui/tests/search.test.ts index dfeaf9de..c025d86d 100644 --- a/packages/tui/tests/search.test.ts +++ b/packages/tui/tests/search.test.ts @@ -555,6 +555,22 @@ describe('multisearch prompt', () => { expect(output.text()).not.toContain('Selected: Green'); }); + it('supports tab and reverse-tab multisearch navigation', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.tab, Key.tab, Key.shiftTab, Key.space, Key.enter]), + output, + error: output, + interactive: true, + }, + () => multisearch({ message: 'Favorite colors?', options: colors }), + ); + + expect(result).toEqual(['red']); + }); + it('keeps selected multisearch values visible after clearing the query', async () => { const output = createMemoryOutput(); From 97c96a0da6248b1944c985283332a43c1f183d67 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 20:00:55 +0800 Subject: [PATCH 143/675] Split suggest key handling --- packages/tui/src/prompts/suggest/keys.ts | 58 ++++++++++++++++++++++++ packages/tui/src/prompts/suggest/read.ts | 54 +++------------------- packages/tui/tests/suggest.test.ts | 6 +++ 3 files changed, 70 insertions(+), 48 deletions(-) create mode 100644 packages/tui/src/prompts/suggest/keys.ts diff --git a/packages/tui/src/prompts/suggest/keys.ts b/packages/tui/src/prompts/suggest/keys.ts new file mode 100644 index 00000000..e9b644fb --- /dev/null +++ b/packages/tui/src/prompts/suggest/keys.ts @@ -0,0 +1,58 @@ +import { Key, oneOf } from '#tui/key'; +import { firstSuggestionHighlight, lastSuggestionHighlight, nextSuggestionHighlight, pageSuggestionHighlight } from '#tui/prompts/suggest/navigation'; + +export type SuggestNavigationAction = 'first' | 'last' | 'next' | 'page-next' | 'page-previous' | 'previous'; + +export const suggestNavigationAction = (key: string): SuggestNavigationAction | null => { + if (key === Key.tab || key === Key.down || key === Key.downArrow || key === Key.ctrlN) { + return 'next'; + } + + if (key === Key.up || key === Key.upArrow || key === Key.ctrlP || key === Key.shiftTab) { + return 'previous'; + } + + if (key === Key.pageDown) { + return 'page-next'; + } + + if (key === Key.pageUp) { + return 'page-previous'; + } + + if (oneOf([Key.home, Key.ctrlA], key)) { + return 'first'; + } + + if (oneOf([Key.end, Key.ctrlE], key)) { + return 'last'; + } + + return null; +}; + +export const clearsSuggestionHighlight = (key: string): boolean => oneOf([Key.left, Key.leftArrow, Key.right, Key.rightArrow, Key.ctrlB, Key.ctrlF], key) !== undefined; + +export const moveSuggestionHighlight = (matches: string[], highlighted: number | null, action: SuggestNavigationAction, scroll?: number): number | null => { + if (action === 'next') { + return nextSuggestionHighlight(matches, highlighted, 1); + } + + if (action === 'previous') { + return nextSuggestionHighlight(matches, highlighted, -1); + } + + if (action === 'page-next') { + return pageSuggestionHighlight(matches, highlighted, 1, scroll); + } + + if (action === 'page-previous') { + return pageSuggestionHighlight(matches, highlighted, -1, scroll); + } + + if (action === 'first') { + return firstSuggestionHighlight(matches); + } + + return lastSuggestionHighlight(matches); +}; diff --git a/packages/tui/src/prompts/suggest/read.ts b/packages/tui/src/prompts/suggest/read.ts index f4538a77..02d17979 100644 --- a/packages/tui/src/prompts/suggest/read.ts +++ b/packages/tui/src/prompts/suggest/read.ts @@ -1,11 +1,11 @@ import { promptEnvironment } from '#tui/environment'; -import { Key, oneOf } from '#tui/key'; +import { Key } from '#tui/key'; import { ask } from '#tui/prompt'; import { applyTypedKey } from '#tui/typed-value'; +import { clearsSuggestionHighlight, moveSuggestionHighlight, suggestNavigationAction } from '#tui/prompts/suggest/keys'; import { renderSuggestions } from '#tui/prompts/suggest/render'; import { resolveSuggestions } from '#tui/prompts/suggest/resolve'; import { characterLength } from '#tui/typed-value/characters'; -import { firstSuggestionHighlight, lastSuggestionHighlight, nextSuggestionHighlight, pageSuggestionHighlight } from '#tui/prompts/suggest/navigation'; import type { SuggestOptions } from '#tui/prompts/suggest/options'; export const readSuggestionValue = async (options: SuggestOptions): Promise => { @@ -32,59 +32,17 @@ export const readSuggestionValue = async (options: SuggestOptions): Promise { await expectSuggestion(['b', Key.down, Key.end[0], Key.home[0], Key.enter], ['Red', 'Blue', 'Black', 'Blurple'], 'Blue'); }); + it('supports control-line suggestion navigation keys', async () => { + await expectSuggestion(['b', Key.down, Key.ctrlE, Key.enter], ['Red', 'Blue', 'Black', 'Blurple'], 'Blurple'); + + await expectSuggestion(['b', Key.down, Key.ctrlE, Key.ctrlA, Key.enter], ['Red', 'Blue', 'Black', 'Blurple'], 'Blue'); + }); + it('supports callback options', async () => { await expectSuggestion(['e', 'e', Key.down, Key.enter], (value) => ['Red', 'Green', 'Blue'].filter((option) => option.toLowerCase().includes(value.toLowerCase())), 'Green'); }); From db731174b71690e78772307d42e5be6d77c8d01a Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 20:03:27 +0800 Subject: [PATCH 144/675] Split autocomplete completion handling --- .../tui/src/prompts/suggest/autocomplete.ts | 32 +++++++++++++++++++ .../src/prompts/suggest/read-autocomplete.ts | 30 ++++++----------- packages/tui/tests/suggest.test.ts | 16 ++++++++++ 3 files changed, 58 insertions(+), 20 deletions(-) create mode 100644 packages/tui/src/prompts/suggest/autocomplete.ts diff --git a/packages/tui/src/prompts/suggest/autocomplete.ts b/packages/tui/src/prompts/suggest/autocomplete.ts new file mode 100644 index 00000000..cbc8102b --- /dev/null +++ b/packages/tui/src/prompts/suggest/autocomplete.ts @@ -0,0 +1,32 @@ +import { Key } from '#tui/key'; +import { characterLength } from '#tui/typed-value/characters'; +import { nextAutocompleteHighlight } from '#tui/prompts/suggest/navigation'; +import type { TypedValueState } from '#tui/typed-value/types'; + +export const autocompleteNavigationDirection = (key: string): 1 | -1 | null => { + if (key === Key.up || key === Key.upArrow) { + return -1; + } + + if (key === Key.down || key === Key.downArrow) { + return 1; + } + + return null; +}; + +export const moveAutocompleteHighlight = (matches: string[], highlighted: number, direction: 1 | -1): number => nextAutocompleteHighlight(matches, highlighted, direction); + +export const canAcceptAutocomplete = (state: TypedValueState): boolean => state.cursor >= characterLength(state.value); + +export const acceptAutocompleteMatch = (state: TypedValueState, match: string | undefined, requireGrowth: boolean): TypedValueState | null => { + if (match === undefined) { + return null; + } + + if (requireGrowth && match.length <= state.value.length) { + return null; + } + + return { cursor: match.length, value: match }; +}; diff --git a/packages/tui/src/prompts/suggest/read-autocomplete.ts b/packages/tui/src/prompts/suggest/read-autocomplete.ts index 28ed5db6..5c14b8fa 100644 --- a/packages/tui/src/prompts/suggest/read-autocomplete.ts +++ b/packages/tui/src/prompts/suggest/read-autocomplete.ts @@ -2,10 +2,10 @@ import { promptEnvironment } from '#tui/environment'; import { Key } from '#tui/key'; import { ask } from '#tui/prompt'; import { applyTypedKey } from '#tui/typed-value'; +import { acceptAutocompleteMatch, autocompleteNavigationDirection, canAcceptAutocomplete, moveAutocompleteHighlight } from '#tui/prompts/suggest/autocomplete'; import { renderAutocomplete } from '#tui/prompts/suggest/render-autocomplete'; import { resolveSuggestions } from '#tui/prompts/suggest/resolve'; import { characterLength } from '#tui/typed-value/characters'; -import { nextAutocompleteHighlight } from '#tui/prompts/suggest/navigation'; import type { SuggestOptions } from '#tui/prompts/suggest/options'; export const readAutocompleteValue = async (options: SuggestOptions): Promise => { @@ -32,29 +32,23 @@ export const readAutocompleteValue = async (options: SuggestOptions): Promise= characterLength(state.value)) { + if (key === Key.tab && canAcceptAutocomplete(state)) { matches = await resolveSuggestions(options.options, state.value); - const match = matches[highlighted]; + const next = acceptAutocompleteMatch(state, matches[highlighted], true); - if (match !== undefined && match.length > state.value.length) { - state = { cursor: match.length, value: match }; + if (next !== null) { + state = next; matches = await resolveSuggestions(options.options, state.value); } else { @@ -65,14 +59,10 @@ export const readAutocompleteValue = async (options: SuggestOptions): Promise= characterLength(state.value)) { + if ((key === Key.right || key === Key.rightArrow) && canAcceptAutocomplete(state)) { matches = await resolveSuggestions(options.options, state.value); - const match = matches[highlighted]; - - if (match !== undefined) { - state = { cursor: match.length, value: match }; - } + state = acceptAutocompleteMatch(state, matches[highlighted], false) ?? state; renderAutocomplete(options.message, state.value, matches, highlighted, options.hint, options.placeholder); continue; diff --git a/packages/tui/tests/suggest.test.ts b/packages/tui/tests/suggest.test.ts index 4097a38d..2c4d63ed 100644 --- a/packages/tui/tests/suggest.test.ts +++ b/packages/tui/tests/suggest.test.ts @@ -161,6 +161,22 @@ describe('autocomplete prompt', () => { expect(output.text()).toContain('Favorite color? Blue'); }); + it('does not accept tab completion before the cursor reaches the end', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['b', Key.left, Key.tab, Key.enter]), + output, + error: output, + interactive: true, + }, + () => autocomplete('Favorite color?', ['Blue']), + ); + + expect(result).toBe('b'); + }); + it('accepts same-length autocomplete matches with right arrow', async () => { const output = createMemoryOutput(); From db8691addcd48321de1f5c2b90811838e55440ae Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 20:05:28 +0800 Subject: [PATCH 145/675] Split progress runner --- packages/tui/src/status/progress.ts | 17 ++-------------- packages/tui/src/status/progress/run.ts | 27 +++++++++++++++++++++++++ packages/tui/tests/progress.test.ts | 10 +++++++++ 3 files changed, 39 insertions(+), 15 deletions(-) create mode 100644 packages/tui/src/status/progress/run.ts diff --git a/packages/tui/src/status/progress.ts b/packages/tui/src/status/progress.ts index 417aab02..cee154ad 100644 --- a/packages/tui/src/status/progress.ts +++ b/packages/tui/src/status/progress.ts @@ -1,6 +1,7 @@ import { promptEnvironment } from '#tui/environment'; import { parseProgressStep, parseProgressTotal } from '#tui/status/validators/progress'; import { renderProgressFrame } from '#tui/status/progress/render'; +import { runProgressSteps } from '#tui/status/progress/run'; import { progressValues } from '#tui/status/progress/steps'; import type { MaybePromise } from '#tui/types'; @@ -86,19 +87,5 @@ export function progress( return bar; } - return (async () => { - const results: R[] = []; - - bar.start(); - - for (const value of values) { - results.push(await callback(value, bar)); - - bar.advance(); - } - - bar.finish(); - - return results; - })(); + return runProgressSteps(bar, values, callback); } diff --git a/packages/tui/src/status/progress/run.ts b/packages/tui/src/status/progress/run.ts new file mode 100644 index 00000000..ca824ff5 --- /dev/null +++ b/packages/tui/src/status/progress/run.ts @@ -0,0 +1,27 @@ +import type { MaybePromise } from '#tui/types'; + +type ProgressRunner = { + advance(): 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(); + + for (const value of values) { + results.push(await callback(value, progress)); + + progress.advance(); + } + + progress.finish(); + + return results; +}; diff --git a/packages/tui/tests/progress.test.ts b/packages/tui/tests/progress.test.ts index d625c1a2..d24c297a 100644 --- a/packages/tui/tests/progress.test.ts +++ b/packages/tui/tests/progress.test.ts @@ -14,6 +14,16 @@ describe('progress helper', () => { expect(output.text()).toContain('2 / 2'); }); + it('passes the current count to progress callbacks before advancing', async () => { + const output = createMemoryOutput(); + + const counts = await withPromptEnvironment({ output, error: output }, async () => { + return progress('Adding States', ['Alabama', 'Alaska'], (_state, bar) => bar.current()); + }); + + expect(counts).toEqual([0, 1]); + }); + it('supports manual progress updates with label and hint changes', async () => { const output = createMemoryOutput(); From 49d27add1d27d669a4275f91354b6fab80fe4bba Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 20:08:22 +0800 Subject: [PATCH 146/675] Split form submit flow --- packages/tui/src/form/builder/index.ts | 43 ++--------------------- packages/tui/src/form/builder/submit.ts | 45 +++++++++++++++++++++++++ packages/tui/tests/form.test.ts | 18 ++++++++++ 3 files changed, 65 insertions(+), 41 deletions(-) create mode 100644 packages/tui/src/form/builder/submit.ts diff --git a/packages/tui/src/form/builder/index.ts b/packages/tui/src/form/builder/index.ts index 02507bbd..bbcb4517 100644 --- a/packages/tui/src/form/builder/index.ts +++ b/packages/tui/src/form/builder/index.ts @@ -1,8 +1,7 @@ import { outputBuilderMethods } from '#tui/form/builder/output'; import { promptBuilderMethods } from '#tui/form/builder/prompts'; -import { FormRevertedError, runWithFormRevert } from '#tui/form/builder/revert'; import { statusBuilderMethods } from '#tui/form/builder/status'; -import { shouldIgnoreStepWhenReverting, shouldRunStep } from '#tui/form/builder/conditions'; +import { submitFormSteps } from '#tui/form/builder/submit'; import type { FormResponses, FormStep } from '#tui/form/types'; import type { MaybePromise } from '#tui/types'; @@ -63,44 +62,6 @@ export class FormBuilder { } async submit(): Promise { - let index = 0; - let wasReverted = false; - - while (index < this.#steps.length) { - const step = this.#steps[index]; - - if (step === undefined) { - break; - } - - if (wasReverted && index > 0 && shouldIgnoreStepWhenReverting(step, this.#responses)) { - index -= 1; - continue; - } - - wasReverted = false; - - const key = step.name ?? index; - - if (!shouldRunStep(step, this.#responses)) { - this.#responses[key] = null; - index += 1; - continue; - } - - try { - this.#responses[key] = await runWithFormRevert(index > 0, () => step.run(this.#responses, this.#responses[key], step.name)); - } catch (error) { - if (!(error instanceof FormRevertedError)) { - throw error; - } - - wasReverted = true; - } - - index += wasReverted ? -1 : 1; - } - - return this.#responses; + return submitFormSteps(this.#steps, this.#responses); } } diff --git a/packages/tui/src/form/builder/submit.ts b/packages/tui/src/form/builder/submit.ts new file mode 100644 index 00000000..cc409381 --- /dev/null +++ b/packages/tui/src/form/builder/submit.ts @@ -0,0 +1,45 @@ +import { shouldIgnoreStepWhenReverting, shouldRunStep } from '#tui/form/builder/conditions'; +import { FormRevertedError, runWithFormRevert } from '#tui/form/builder/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 && shouldIgnoreStepWhenReverting(step, responses)) { + index -= 1; + continue; + } + + wasReverted = false; + + const key = step.name ?? index; + + if (!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 (!(error instanceof FormRevertedError)) { + throw error; + } + + wasReverted = true; + } + + index += wasReverted ? -1 : 1; + } + + return responses; +}; diff --git a/packages/tui/tests/form.test.ts b/packages/tui/tests/form.test.ts index 8ce532c9..5e25d75d 100644 --- a/packages/tui/tests/form.test.ts +++ b/packages/tui/tests/form.test.ts @@ -115,6 +115,24 @@ describe('form builder', () => { expect(responses[1]).toBe('D'); }); + it('reverts past ignored side-effect steps', async () => { + const output = createMemoryOutput(); + + const responses = await withPromptEnvironment( + { + input: createScriptedInput(['A', Key.enter, 'B', Key.ctrlU, Key.backspace, 'C', Key.enter, 'D', Key.enter]), + output, + error: output, + interactive: true, + }, + () => form().text('First').note('Skipping back over this', null, 'note').text('Second').submit(), + ); + + expect(responses[0]).toBe('C'); + expect(responses.note).toBe(true); + expect(responses[2]).toBe('D'); + }); + it('does not revert the first form step', async () => { const output = createMemoryOutput(); From 7cdb89f6940e9bd045c3471e653f664b3c187e89 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 20:11:42 +0800 Subject: [PATCH 147/675] Split data table search state --- packages/tui/src/output/data-table.ts | 43 ++++----------- packages/tui/src/output/data-table/search.ts | 58 ++++++++++++++++++++ packages/tui/tests/table.test.ts | 6 ++ 3 files changed, 76 insertions(+), 31 deletions(-) create mode 100644 packages/tui/src/output/data-table/search.ts diff --git a/packages/tui/src/output/data-table.ts b/packages/tui/src/output/data-table.ts index d8d56437..9a1f0a6f 100644 --- a/packages/tui/src/output/data-table.ts +++ b/packages/tui/src/output/data-table.ts @@ -1,11 +1,11 @@ import { promptEnvironment } from '#tui/environment'; import { Key } from '#tui/key'; import { promptUntilValid, PromptValidationError } from '#tui/prompt'; -import { applyTypedKey } from '#tui/typed-value'; import { dataTableNavigationAction, startsDataTableSearch } from '#tui/output/data-table/keys'; import { moveDataTableSelection } from '#tui/output/data-table/navigation'; import { dataTableRowValue, deriveDataTableHeaders, visibleDataTableRows } from '#tui/output/data-table/rows'; import { renderDataTableFrame } from '#tui/output/data-table/render'; +import { applyDataTableSearchKey, initialDataTableSearchState, startDataTableSearch } from '#tui/output/data-table/search'; import type { DataTablePromptOptions } from '#tui/types'; export const datatable = async (options: DataTablePromptOptions): Promise => { @@ -15,17 +15,16 @@ export const datatable = async (options: DataTablePromptOptions) const environment = promptEnvironment(); let selected = 0; - let mode: 'browse' | 'search' = 'browse'; - let query = { cursor: 0, value: '' }; + let search = initialDataTableSearchState(); - const visibleRows = () => visibleDataTableRows(options, headers, query.value); + const visibleRows = () => visibleDataTableRows(options, headers, search.query.value); const render = (): void => { selected = renderDataTableFrame({ headers, message: options.message, - mode, - query: query.value, + mode: search.mode, + query: search.query.value, rows: visibleRows(), scroll: options.scroll, selected, @@ -66,35 +65,17 @@ export const datatable = async (options: DataTablePromptOptions) return dataTableRowValue(selectedRow.row, selectedRow.index); } - if (mode === 'search') { - if (key === Key.enter) { - mode = 'browse'; - selected = 0; - render(); - continue; - } - - if (key === Key.escape) { - mode = 'browse'; - query = { cursor: 0, value: '' }; - selected = 0; - render(); - continue; - } - - const next = applyTypedKey(query, key); + const nextSearch = applyDataTableSearchKey(search, key); - if (next.value !== query.value || next.cursor !== query.cursor) { - query = { cursor: next.cursor, value: next.value }; - selected = 0; - render(); - continue; - } + if (nextSearch.changed) { + search = nextSearch.state; + selected = 0; + render(); + continue; } if (startsDataTableSearch(key)) { - mode = 'search'; - query = { cursor: 0, value: '' }; + search = startDataTableSearch(); selected = 0; render(); continue; diff --git a/packages/tui/src/output/data-table/search.ts b/packages/tui/src/output/data-table/search.ts new file mode 100644 index 00000000..26753213 --- /dev/null +++ b/packages/tui/src/output/data-table/search.ts @@ -0,0 +1,58 @@ +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 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): { changed: boolean; state: DataTableSearchState } => { + if (state.mode !== 'search') { + return { changed: false, state }; + } + + if (key === Key.enter) { + return { changed: true, state: confirmDataTableSearch(state) }; + } + + if (key === Key.escape) { + return { changed: true, state: clearDataTableSearch() }; + } + + const next = applyTypedKey(state.query, key); + + if (next.value === state.query.value && next.cursor === state.query.cursor) { + return { changed: false, state }; + } + + return { + changed: true, + state: { + mode: 'search', + query: { cursor: next.cursor, value: next.value }, + }, + }; +}; diff --git a/packages/tui/tests/table.test.ts b/packages/tui/tests/table.test.ts index f35d9c0b..ac3606ae 100644 --- a/packages/tui/tests/table.test.ts +++ b/packages/tui/tests/table.test.ts @@ -239,6 +239,12 @@ describe('data table prompt', () => { ); expect(result).toBe('beta'); + + const latestFrame = output.text().split('Pick project\n').at(-1) ?? ''; + + expect(latestFrame).not.toContain('Pick project B'); + expect(latestFrame).toContain('Alpha'); + expect(latestFrame).toContain('Beta'); }); it('cancels data table prompts with the current selected row', async () => { From 4488bcb34a560245bad31279b92f824109463d4e Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 20:17:21 +0800 Subject: [PATCH 148/675] Split prompt environment concerns --- UPSTREAM.md | 2 +- packages/tui/src/environment.ts | 108 +----------------- packages/tui/src/environment/default-input.ts | 45 ++++++++ packages/tui/src/environment/memory-output.ts | 22 ++++ packages/tui/src/environment/runtime.ts | 11 ++ packages/tui/src/environment/scope.ts | 25 ++++ .../tui/src/environment/scripted-input.ts | 14 +++ packages/tui/src/environment/stream-output.ts | 7 ++ packages/tui/tests/environment.test.ts | 73 ++++++++++++ 9 files changed, 201 insertions(+), 106 deletions(-) create mode 100644 packages/tui/src/environment/default-input.ts create mode 100644 packages/tui/src/environment/memory-output.ts create mode 100644 packages/tui/src/environment/runtime.ts create mode 100644 packages/tui/src/environment/scope.ts create mode 100644 packages/tui/src/environment/scripted-input.ts create mode 100644 packages/tui/src/environment/stream-output.ts create mode 100644 packages/tui/tests/environment.test.ts diff --git a/UPSTREAM.md b/UPSTREAM.md index a8fe3dfd..cf6d5e84 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -12,7 +12,7 @@ TypeScript and OpenTUI internals. | Upstream area | TypeScript area | | --- | --- | | `src/helpers.php` | `packages/tui/src/index.ts` | -| Prompt lifecycle and fallback behavior | `packages/tui/src/prompt.ts`, `packages/tui/src/environment.ts` | +| Prompt lifecycle and fallback behavior | `packages/tui/src/prompt.ts`, `packages/tui/src/environment.ts`, `packages/tui/src/environment/*` | | Key constants | `packages/tui/src/key.ts` | | Terminal helpers | `packages/tui/src/terminal.ts` | | Text input | `packages/tui/src/prompts/text.ts`, `packages/tui/src/typed-value.ts` | diff --git a/packages/tui/src/environment.ts b/packages/tui/src/environment.ts index 35148206..4c1e2f95 100644 --- a/packages/tui/src/environment.ts +++ b/packages/tui/src/environment.ts @@ -1,105 +1,3 @@ -import { createInterface } from 'node:readline/promises'; -import { stdin as defaultStdin, stdout as defaultStdout, stderr as defaultStderr } from 'node:process'; -import type { PromptEnvironment, PromptInput, PromptOutput } from '#tui/types'; - -const outputFromStream = (stream: NodeJS.WritableStream): PromptOutput => ({ - write(content: string): void { - stream.write(content); - }, -}); - -const defaultInput: PromptInput = { - async readKey(): Promise { - return new Promise((resolve) => { - const input = defaultStdin; - const wasRaw = input.isRaw; - - const cleanup = (): void => { - input.off('data', onData); - - if (input.isTTY) { - input.setRawMode(wasRaw); - input.pause(); - } - }; - - const onData = (chunk: Buffer): void => { - cleanup(); - resolve(chunk.toString('utf8')); - }; - - input.once('data', onData); - - if (input.isTTY) { - input.setRawMode(true); - input.resume(); - } - }); - }, - async readLine(message: string): Promise { - const readline = createInterface({ - input: defaultStdin, - output: defaultStdout, - }); - - try { - return await readline.question(message); - } finally { - readline.close(); - } - }, -}; - -let currentEnvironment: PromptEnvironment = { - input: defaultInput, - output: outputFromStream(defaultStdout), - error: outputFromStream(defaultStderr), - interactive: Boolean(defaultStdin.isTTY), -}; - -export const promptEnvironment = (): PromptEnvironment => currentEnvironment; - -export const configurePrompts = (environment: Partial): void => { - currentEnvironment = { - ...currentEnvironment, - ...environment, - }; -}; - -export const withPromptEnvironment = async (environment: Partial, callback: () => Promise): Promise => { - const previous = currentEnvironment; - - configurePrompts(environment); - - try { - return await callback(); - } finally { - currentEnvironment = previous; - } -}; - -export const createMemoryOutput = (): PromptOutput & { text(): string } => { - let buffer = ''; - - return { - write(content: string): void { - buffer += content; - }, - text(): string { - return buffer; - }, - }; -}; - -export const createScriptedInput = (lines: string[]): PromptInput => { - const queued = [...lines]; - - return { - async readKey(): Promise { - return queued.shift() ?? null; - }, - async readLine(): Promise { - return queued.shift() ?? ''; - }, - }; -}; +export { configurePrompts, promptEnvironment, withPromptEnvironment } from '#tui/environment/scope'; +export { createMemoryOutput } from '#tui/environment/memory-output'; +export { createScriptedInput } from '#tui/environment/scripted-input'; diff --git a/packages/tui/src/environment/default-input.ts b/packages/tui/src/environment/default-input.ts new file mode 100644 index 00000000..d5f01227 --- /dev/null +++ b/packages/tui/src/environment/default-input.ts @@ -0,0 +1,45 @@ +import { stdin as defaultStdin, stdout as defaultStdout } from 'node:process'; +import { createInterface } from 'node:readline/promises'; +import type { PromptInput } from '#tui/types'; + +export const defaultInput: PromptInput = { + async readKey(): Promise { + return new Promise((resolve) => { + const input = defaultStdin; + const wasRaw = input.isRaw; + + const cleanup = (): void => { + input.off('data', onData); + + if (input.isTTY) { + input.setRawMode(wasRaw); + input.pause(); + } + }; + + const onData = (chunk: Buffer): void => { + cleanup(); + resolve(chunk.toString('utf8')); + }; + + input.once('data', onData); + + if (input.isTTY) { + input.setRawMode(true); + input.resume(); + } + }); + }, + async readLine(message: string): Promise { + const readline = createInterface({ + input: defaultStdin, + output: defaultStdout, + }); + + try { + return await readline.question(message); + } finally { + readline.close(); + } + }, +}; diff --git a/packages/tui/src/environment/memory-output.ts b/packages/tui/src/environment/memory-output.ts new file mode 100644 index 00000000..87303d73 --- /dev/null +++ b/packages/tui/src/environment/memory-output.ts @@ -0,0 +1,22 @@ +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 += content; + }, + text(): string { + return buffer; + }, + }; +}; diff --git a/packages/tui/src/environment/runtime.ts b/packages/tui/src/environment/runtime.ts new file mode 100644 index 00000000..1feafba7 --- /dev/null +++ b/packages/tui/src/environment/runtime.ts @@ -0,0 +1,11 @@ +import { stderr as defaultStderr, stdin as defaultStdin, stdout as defaultStdout } from 'node:process'; +import { defaultInput } from '#tui/environment/default-input'; +import { outputFromStream } from '#tui/environment/stream-output'; +import type { PromptEnvironment } from '#tui/types'; + +export const defaultEnvironment: PromptEnvironment = { + input: defaultInput, + output: outputFromStream(defaultStdout), + error: outputFromStream(defaultStderr), + interactive: Boolean(defaultStdin.isTTY), +}; diff --git a/packages/tui/src/environment/scope.ts b/packages/tui/src/environment/scope.ts new file mode 100644 index 00000000..d18b4521 --- /dev/null +++ b/packages/tui/src/environment/scope.ts @@ -0,0 +1,25 @@ +import { defaultEnvironment } from '#tui/environment/runtime'; +import type { PromptEnvironment } from '#tui/types'; + +let currentEnvironment: PromptEnvironment = defaultEnvironment; + +export const promptEnvironment = (): PromptEnvironment => currentEnvironment; + +export const configurePrompts = (environment: Partial): void => { + currentEnvironment = { + ...currentEnvironment, + ...environment, + }; +}; + +export const withPromptEnvironment = async (environment: Partial, callback: () => Promise): Promise => { + const previous = currentEnvironment; + + configurePrompts(environment); + + try { + return await callback(); + } finally { + currentEnvironment = previous; + } +}; diff --git a/packages/tui/src/environment/scripted-input.ts b/packages/tui/src/environment/scripted-input.ts new file mode 100644 index 00000000..7a451b8c --- /dev/null +++ b/packages/tui/src/environment/scripted-input.ts @@ -0,0 +1,14 @@ +import type { PromptInput } from '#tui/types'; + +export const createScriptedInput = (lines: string[]): PromptInput => { + const queued = [...lines]; + + return { + async readKey(): Promise { + return queued.shift() ?? null; + }, + async readLine(): Promise { + return queued.shift() ?? ''; + }, + }; +}; diff --git a/packages/tui/src/environment/stream-output.ts b/packages/tui/src/environment/stream-output.ts new file mode 100644 index 00000000..a849fb58 --- /dev/null +++ b/packages/tui/src/environment/stream-output.ts @@ -0,0 +1,7 @@ +import type { PromptOutput } from '#tui/types'; + +export const outputFromStream = (stream: NodeJS.WritableStream): PromptOutput => ({ + write(content: string): void { + stream.write(content); + }, +}); diff --git a/packages/tui/tests/environment.test.ts b/packages/tui/tests/environment.test.ts new file mode 100644 index 00000000..6e8b2fdd --- /dev/null +++ b/packages/tui/tests/environment.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; +import { configurePrompts, createMemoryOutput, createScriptedInput, promptEnvironment, withPromptEnvironment } from '#tui/environment'; + +describe('prompt environment', () => { + it('restores the previous environment when a scoped callback fails', async () => { + const output = createMemoryOutput(); + const error = createMemoryOutput(); + const previous = promptEnvironment(); + + configurePrompts({ output, error, interactive: false }); + + const scopedOutput = createMemoryOutput(); + + await expect( + withPromptEnvironment({ output: scopedOutput, interactive: true }, async () => { + expect(promptEnvironment().output).toBe(scopedOutput); + expect(promptEnvironment().error).toBe(error); + expect(promptEnvironment().interactive).toBe(true); + + throw new Error('failed'); + }), + ).rejects.toThrow('failed'); + + expect(promptEnvironment().output).toBe(output); + expect(promptEnvironment().error).toBe(error); + expect(promptEnvironment().interactive).toBe(false); + + configurePrompts(previous); + }); + + it('merges partial prompt configuration without replacing unspecified concerns', () => { + const previous = promptEnvironment(); + const output = createMemoryOutput(); + const error = createMemoryOutput(); + + configurePrompts({ output, error, interactive: false }); + + const nextOutput = createMemoryOutput(); + + configurePrompts({ output: nextOutput }); + + expect(promptEnvironment().output).toBe(nextOutput); + expect(promptEnvironment().error).toBe(error); + expect(promptEnvironment().interactive).toBe(false); + + configurePrompts(previous); + }); + + it('captures and clears memory output', () => { + const output = createMemoryOutput(); + + output.write('first'); + output.write(' second'); + + expect(output.text()).toBe('first second'); + + output.clear(); + + expect(output.text()).toBe(''); + }); + + it('drains scripted input independently for key and line reads', async () => { + const input = createScriptedInput(['a', 'line']); + + await expect(input.readKey?.()).resolves.toBe('a'); + + await expect(input.readLine?.('Question')).resolves.toBe('line'); + + await expect(input.readKey?.()).resolves.toBeNull(); + + await expect(input.readLine?.('Question')).resolves.toBe(''); + }); +}); From 576a9986ea821d73f42c76a169fff8b12d858bfa Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 20:19:55 +0800 Subject: [PATCH 149/675] Split keyboard mapping concerns --- UPSTREAM.md | 2 +- packages/tui/src/key.ts | 127 +----------------------------- packages/tui/src/key/constants.ts | 34 ++++++++ packages/tui/src/key/event.ts | 75 ++++++++++++++++++ packages/tui/src/key/match.ts | 21 +++++ packages/tui/src/key/types.ts | 13 +++ packages/tui/tests/key.test.ts | 8 +- 7 files changed, 155 insertions(+), 125 deletions(-) create mode 100644 packages/tui/src/key/constants.ts create mode 100644 packages/tui/src/key/event.ts create mode 100644 packages/tui/src/key/match.ts create mode 100644 packages/tui/src/key/types.ts diff --git a/UPSTREAM.md b/UPSTREAM.md index cf6d5e84..27ac983a 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -13,7 +13,7 @@ TypeScript and OpenTUI internals. | --- | --- | | `src/helpers.php` | `packages/tui/src/index.ts` | | Prompt lifecycle and fallback behavior | `packages/tui/src/prompt.ts`, `packages/tui/src/environment.ts`, `packages/tui/src/environment/*` | -| Key constants | `packages/tui/src/key.ts` | +| Key constants 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` | | Textarea input | `packages/tui/src/prompts/textarea.ts`, `packages/tui/src/typed-value.ts` | diff --git a/packages/tui/src/key.ts b/packages/tui/src/key.ts index 6fbb56b8..44382ce1 100644 --- a/packages/tui/src/key.ts +++ b/packages/tui/src/key.ts @@ -1,123 +1,4 @@ -export const Key = { - up: '\u001B[A', - shiftUp: '\u001B[1;2A', - pageUp: '\u001B[5~', - down: '\u001B[B', - shiftDown: '\u001B[1;2B', - pageDown: '\u001B[6~', - right: '\u001B[C', - left: '\u001B[D', - upArrow: '\u001BOA', - downArrow: '\u001BOB', - rightArrow: '\u001BOC', - leftArrow: '\u001BOD', - escape: '\u001B', - delete: '\u001B[3~', - backspace: '\u007F', - enter: '\n', - space: ' ', - tab: '\t', - shiftTab: '\u001B[Z', - home: ['\u001B[1~', '\u001BOH', '\u001B[H', '\u001B[7~'], - end: ['\u001B[4~', '\u001BOF', '\u001B[F', '\u001B[8~'], - ctrlC: '\u0003', - ctrlP: '\u0010', - ctrlN: '\u000E', - ctrlF: '\u0006', - ctrlB: '\u0002', - ctrlH: '\u0008', - ctrlA: '\u0001', - ctrlD: '\u0004', - ctrlE: '\u0005', - ctrlU: '\u0015', - optionBackspace: '\u001B\u007F', -} as const; - -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; -}; - -const ctrlKeyMap: Record = { - a: Key.ctrlA, - b: Key.ctrlB, - c: Key.ctrlC, - d: Key.ctrlD, - e: Key.ctrlE, - f: Key.ctrlF, - h: Key.ctrlH, - n: Key.ctrlN, - p: Key.ctrlP, - u: Key.ctrlU, -}; - -const namedKeyMap: Record = { - pagedown: Key.pageDown, - pageup: Key.pageUp, - return: Key.enter, -}; - -export const keyFromEvent = (event: KeyboardEventLike): KeyName | string => { - if (event.ctrl && event.name) { - const key = ctrlKeyMap[event.name.toLowerCase()]; - - if (key !== undefined) { - return key; - } - } - - if (event.meta && event.name === 'backspace') { - return Key.optionBackspace; - } - - if (event.shift && event.name?.toLowerCase() === 'tab') { - return Key.shiftTab; - } - - if (event.name) { - const key = namedKeyMap[event.name.toLowerCase()]; - - if (key !== undefined) { - return key; - } - } - - if (event.name && event.name in Key) { - const key = Key[event.name as keyof typeof Key]; - - return typeof key === 'string' ? key : key[0]; - } - - if (event.sequence === ' ') { - return Key.space; - } - - if (event.sequence === '\r' || event.sequence === '\n') { - return Key.enter; - } - - return event.name ?? event.sequence ?? ''; -}; - -export const oneOf = (keys: KeyValue[], match: string): string | undefined => { - for (const key of keys) { - if (Array.isArray(key)) { - const nested = oneOf([...key], match); - - if (nested !== undefined) { - return nested; - } - } else if (key === match) { - return match; - } - } - - return undefined; -}; +export { Key } from '#tui/key/constants'; +export { keyFromEvent } from '#tui/key/event'; +export { oneOf } from '#tui/key/match'; +export type { KeyboardEventLike, KeyName, KeyValue } from '#tui/key/types'; diff --git a/packages/tui/src/key/constants.ts b/packages/tui/src/key/constants.ts new file mode 100644 index 00000000..d24fa895 --- /dev/null +++ b/packages/tui/src/key/constants.ts @@ -0,0 +1,34 @@ +export const Key = { + up: '\u001B[A', + shiftUp: '\u001B[1;2A', + pageUp: '\u001B[5~', + down: '\u001B[B', + shiftDown: '\u001B[1;2B', + pageDown: '\u001B[6~', + right: '\u001B[C', + left: '\u001B[D', + upArrow: '\u001BOA', + downArrow: '\u001BOB', + rightArrow: '\u001BOC', + leftArrow: '\u001BOD', + escape: '\u001B', + delete: '\u001B[3~', + backspace: '\u007F', + enter: '\n', + space: ' ', + tab: '\t', + shiftTab: '\u001B[Z', + home: ['\u001B[1~', '\u001BOH', '\u001B[H', '\u001B[7~'], + end: ['\u001B[4~', '\u001BOF', '\u001B[F', '\u001B[8~'], + ctrlC: '\u0003', + ctrlP: '\u0010', + ctrlN: '\u000E', + ctrlF: '\u0006', + ctrlB: '\u0002', + ctrlH: '\u0008', + ctrlA: '\u0001', + ctrlD: '\u0004', + ctrlE: '\u0005', + ctrlU: '\u0015', + optionBackspace: '\u001B\u007F', +} as const; diff --git a/packages/tui/src/key/event.ts b/packages/tui/src/key/event.ts new file mode 100644 index 00000000..5bdb94ac --- /dev/null +++ b/packages/tui/src/key/event.ts @@ -0,0 +1,75 @@ +import { Key } from '#tui/key/constants'; +import type { KeyboardEventLike, KeyName, KeyValue } from '#tui/key/types'; + +const ctrlKeyMap: Record = { + a: Key.ctrlA, + b: Key.ctrlB, + c: Key.ctrlC, + d: Key.ctrlD, + e: Key.ctrlE, + f: Key.ctrlF, + h: Key.ctrlH, + n: Key.ctrlN, + p: Key.ctrlP, + u: Key.ctrlU, +}; + +const namedKeyMap: Record = { + pagedown: Key.pageDown, + pageup: Key.pageUp, + return: Key.enter, +}; + +const keyValueFromName = (name: string): KeyValue | undefined => { + if (!(name in Key)) { + return undefined; + } + + return Key[name as keyof typeof Key]; +}; + +const firstKeyValue = (key: KeyValue): string => (typeof key === 'string' ? key : key[0]); + +export const keyFromEvent = (event: KeyboardEventLike): KeyName | string => { + if (event.ctrl && event.name) { + const key = ctrlKeyMap[event.name.toLowerCase()]; + + if (key !== undefined) { + return key; + } + } + + if (event.meta && event.name === 'backspace') { + return Key.optionBackspace; + } + + if (event.shift && event.name?.toLowerCase() === 'tab') { + return Key.shiftTab; + } + + if (event.name) { + const key = namedKeyMap[event.name.toLowerCase()]; + + if (key !== undefined) { + return key; + } + } + + if (event.name) { + const key = keyValueFromName(event.name); + + if (key !== undefined) { + return firstKeyValue(key); + } + } + + if (event.sequence === ' ') { + return Key.space; + } + + if (event.sequence === '\r' || event.sequence === '\n') { + return Key.enter; + } + + return event.name ?? event.sequence ?? ''; +}; diff --git a/packages/tui/src/key/match.ts b/packages/tui/src/key/match.ts new file mode 100644 index 00000000..bb871507 --- /dev/null +++ b/packages/tui/src/key/match.ts @@ -0,0 +1,21 @@ +import type { KeyValue } from '#tui/key/types'; + +export const oneOf = (keys: KeyValue[], match: string): string | undefined => { + for (const key of keys) { + if (Array.isArray(key)) { + const nested = oneOf([...key], match); + + if (nested !== undefined) { + return nested; + } + + continue; + } + + if (key === match) { + return match; + } + } + + return undefined; +}; diff --git a/packages/tui/src/key/types.ts b/packages/tui/src/key/types.ts new file mode 100644 index 00000000..19091507 --- /dev/null +++ b/packages/tui/src/key/types.ts @@ -0,0 +1,13 @@ +import type { Key } from '#tui/key/constants'; + +export type KeyValue = string | readonly string[]; + +export type KeyName = Extract<(typeof Key)[keyof typeof Key], string>; + +export type KeyboardEventLike = { + name?: string; + sequence?: string; + ctrl?: boolean; + meta?: boolean; + shift?: boolean; +}; diff --git a/packages/tui/tests/key.test.ts b/packages/tui/tests/key.test.ts index 93093236..5f2e85b8 100644 --- a/packages/tui/tests/key.test.ts +++ b/packages/tui/tests/key.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { Key, keyFromEvent } from '#tui/index'; +import { Key, keyFromEvent, oneOf } from '#tui/index'; describe('keyboard mapping', () => { it('maps named keys', () => { @@ -30,4 +30,10 @@ describe('keyboard mapping', () => { it('maps shifted tab events', () => { expect(keyFromEvent({ name: 'tab', shift: true })).toBe(Key.shiftTab); }); + + it('matches nested key variants', () => { + expect(oneOf([Key.home, Key.end], Key.home[2])).toBe(Key.home[2]); + expect(oneOf([Key.home, Key.end], Key.end[3])).toBe(Key.end[3]); + expect(oneOf([Key.home, Key.end], Key.enter)).toBeUndefined(); + }); }); From 2cd603974f86318e46c8a6771928f2cc2f13c170 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 20:22:13 +0800 Subject: [PATCH 150/675] Split public type contracts --- UPSTREAM.md | 1 + packages/tui/src/contracts/base.ts | 7 + packages/tui/src/contracts/choices.ts | 12 ++ packages/tui/src/contracts/environment.ts | 15 +++ packages/tui/src/contracts/options.ts | 9 ++ packages/tui/src/contracts/output.ts | 22 ++++ packages/tui/src/contracts/prompts.ts | 66 ++++++++++ packages/tui/src/contracts/status.ts | 3 + packages/tui/src/types.ts | 148 +++------------------- 9 files changed, 151 insertions(+), 132 deletions(-) create mode 100644 packages/tui/src/contracts/base.ts create mode 100644 packages/tui/src/contracts/choices.ts create mode 100644 packages/tui/src/contracts/environment.ts create mode 100644 packages/tui/src/contracts/options.ts create mode 100644 packages/tui/src/contracts/output.ts create mode 100644 packages/tui/src/contracts/prompts.ts create mode 100644 packages/tui/src/contracts/status.ts diff --git a/UPSTREAM.md b/UPSTREAM.md index 27ac983a..8b841f9e 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -32,6 +32,7 @@ TypeScript and OpenTUI internals. | Spinner, progress, task, stream | `packages/tui/src/status/*`, `packages/tui/src/status.ts` | | ANSI, wrapping, truncation, Unicode width | `packages/tui/src/strings.ts` | | OpenTUI adapter | `packages/tui/src/opentui.ts` | +| Type contracts | `packages/tui/src/types.ts`, `packages/tui/src/contracts/*` | ## Remaining Exact-Parity Work diff --git a/packages/tui/src/contracts/base.ts b/packages/tui/src/contracts/base.ts new file mode 100644 index 00000000..3c3cc357 --- /dev/null +++ b/packages/tui/src/contracts/base.ts @@ -0,0 +1,7 @@ +export type MaybePromise = T | Promise; + +export type ValidationResult = string | null | undefined; + +export type Validator = (value: T) => MaybePromise; + +export type PromptValue = string | number | boolean | string[] | number[]; diff --git a/packages/tui/src/contracts/choices.ts b/packages/tui/src/contracts/choices.ts new file mode 100644 index 00000000..14177a17 --- /dev/null +++ b/packages/tui/src/contracts/choices.ts @@ -0,0 +1,12 @@ +export type Choice = { + label: string; + value: T; + hint?: string; + disabled?: boolean | string; +}; + +export type ChoiceInput = Choice | T; + +export type ChoiceOptions = Array> | Record; + +export type PromptInfo = string | ((value: T | null) => string | null | undefined); diff --git a/packages/tui/src/contracts/environment.ts b/packages/tui/src/contracts/environment.ts new file mode 100644 index 00000000..d3d85083 --- /dev/null +++ b/packages/tui/src/contracts/environment.ts @@ -0,0 +1,15 @@ +export type PromptOutput = { + write(content: string): void; +}; + +export type PromptInput = { + readKey?(): Promise; + readLine?(message: string): Promise; +}; + +export type PromptEnvironment = { + input: PromptInput; + output: PromptOutput; + error: PromptOutput; + interactive: boolean; +}; diff --git a/packages/tui/src/contracts/options.ts b/packages/tui/src/contracts/options.ts new file mode 100644 index 00000000..2263fdaf --- /dev/null +++ b/packages/tui/src/contracts/options.ts @@ -0,0 +1,9 @@ +import type { Validator } from '#tui/contracts/base'; + +export type BasePromptOptions = { + message: string; + default?: T; + required?: boolean | string; + hint?: string; + validate?: Validator; +}; diff --git a/packages/tui/src/contracts/output.ts b/packages/tui/src/contracts/output.ts new file mode 100644 index 00000000..0dca01dd --- /dev/null +++ b/packages/tui/src/contracts/output.ts @@ -0,0 +1,22 @@ +import type { BasePromptOptions } from '#tui/contracts/options'; + +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; +}; + +export type DataTableObjectRow = { + cells: Record; + value?: T; +}; + +export type DataTableRow = Array | Record | DataTableObjectRow; diff --git a/packages/tui/src/contracts/prompts.ts b/packages/tui/src/contracts/prompts.ts new file mode 100644 index 00000000..3f74f228 --- /dev/null +++ b/packages/tui/src/contracts/prompts.ts @@ -0,0 +1,66 @@ +import type { MaybePromise } from '#tui/contracts/base'; +import type { ChoiceOptions, PromptInfo } from '#tui/contracts/choices'; +import type { BasePromptOptions } from '#tui/contracts/options'; + +export type TextPromptOptions = BasePromptOptions & { + label?: string; + options?: string[] | ((query: string) => MaybePromise); + placeholder?: string; + transform?: (value: string) => MaybePromise; +}; + +export type TextareaPromptOptions = TextPromptOptions & { + rows?: number; +}; + +export type NumberPromptOptions = BasePromptOptions & { + label?: string; + placeholder?: string; + min?: number; + max?: number; + integer?: boolean; + step?: number; + transform?: (value: number | string) => MaybePromise; +}; + +export type ConfirmPromptOptions = BasePromptOptions & { + label?: string; + yes?: string; + no?: string; + transform?: (value: boolean) => MaybePromise; +}; + +export type SelectPromptOptions = BasePromptOptions & { + label?: string; + options: ChoiceOptions; + scroll?: number; + info?: PromptInfo; + transform?: (value: T) => MaybePromise; +}; + +export type MultiSelectPromptOptions = BasePromptOptions & { + label?: string; + options: ChoiceOptions; + scroll?: number; + info?: PromptInfo; + transform?: (value: T[]) => MaybePromise; +}; + +export type SearchPromptOptions = Omit, 'required'> & { + label?: string; + options: ChoiceOptions | ((query: string) => MaybePromise>); + placeholder?: string; + required?: true | string; + scroll?: number; + info?: PromptInfo; + transform?: (value: T) => MaybePromise; +}; + +export type MultiSearchPromptOptions = BasePromptOptions & { + label?: string; + options: ChoiceOptions | ((query: string) => MaybePromise>); + placeholder?: string; + scroll?: number; + info?: PromptInfo; + transform?: (value: T[]) => MaybePromise; +}; diff --git a/packages/tui/src/contracts/status.ts b/packages/tui/src/contracts/status.ts new file mode 100644 index 00000000..f8d39773 --- /dev/null +++ b/packages/tui/src/contracts/status.ts @@ -0,0 +1,3 @@ +export type StatusOptions = { + message: string; +}; diff --git a/packages/tui/src/types.ts b/packages/tui/src/types.ts index 948a015a..ce40ef0b 100644 --- a/packages/tui/src/types.ts +++ b/packages/tui/src/types.ts @@ -1,132 +1,16 @@ -export type MaybePromise = T | Promise; - -export type ValidationResult = string | null | undefined; - -export type Validator = (value: T) => MaybePromise; - -export type PromptValue = string | number | boolean | string[] | number[]; - -export type PromptOutput = { - write(content: string): void; -}; - -export type PromptInput = { - readKey?(): Promise; - readLine?(message: string): Promise; -}; - -export type PromptEnvironment = { - input: PromptInput; - output: PromptOutput; - error: PromptOutput; - interactive: boolean; -}; - -export type BasePromptOptions = { - message: string; - default?: T; - required?: boolean | string; - hint?: string; - validate?: Validator; -}; - -export type Choice = { - label: string; - value: T; - hint?: string; - disabled?: boolean | string; -}; - -export type ChoiceInput = Choice | T; - -export type ChoiceOptions = Array> | Record; - -export type PromptInfo = string | ((value: T | null) => string | null | undefined); - -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; -}; - -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; -}; - -export type DataTableObjectRow = { - cells: Record; - value?: T; -}; - -export type DataTableRow = Array | Record | DataTableObjectRow; - -export type StatusOptions = { - message: string; -}; +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'; From 17af2f995d17592b284b3beef9369806c994ca32 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 20:24:22 +0800 Subject: [PATCH 151/675] Split data table read loop --- packages/tui/src/output/data-table.ts | 101 +-------------------- packages/tui/src/output/data-table/read.ts | 94 +++++++++++++++++++ packages/tui/tests/table.test.ts | 24 +++++ 3 files changed, 122 insertions(+), 97 deletions(-) create mode 100644 packages/tui/src/output/data-table/read.ts diff --git a/packages/tui/src/output/data-table.ts b/packages/tui/src/output/data-table.ts index 9a1f0a6f..b3c5d442 100644 --- a/packages/tui/src/output/data-table.ts +++ b/packages/tui/src/output/data-table.ts @@ -1,103 +1,10 @@ -import { promptEnvironment } from '#tui/environment'; -import { Key } from '#tui/key'; -import { promptUntilValid, PromptValidationError } from '#tui/prompt'; -import { dataTableNavigationAction, startsDataTableSearch } from '#tui/output/data-table/keys'; -import { moveDataTableSelection } from '#tui/output/data-table/navigation'; -import { dataTableRowValue, deriveDataTableHeaders, visibleDataTableRows } from '#tui/output/data-table/rows'; -import { renderDataTableFrame } from '#tui/output/data-table/render'; -import { applyDataTableSearchKey, initialDataTableSearchState, startDataTableSearch } from '#tui/output/data-table/search'; +import { promptUntilValid } from '#tui/prompt'; +import { readDataTableSelection } from '#tui/output/data-table/read'; +import { deriveDataTableHeaders } from '#tui/output/data-table/rows'; import type { DataTablePromptOptions } from '#tui/types'; export const datatable = async (options: DataTablePromptOptions): Promise => { const headers = options.headers ?? deriveDataTableHeaders(options.rows); - return promptUntilValid(options, async () => { - const environment = promptEnvironment(); - - let selected = 0; - let search = initialDataTableSearchState(); - - const visibleRows = () => visibleDataTableRows(options, headers, search.query.value); - - const render = (): void => { - selected = renderDataTableFrame({ - headers, - message: options.message, - mode: search.mode, - query: search.query.value, - rows: visibleRows(), - scroll: options.scroll, - selected, - }); - }; - - if (!environment.input.readKey) { - const rows = visibleRows(); - const selectedRow = rows[0]; - - if (!selectedRow) { - throw new PromptValidationError('Please select a valid row.'); - } - - return dataTableRowValue(selectedRow.row, selectedRow.index); - } - - render(); - - while (true) { - const key = await environment.input.readKey(); - - if (key === null) { - throw new PromptValidationError('Please select a valid row.'); - } - - const rows = visibleRows(); - - if (key === Key.ctrlC) { - const selectedRow = rows[selected]; - - environment.error.write('Cancelled.\n'); - - if (!selectedRow) { - throw new PromptValidationError('Please select a valid row.'); - } - - return dataTableRowValue(selectedRow.row, selectedRow.index); - } - - const nextSearch = applyDataTableSearchKey(search, key); - - if (nextSearch.changed) { - search = nextSearch.state; - selected = 0; - render(); - continue; - } - - if (startsDataTableSearch(key)) { - search = startDataTableSearch(); - selected = 0; - render(); - continue; - } - - const action = dataTableNavigationAction(key); - - if (action !== null) { - selected = moveDataTableSelection(action, selected, rows.length, options.scroll); - render(); - continue; - } - - if (key === Key.enter) { - const selectedRow = rows[selected]; - - if (!selectedRow) { - throw new PromptValidationError('Please select a valid row.'); - } - - return dataTableRowValue(selectedRow.row, selectedRow.index); - } - } - }); + return promptUntilValid(options, async () => readDataTableSelection(options, headers)); }; diff --git a/packages/tui/src/output/data-table/read.ts b/packages/tui/src/output/data-table/read.ts new file mode 100644 index 00000000..1360d8fd --- /dev/null +++ b/packages/tui/src/output/data-table/read.ts @@ -0,0 +1,94 @@ +import { promptEnvironment } from '#tui/environment'; +import { Key } from '#tui/key'; +import { PromptValidationError } from '#tui/prompt'; +import { dataTableNavigationAction, startsDataTableSearch } from '#tui/output/data-table/keys'; +import { moveDataTableSelection } from '#tui/output/data-table/navigation'; +import { renderDataTableFrame } from '#tui/output/data-table/render'; +import { dataTableRowValue, visibleDataTableRows } from '#tui/output/data-table/rows'; +import { applyDataTableSearchKey, initialDataTableSearchState, startDataTableSearch } from '#tui/output/data-table/search'; +import type { DataTableSearchState } from '#tui/output/data-table/search'; +import type { VisibleDataTableRow } from '#tui/output/data-table/types'; +import type { DataTablePromptOptions } from '#tui/types'; + +const invalidRow = (): PromptValidationError => new PromptValidationError('Please select a valid row.'); + +const selectedDataTableValue = (rows: Array>, selected: number): T | number => { + const selectedRow = rows[selected]; + + if (!selectedRow) { + throw invalidRow(); + } + + return dataTableRowValue(selectedRow.row, selectedRow.index); +}; + +export const readDataTableSelection = async (options: DataTablePromptOptions, headers: string[]): Promise => { + const environment = promptEnvironment(); + + let selected = 0; + let search: DataTableSearchState = initialDataTableSearchState(); + + const visibleRows = () => visibleDataTableRows(options, headers, search.query.value); + + const render = (): void => { + selected = renderDataTableFrame({ + headers, + message: options.message, + mode: search.mode, + query: search.query.value, + rows: visibleRows(), + scroll: options.scroll, + selected, + }); + }; + + if (!environment.input.readKey) { + return selectedDataTableValue(visibleRows(), selected); + } + + render(); + + while (true) { + const key = await environment.input.readKey(); + + if (key === null) { + throw invalidRow(); + } + + const rows = visibleRows(); + + if (key === Key.ctrlC) { + environment.error.write('Cancelled.\n'); + + return selectedDataTableValue(rows, selected); + } + + const nextSearch = applyDataTableSearchKey(search, key); + + if (nextSearch.changed) { + search = nextSearch.state; + selected = 0; + render(); + continue; + } + + if (startsDataTableSearch(key)) { + search = startDataTableSearch(); + selected = 0; + render(); + continue; + } + + const action = dataTableNavigationAction(key); + + if (action !== null) { + selected = moveDataTableSelection(action, selected, rows.length, options.scroll); + render(); + continue; + } + + if (key === Key.enter) { + return selectedDataTableValue(rows, selected); + } + } +}; diff --git a/packages/tui/tests/table.test.ts b/packages/tui/tests/table.test.ts index ac3606ae..0a63aa88 100644 --- a/packages/tui/tests/table.test.ts +++ b/packages/tui/tests/table.test.ts @@ -364,6 +364,30 @@ describe('data table prompt', () => { expect(result).toBe(1); }); + it('uses the first visible row without keyboard input support', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: {}, + output, + error: output, + interactive: true, + }, + () => + datatable({ + message: 'Pick row', + headers: ['Name'], + rows: [ + { Name: 'First', value: 'first' }, + { Name: 'Second', value: 'second' }, + ], + }), + ); + + expect(result).toBe('first'); + }); + it('renders a scrolling data table window around the selected row', async () => { const output = createMemoryOutput(); From 072c5116fb26dd5efba2622d35e67c25c74cbeae Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 20:27:45 +0800 Subject: [PATCH 152/675] Split prompt lifecycle concerns --- README.md | 6 +- UPSTREAM.md | 50 +++++++------- packages/tui/src/prompt.ts | 94 ++------------------------- packages/tui/src/prompt/ask.ts | 13 ++++ packages/tui/src/prompt/error.ts | 6 ++ packages/tui/src/prompt/lifecycle.ts | 54 +++++++++++++++ packages/tui/src/prompt/validation.ts | 17 +++++ packages/tui/tests/prompt.test.ts | 61 ++++++++++++++++- 8 files changed, 182 insertions(+), 119 deletions(-) create mode 100644 packages/tui/src/prompt/ask.ts create mode 100644 packages/tui/src/prompt/error.ts create mode 100644 packages/tui/src/prompt/lifecycle.ts create mode 100644 packages/tui/src/prompt/validation.ts diff --git a/README.md b/README.md index e2cbdfab..9b55ab51 100644 --- a/README.md +++ b/README.md @@ -2,14 +2,14 @@ An idiomatic TypeScript/OpenTUI prompt toolkit. -This repository targets behavioral parity with the upstream prompt toolkit -release `v0.3.18` while using TypeScript-first modules and package boundaries. +This repository targets behavioral parity with reference prompt toolkit release +`v0.3.18` while using TypeScript-first modules and package boundaries. ## Status The port is being delivered phase by phase on one draft PR. Current code includes working key-driven text and choice prompts, output helpers, terminal utilities, -OpenTUI adapter wiring, and local parity tests. Full upstream feature-test parity +OpenTUI adapter wiring, and local parity tests. Full reference feature-test parity is tracked in `UPSTREAM.md`. ## Development diff --git a/UPSTREAM.md b/UPSTREAM.md index 8b841f9e..240f277c 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -1,17 +1,17 @@ -# Upstream Parity Map +# Reference Parity Map -Target upstream release: `v0.3.18` +Target reference release: `v0.3.18` Tree: `a19af51bb144bf87f08397921fa619f85c7d4e72` -This port keeps upstream behavior as the source of truth while using idiomatic +This port keeps reference behavior as the source of truth while using idiomatic TypeScript and OpenTUI internals. ## Implemented In Phase 1 -| Upstream area | TypeScript area | +| Reference area | TypeScript area | | --- | --- | -| `src/helpers.php` | `packages/tui/src/index.ts` | +| 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/*` | | Key constants and event mapping | `packages/tui/src/key.ts`, `packages/tui/src/key/*` | | Terminal helpers | `packages/tui/src/terminal.ts` | @@ -36,29 +36,29 @@ TypeScript and OpenTUI internals. ## Remaining Exact-Parity Work -The following upstream files still need full line-by-line behavioral mapping in +The following reference areas still need full line-by-line behavioral mapping in later phases: -- `src/AutoCompletePrompt.php` advanced ghost-text rendering -- `src/FormBuilder.php` -- `src/MultiSearchPrompt.php` full rendering and selected-result summary parity -- `src/MultiSelectPrompt.php` -- `src/NotifyPrompt.php` -- `src/Progress.php` exact box drawing and signal handling parity -- `src/Prompt.php` -- `src/SearchPrompt.php` full rendering and highlighted result label parity -- `src/Spinner.php` -- `src/Stream.php` -- `src/Task.php` -- `src/Terminal.php` -- `src/TextareaPrompt.php` -- `src/Themes/Default/*` -- `tests/Feature/*` +- Autocomplete advanced ghost-text rendering +- Form builder +- Multi-search full rendering and selected-result summary parity +- Multi-select +- Notifications +- Progress exact box drawing and signal handling parity +- Prompt lifecycle +- Search full rendering and highlighted result label parity +- Spinner +- Stream +- Task +- Terminal +- Textarea +- Default theme +- Feature parity tests ## Documented Deviations -- Symfony Console output classes are represented by a TypeScript `PromptEnvironment`. -- Composer runtime checks are not applicable to an npm package. -- PHP validator integration is represented by typed synchronous or asynchronous +- Console output classes are represented by a TypeScript `PromptEnvironment`. +- Package-manager runtime checks are not applicable to this npm package. +- External validator integration is represented by typed synchronous or asynchronous validator callbacks. -- `pcntl` spinner behavior is represented by TypeScript timers. +- Process-signal spinner behavior is represented by TypeScript timers. diff --git a/packages/tui/src/prompt.ts b/packages/tui/src/prompt.ts index 665e9d86..d0cca60a 100644 --- a/packages/tui/src/prompt.ts +++ b/packages/tui/src/prompt.ts @@ -1,90 +1,4 @@ -import { promptEnvironment } from '#tui/environment'; -import { renderError, renderQuestion } from '#tui/theme'; -import { requiredMessage } from '#tui/validators/required'; -import { parseValidationResult } from '#tui/validators/result'; -import type { BasePromptOptions, Validator } from '#tui/types'; - -export class PromptValidationError extends Error { - constructor(message: string) { - super(message); - this.name = 'PromptValidationError'; - } -} - -export const validationMessage = async (value: T, validator?: Validator): Promise => { - const result = parseValidationResult(await validator?.(value)); - - if (result !== undefined && result !== null && result.length > 0) { - return result; - } - - return undefined; -}; - -export const ensureRequired = (value: T, required?: boolean | string): string | undefined => { - return requiredMessage(value, required); -}; - -export const promptUntilValid = async (options: BasePromptOptions, read: (attempt: number) => Promise): Promise => { - const environment = promptEnvironment(); - - if (!environment.interactive) { - const value = options.default as T; - const required = ensureRequired(value, options.required); - - const validation = required ?? (await validationMessage(value, options.validate)); - - if (validation) { - throw new PromptValidationError(validation); - } - - return value; - } - - let attempt = 0; - - while (true) { - let value: T; - - try { - value = await read(attempt); - } catch (error) { - if (error instanceof PromptValidationError) { - if (!environment.interactive) { - throw error; - } - - environment.error.write(renderError(error.message)); - attempt += 1; - continue; - } - - throw error; - } - - const required = ensureRequired(value, options.required); - - const validation = required ?? (await validationMessage(value, options.validate)); - - if (!validation) { - return value; - } - - if (!environment.interactive) { - throw new PromptValidationError(validation); - } - - environment.error.write(renderError(validation)); - attempt += 1; - } -}; - -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)); -}; +export { ask } from '#tui/prompt/ask'; +export { PromptValidationError } from '#tui/prompt/error'; +export { promptUntilValid } from '#tui/prompt/lifecycle'; +export { ensureRequired, validationMessage } from '#tui/prompt/validation'; diff --git a/packages/tui/src/prompt/ask.ts b/packages/tui/src/prompt/ask.ts new file mode 100644 index 00000000..8cfd2690 --- /dev/null +++ b/packages/tui/src/prompt/ask.ts @@ -0,0 +1,13 @@ +import { promptEnvironment } from '#tui/environment'; +import { PromptValidationError } from '#tui/prompt/error'; +import { renderQuestion } from '#tui/theme'; + +export const ask = async (message: string, hint?: string): Promise => { + const environment = promptEnvironment(); + + if (!environment.input.readLine) { + throw new PromptValidationError('The configured prompt input cannot read lines.'); + } + + return environment.input.readLine(renderQuestion(message, hint)); +}; diff --git a/packages/tui/src/prompt/error.ts b/packages/tui/src/prompt/error.ts new file mode 100644 index 00000000..e20f555e --- /dev/null +++ b/packages/tui/src/prompt/error.ts @@ -0,0 +1,6 @@ +export class PromptValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'PromptValidationError'; + } +} diff --git a/packages/tui/src/prompt/lifecycle.ts b/packages/tui/src/prompt/lifecycle.ts new file mode 100644 index 00000000..1524b1b1 --- /dev/null +++ b/packages/tui/src/prompt/lifecycle.ts @@ -0,0 +1,54 @@ +import { promptEnvironment } from '#tui/environment'; +import { PromptValidationError } from '#tui/prompt/error'; +import { ensureRequired, validationMessage } from '#tui/prompt/validation'; +import { renderError } from '#tui/theme'; +import type { BasePromptOptions } from '#tui/types'; + +type PromptReader = (attempt: number) => Promise; + +const validatedPromptValue = async (options: BasePromptOptions, value: T): Promise => { + return ensureRequired(value, options.required) ?? (await validationMessage(value, options.validate)); +}; + +export const promptUntilValid = async (options: BasePromptOptions, read: PromptReader): Promise => { + const environment = promptEnvironment(); + + if (!environment.interactive) { + const value = options.default as T; + + const validation = await validatedPromptValue(options, value); + + if (validation) { + throw new PromptValidationError(validation); + } + + return value; + } + + let attempt = 0; + + while (true) { + let value: T; + + try { + value = await read(attempt); + } catch (error) { + if (error instanceof PromptValidationError) { + environment.error.write(renderError(error.message)); + attempt += 1; + continue; + } + + throw error; + } + + const validation = await validatedPromptValue(options, value); + + if (!validation) { + return value; + } + + environment.error.write(renderError(validation)); + attempt += 1; + } +}; diff --git a/packages/tui/src/prompt/validation.ts b/packages/tui/src/prompt/validation.ts new file mode 100644 index 00000000..601d46fa --- /dev/null +++ b/packages/tui/src/prompt/validation.ts @@ -0,0 +1,17 @@ +import { requiredMessage } from '#tui/validators/required'; +import { parseValidationResult } from '#tui/validators/result'; +import type { Validator } from '#tui/types'; + +export const validationMessage = async (value: T, validator?: Validator): Promise => { + const result = parseValidationResult(await validator?.(value)); + + if (result !== undefined && result !== null && result.length > 0) { + return result; + } + + return undefined; +}; + +export const ensureRequired = (value: T, required?: boolean | string): string | undefined => { + return requiredMessage(value, required); +}; diff --git a/packages/tui/tests/prompt.test.ts b/packages/tui/tests/prompt.test.ts index be0cfaae..2e0da41f 100644 --- a/packages/tui/tests/prompt.test.ts +++ b/packages/tui/tests/prompt.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { ensureRequired, validationMessage } from '#tui/prompt'; +import { createMemoryOutput, withPromptEnvironment } from '#tui/environment'; +import { ask, ensureRequired, promptUntilValid, PromptValidationError, validationMessage } from '#tui/prompt'; describe('prompt validation', () => { it('uses the default required message for empty values', () => { @@ -30,4 +31,62 @@ describe('prompt validation', () => { it('rejects invalid validator results', async () => { await expect(validationMessage('value', () => false as never)).rejects.toThrow('The validator must return a string or null.'); }); + + it('retries when a prompt reader throws a validation error', async () => { + const output = createMemoryOutput(); + const attempts: number[] = []; + + const result = await withPromptEnvironment( + { + output, + error: output, + interactive: true, + }, + () => + promptUntilValid({ message: 'Name' }, async (attempt) => { + attempts.push(attempt); + + if (attempt === 0) { + throw new PromptValidationError('Try again.'); + } + + return 'Ada'; + }), + ); + + expect(result).toBe('Ada'); + expect(attempts).toEqual([0, 1]); + expect(output.text()).toContain('Try again.'); + }); + + it('retries required validation failures in interactive mode', async () => { + const output = createMemoryOutput(); + const values = ['', 'Ada']; + + const result = await withPromptEnvironment( + { + output, + error: output, + interactive: true, + }, + () => promptUntilValid({ message: 'Name', required: true }, async () => values.shift() ?? ''), + ); + + expect(result).toBe('Ada'); + expect(output.text()).toContain('Required.'); + }); + + it('rejects missing line input support', async () => { + await withPromptEnvironment( + { + input: {}, + output: createMemoryOutput(), + error: createMemoryOutput(), + interactive: true, + }, + async () => { + await expect(ask('Name')).rejects.toThrow('The configured prompt input cannot read lines.'); + }, + ); + }); }); From 68cfb879cc0f7dc073b773a8141f12ed6c330325 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 20:30:28 +0800 Subject: [PATCH 153/675] Split single search result handling --- .../tui/src/prompts/search/read-single.ts | 21 +++------------ .../src/prompts/search/read-single/result.ts | 26 +++++++++++++++++++ packages/tui/tests/search.test.ts | 25 ++++++++++++++++++ 3 files changed, 55 insertions(+), 17 deletions(-) create mode 100644 packages/tui/src/prompts/search/read-single/result.ts diff --git a/packages/tui/src/prompts/search/read-single.ts b/packages/tui/src/prompts/search/read-single.ts index e56d85b4..751cb679 100644 --- a/packages/tui/src/prompts/search/read-single.ts +++ b/packages/tui/src/prompts/search/read-single.ts @@ -1,20 +1,17 @@ import { promptEnvironment } from '#tui/environment'; import { Key } from '#tui/key'; -import { ask } from '#tui/prompt'; import { applyTypedKey } from '#tui/typed-value'; import { resolveSearchChoices } from '#tui/prompts/search/choices'; import { clearsSearchHighlight, moveSearchHighlight, searchNavigationAction } from '#tui/prompts/search/keys'; -import { resolveLineSearchChoice } from '#tui/prompts/search/line-mode'; import { renderSearchChoices } from '#tui/prompts/search/render'; +import { cancelledSearchValue, lineSearchValue, selectedSearchValue } from '#tui/prompts/search/read-single/result'; import type { SearchPromptOptions } from '#tui/types'; export const readSearchChoice = async (options: SearchPromptOptions, attempt = 0): Promise => { const environment = promptEnvironment(); if (!environment.input.readKey) { - const query = (await ask(options.message, options.hint)).trim(); - - return resolveLineSearchChoice(options, query); + return lineSearchValue(options); } let state = { cursor: 0, value: '' }; @@ -33,15 +30,7 @@ export const readSearchChoice = async (options: SearchPromptOptions, attem } if (key === Key.ctrlC) { - environment.error.write('Cancelled.\n'); - - if (highlighted !== null) { - const choice = choices[highlighted]; - - return choice?.disabled ? options.default : (choice?.value ?? options.default); - } - - return options.default; + return cancelledSearchValue(choices, highlighted, options.default); } const action = searchNavigationAction(key, { controlNavigation: true, lineControls: true }); @@ -62,9 +51,7 @@ export const readSearchChoice = async (options: SearchPromptOptions, attem if (key === Key.enter) { if (highlighted !== null) { - const choice = choices[highlighted]; - - return choice?.disabled ? undefined : choice?.value; + return selectedSearchValue(choices, highlighted); } choices = await resolveSearchChoices(options.options, state.value); diff --git a/packages/tui/src/prompts/search/read-single/result.ts b/packages/tui/src/prompts/search/read-single/result.ts new file mode 100644 index 00000000..17b586cf --- /dev/null +++ b/packages/tui/src/prompts/search/read-single/result.ts @@ -0,0 +1,26 @@ +import { promptEnvironment } from '#tui/environment'; +import { ask } from '#tui/prompt'; +import { resolveLineSearchChoice } from '#tui/prompts/search/line-mode'; +import type { Choice, SearchPromptOptions } from '#tui/types'; + +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 cancelledSearchValue = (choices: Array>, highlighted: number | null, fallback?: T): T | undefined => { + promptEnvironment().error.write('Cancelled.\n'); + + return selectedSearchValue(choices, highlighted) ?? fallback; +}; diff --git a/packages/tui/tests/search.test.ts b/packages/tui/tests/search.test.ts index c025d86d..fe8d9769 100644 --- a/packages/tui/tests/search.test.ts +++ b/packages/tui/tests/search.test.ts @@ -232,6 +232,31 @@ describe('search prompt', () => { expect(output.text()).toContain('Cancelled.'); }); + it('uses the default when cancelling a disabled highlighted search result', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.ctrlC]), + output, + error: output, + interactive: true, + }, + () => + search({ + message: 'Favorite color?', + options: [ + { label: 'Red', value: 'red', disabled: true }, + { label: 'Green', value: 'green' }, + ], + default: 'green', + }), + ); + + expect(result).toBe('green'); + expect(output.text()).toContain('Cancelled.'); + }); + it('rejects optional single search prompts', async () => { const options = { message: 'Favorite color?', From f778a7cbc5177ae42ef93e6c56cd30371518ca61 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 20:32:45 +0800 Subject: [PATCH 154/675] Split multi search result handling --- packages/tui/src/prompts/search/read-multi.ts | 19 +++++--------- .../src/prompts/search/read-multi/result.ts | 25 +++++++++++++++++++ packages/tui/tests/search.test.ts | 22 ++++++++++++++++ 3 files changed, 53 insertions(+), 13 deletions(-) create mode 100644 packages/tui/src/prompts/search/read-multi/result.ts diff --git a/packages/tui/src/prompts/search/read-multi.ts b/packages/tui/src/prompts/search/read-multi.ts index 145830b0..60f386cf 100644 --- a/packages/tui/src/prompts/search/read-multi.ts +++ b/packages/tui/src/prompts/search/read-multi.ts @@ -1,21 +1,18 @@ import { promptEnvironment } from '#tui/environment'; import { Key } from '#tui/key'; -import { ask } from '#tui/prompt'; import { applyTypedKey } from '#tui/typed-value'; import { resolveSearchChoices } from '#tui/prompts/search/choices'; import { moveSearchHighlight, searchNavigationAction } from '#tui/prompts/search/keys'; -import { resolveLineMultiSearchChoices } from '#tui/prompts/search/line-mode'; import { renderSearchChoices } from '#tui/prompts/search/render'; -import { createInitialSearchSelection, displayedSearchChoices, markedSearchChoiceIndexes, toggleSearchChoice, toggleSearchChoices } from '#tui/prompts/search/selection'; +import { lineMultiSearchValues, selectedSearchValues, toggleHighlightedSearchChoice } from '#tui/prompts/search/read-multi/result'; +import { createInitialSearchSelection, displayedSearchChoices, markedSearchChoiceIndexes, toggleSearchChoices } from '#tui/prompts/search/selection'; import type { MultiSearchPromptOptions } from '#tui/types'; export const readMultiSearchChoices = async (options: MultiSearchPromptOptions): Promise => { const environment = promptEnvironment(); if (!environment.input.readKey) { - const query = (await ask(options.message, options.hint)).trim(); - - return resolveLineMultiSearchChoices(options, query); + return lineMultiSearchValues(options); } let state = { cursor: 0, value: '' }; @@ -40,13 +37,13 @@ export const readMultiSearchChoices = async (options: MultiSearchPromptOption const key = await environment.input.readKey(); if (key === null || key === Key.enter) { - return [...selected.keys()]; + return selectedSearchValues(selected); } if (key === Key.ctrlC) { environment.error.write('Cancelled.\n'); - return [...selected.keys()]; + return selectedSearchValues(selected); } const action = searchNavigationAction(key); @@ -73,11 +70,7 @@ export const readMultiSearchChoices = async (options: MultiSearchPromptOption } if (key === Key.space && highlighted !== null) { - const choice = displayedChoices()[highlighted]; - - if (choice && !choice.disabled) { - toggleSearchChoice(selected, choice); - } + toggleHighlightedSearchChoice(selected, displayedChoices(), highlighted); render(); continue; diff --git a/packages/tui/src/prompts/search/read-multi/result.ts b/packages/tui/src/prompts/search/read-multi/result.ts new file mode 100644 index 00000000..603e76b4 --- /dev/null +++ b/packages/tui/src/prompts/search/read-multi/result.ts @@ -0,0 +1,25 @@ +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 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/tests/search.test.ts b/packages/tui/tests/search.test.ts index fe8d9769..dbf63b5b 100644 --- a/packages/tui/tests/search.test.ts +++ b/packages/tui/tests/search.test.ts @@ -414,6 +414,28 @@ describe('multisearch prompt', () => { expect(output.text()).toContain('Selected: Green'); }); + it('returns selected defaults when multisearch input is exhausted', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([]), + output, + error: output, + interactive: true, + }, + () => + multisearch({ + message: 'Favorite colors?', + options: colors, + default: ['green'], + }), + ); + + expect(result).toEqual(['green']); + expect(output.text()).toContain('Selected: Green'); + }); + it('returns an empty array for non-interactive multisearch prompts without defaults', async () => { const output = createMemoryOutput(); From acb0b5d228f83786304814779db68d7e19dc4d29 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 20:35:08 +0800 Subject: [PATCH 155/675] Split form prompt method contracts --- .../tui/src/form/builder/prompts/basic.ts | 4 +- .../tui/src/form/builder/prompts/choices.ts | 4 +- .../form/builder/prompts/contracts/basic.ts | 52 ++++++++ .../form/builder/prompts/contracts/choices.ts | 68 ++++++++++ .../form/builder/prompts/contracts/index.ts | 4 + .../tui/src/form/builder/prompts/types.ts | 117 +----------------- 6 files changed, 131 insertions(+), 118 deletions(-) create mode 100644 packages/tui/src/form/builder/prompts/contracts/basic.ts create mode 100644 packages/tui/src/form/builder/prompts/contracts/choices.ts create mode 100644 packages/tui/src/form/builder/prompts/contracts/index.ts diff --git a/packages/tui/src/form/builder/prompts/basic.ts b/packages/tui/src/form/builder/prompts/basic.ts index 690b8031..4e1c741a 100644 --- a/packages/tui/src/form/builder/prompts/basic.ts +++ b/packages/tui/src/form/builder/prompts/basic.ts @@ -1,9 +1,9 @@ import { number, password, text, textarea } from '#tui/prompts/basic'; import { previousNumber, previousString } from '#tui/form/builder/previous'; import type { FormBuilder } from '#tui/form/builder/index'; -import type { PromptBuilderMethods } from '#tui/form/builder/prompts/types'; +import type { BasicPromptBuilderMethods } from '#tui/form/builder/prompts/types'; -export const basicPromptBuilderMethods: Pick & ThisType = { +export const basicPromptBuilderMethods: BasicPromptBuilderMethods & ThisType = { number(label, placeholder = '', defaultValue = 0, required = false, validate = undefined, hint = '', min, max, step, name) { return this.add((_, previous) => number(label, placeholder, previousNumber(previous, defaultValue), required, validate, hint, min, max, step), name); }, diff --git a/packages/tui/src/form/builder/prompts/choices.ts b/packages/tui/src/form/builder/prompts/choices.ts index 633ec1b6..d8539c83 100644 --- a/packages/tui/src/form/builder/prompts/choices.ts +++ b/packages/tui/src/form/builder/prompts/choices.ts @@ -1,10 +1,10 @@ import { autocomplete, confirm, multiselect, multisearch, pause, search, select, suggest } from '#tui/prompts/choices'; import { previousArray, previousString } from '#tui/form/builder/previous'; import type { FormBuilder } from '#tui/form/builder/index'; -import type { PromptBuilderMethods } from '#tui/form/builder/prompts/types'; +import type { ChoicePromptBuilderMethods } from '#tui/form/builder/prompts/types'; import type { ChoiceOptions, MultiSearchPromptOptions, SearchPromptOptions } from '#tui/types'; -export const choicePromptBuilderMethods: Omit & ThisType = { +export const choicePromptBuilderMethods: ChoicePromptBuilderMethods & ThisType = { autocomplete(label, options, defaultValue = '', required = false, validate = undefined, hint = '', name, transform = undefined) { return this.add((_, previous) => autocomplete({ message: label, label, options, default: previousString(previous, defaultValue), required, validate, hint, transform }), name); }, diff --git a/packages/tui/src/form/builder/prompts/contracts/basic.ts b/packages/tui/src/form/builder/prompts/contracts/basic.ts new file mode 100644 index 00000000..a849a23a --- /dev/null +++ b/packages/tui/src/form/builder/prompts/contracts/basic.ts @@ -0,0 +1,52 @@ +import type { FormBuilder } from '#tui/form/builder/index'; +import type { TextPromptOptions } from '#tui/types'; +import type { MaybePromise } from '#tui/types'; + +export type BasicPromptBuilderMethods = { + 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, + ): 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, + label: string, + placeholder?: string, + defaultValue?: string, + required?: boolean | string, + validate?: TextPromptOptions['validate'], + hint?: string, + name?: string, + transform?: TextPromptOptions['transform'], + ): FormBuilder; + textarea( + this: FormBuilder, + label: string, + placeholder?: string, + defaultValue?: string, + required?: boolean | string, + validate?: TextPromptOptions['validate'], + hint?: string, + rows?: number, + name?: string, + transform?: TextPromptOptions['transform'], + ): FormBuilder; +}; diff --git a/packages/tui/src/form/builder/prompts/contracts/choices.ts b/packages/tui/src/form/builder/prompts/contracts/choices.ts new file mode 100644 index 00000000..35c0729f --- /dev/null +++ b/packages/tui/src/form/builder/prompts/contracts/choices.ts @@ -0,0 +1,68 @@ +import type { FormBuilder } from '#tui/form/builder/index'; +import type { SuggestOptions } from '#tui/prompts/choices'; +import type { ChoiceOptions, MaybePromise, MultiSearchPromptOptions, SearchPromptOptions, TextPromptOptions } from '#tui/types'; + +export type ChoicePromptBuilderMethods = { + autocomplete( + this: FormBuilder, + label: string, + options: SuggestOptions['options'], + defaultValue?: string, + required?: boolean | string, + validate?: TextPromptOptions['validate'], + hint?: string, + name?: string, + transform?: TextPromptOptions['transform'], + ): 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; + multisearch(this: FormBuilder, options: MultiSearchPromptOptions, 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, + ): FormBuilder; + pause(this: FormBuilder, message?: string, name?: string): FormBuilder; + search(this: FormBuilder, options: SearchPromptOptions, 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, + ): FormBuilder; + suggest( + this: FormBuilder, + label: string, + options: SuggestOptions['options'], + defaultValue?: string, + scroll?: number, + required?: boolean | string, + validate?: TextPromptOptions['validate'], + hint?: string, + name?: string, + transform?: TextPromptOptions['transform'], + ): FormBuilder; +}; diff --git a/packages/tui/src/form/builder/prompts/contracts/index.ts b/packages/tui/src/form/builder/prompts/contracts/index.ts new file mode 100644 index 00000000..3edc9899 --- /dev/null +++ b/packages/tui/src/form/builder/prompts/contracts/index.ts @@ -0,0 +1,4 @@ +import type { BasicPromptBuilderMethods } from '#tui/form/builder/prompts/contracts/basic'; +import type { ChoicePromptBuilderMethods } from '#tui/form/builder/prompts/contracts/choices'; + +export type PromptBuilderMethods = BasicPromptBuilderMethods & ChoicePromptBuilderMethods; diff --git a/packages/tui/src/form/builder/prompts/types.ts b/packages/tui/src/form/builder/prompts/types.ts index d9450366..cc3b5efa 100644 --- a/packages/tui/src/form/builder/prompts/types.ts +++ b/packages/tui/src/form/builder/prompts/types.ts @@ -1,114 +1,3 @@ -import type { FormBuilder } from '#tui/form/builder/index'; -import type { SuggestOptions } from '#tui/prompts/choices'; -import type { ChoiceOptions, MaybePromise, MultiSearchPromptOptions, SearchPromptOptions, TextPromptOptions } from '#tui/types'; - -export type PromptBuilderMethods = { - autocomplete( - this: FormBuilder, - label: string, - options: SuggestOptions['options'], - defaultValue?: string, - required?: boolean | string, - validate?: TextPromptOptions['validate'], - hint?: string, - name?: string, - transform?: TextPromptOptions['transform'], - ): 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; - multisearch(this: FormBuilder, options: MultiSearchPromptOptions, 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, - ): 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, - ): FormBuilder; - password( - this: FormBuilder, - label: string, - placeholder?: string, - required?: boolean | string, - validate?: TextPromptOptions['validate'], - hint?: string, - name?: string, - transform?: TextPromptOptions['transform'], - ): FormBuilder; - pause(this: FormBuilder, message?: string, name?: string): FormBuilder; - search(this: FormBuilder, options: SearchPromptOptions, 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, - ): FormBuilder; - suggest( - this: FormBuilder, - label: string, - options: SuggestOptions['options'], - defaultValue?: string, - scroll?: number, - required?: boolean | string, - validate?: TextPromptOptions['validate'], - hint?: string, - name?: string, - transform?: TextPromptOptions['transform'], - ): 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, - label: string, - placeholder?: string, - defaultValue?: string, - required?: boolean | string, - validate?: TextPromptOptions['validate'], - hint?: string, - rows?: number, - name?: string, - transform?: TextPromptOptions['transform'], - ): FormBuilder; -}; +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'; From 984fb1a8671a9d06b3e400cc5eec84ad48241683 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 20:39:49 +0800 Subject: [PATCH 156/675] Split typed value edit actions --- packages/tui/src/typed-value/edit.ts | 79 +++++-------------- packages/tui/src/typed-value/edit/deletion.ts | 22 ++++++ .../tui/src/typed-value/edit/insertion.ts | 16 ++++ .../tui/src/typed-value/edit/navigation.ts | 30 +++++++ packages/tui/src/typed-value/edit/result.ts | 14 ++++ packages/tui/tests/typed-value.test.ts | 9 +++ 6 files changed, 111 insertions(+), 59 deletions(-) create mode 100644 packages/tui/src/typed-value/edit/deletion.ts create mode 100644 packages/tui/src/typed-value/edit/insertion.ts create mode 100644 packages/tui/src/typed-value/edit/navigation.ts create mode 100644 packages/tui/src/typed-value/edit/result.ts diff --git a/packages/tui/src/typed-value/edit.ts b/packages/tui/src/typed-value/edit.ts index 22a0e93c..7d341ad5 100644 --- a/packages/tui/src/typed-value/edit.ts +++ b/packages/tui/src/typed-value/edit.ts @@ -1,7 +1,9 @@ -import { Key, oneOf } from '#tui/key'; -import { characterLength, characters, fromCharacters, isPrintable } from '#tui/typed-value/characters'; -import { deleteNextCharacter, deletePreviousCharacter, deletePreviousWord, deleteToLineStart } from '#tui/typed-value/delete'; -import { moveLine, moveToLineBoundary } from '#tui/typed-value/lines'; +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 type { AppliedTypedKey, TypedValueState } from '#tui/typed-value/types'; export const applyTypedKey = (state: TypedValueState, key: string, allowNewLine = false): AppliedTypedKey => { @@ -10,84 +12,43 @@ export const applyTypedKey = (state: TypedValueState, key: string, allowNewLine let cursor = Math.max(0, Math.min(value.length, state.cursor)); if (key === Key.ctrlC) { - return { cursor, value: fromCharacters(value), submitted: false, cancelled: true }; + return typedKeyResult(value, cursor, { cancelled: true }); } if (key === Key.ctrlD && allowNewLine) { - return { cursor, value: fromCharacters(value), submitted: true, cancelled: false }; + return typedKeyResult(value, cursor, { submitted: true }); } if (key === Key.enter) { if (!allowNewLine) { - return { cursor, value: fromCharacters(value), submitted: true, cancelled: false }; + return typedKeyResult(value, cursor, { submitted: true }); } value.splice(cursor, 0, '\n'); cursor += 1; - return { cursor, value: fromCharacters(value), submitted: false, cancelled: false }; + return typedKeyResult(value, cursor); } - if (key === Key.left || key === Key.leftArrow || key === Key.ctrlB) { - return { cursor: Math.max(0, cursor - 1), value: fromCharacters(value), submitted: false, cancelled: false }; - } - - if (key === Key.right || key === Key.rightArrow || key === Key.ctrlF) { - return { cursor: Math.min(value.length, cursor + 1), value: fromCharacters(value), submitted: false, cancelled: false }; - } - - if (allowNewLine && (key === Key.up || key === Key.upArrow)) { - return { cursor: moveLine(value, cursor, -1), value: fromCharacters(value), submitted: false, cancelled: false }; - } - - if (allowNewLine && (key === Key.down || key === Key.downArrow)) { - return { cursor: moveLine(value, cursor, 1), value: fromCharacters(value), submitted: false, cancelled: false }; - } - - if (oneOf([Key.home, Key.ctrlA], key)) { - const nextCursor = allowNewLine ? moveToLineBoundary(value, cursor, 'start') : 0; - - return { cursor: nextCursor, value: fromCharacters(value), submitted: false, cancelled: false }; - } + const moved = moveTypedValueCursor(value, cursor, key, allowNewLine); - if (oneOf([Key.end, Key.ctrlE], key)) { - const nextCursor = allowNewLine ? moveToLineBoundary(value, cursor, 'end') : value.length; - - return { cursor: nextCursor, value: fromCharacters(value), submitted: false, cancelled: false }; - } - - if (key === Key.delete) { - cursor = deleteNextCharacter(value, cursor); - - return { cursor, value: fromCharacters(value), submitted: false, cancelled: false }; - } - - if (key === Key.ctrlU) { - cursor = deleteToLineStart(value, cursor, allowNewLine); - - return { cursor, value: fromCharacters(value), submitted: false, cancelled: false }; + if (moved !== undefined) { + return typedKeyResult(value, moved); } - if (key === Key.backspace || key === Key.ctrlH) { - cursor = deletePreviousCharacter(value, cursor); + const deleted = deleteTypedValueKey(value, cursor, key, allowNewLine); - return { cursor, value: fromCharacters(value), submitted: false, cancelled: false }; + if (deleted !== undefined) { + return typedKeyResult(value, deleted); } - if (key === Key.optionBackspace) { - cursor = deletePreviousWord(value, cursor); + const inserted = insertPrintableKey(value, cursor, key); - return { cursor, value: fromCharacters(value), submitted: false, cancelled: false }; - } - - if (isPrintable(key)) { - for (const character of characters(key)) { - value.splice(cursor, 0, character); - cursor += 1; - } + if (inserted !== undefined) { + cursor = inserted; } - return { cursor, value: fromCharacters(value), submitted: false, cancelled: false }; + return typedKeyResult(value, cursor); }; export const initialTypedValueState = (value = ''): TypedValueState => { diff --git a/packages/tui/src/typed-value/edit/deletion.ts b/packages/tui/src/typed-value/edit/deletion.ts new file mode 100644 index 00000000..a61492f1 --- /dev/null +++ b/packages/tui/src/typed-value/edit/deletion.ts @@ -0,0 +1,22 @@ +import { Key } from '#tui/key'; +import { deleteNextCharacter, deletePreviousCharacter, deletePreviousWord, deleteToLineStart } from '#tui/typed-value/delete'; + +export const deleteTypedValueKey = (value: string[], cursor: number, key: string, allowNewLine: boolean): number | undefined => { + if (key === Key.delete) { + return deleteNextCharacter(value, cursor); + } + + if (key === Key.ctrlU) { + return deleteToLineStart(value, cursor, allowNewLine); + } + + if (key === Key.backspace || key === Key.ctrlH) { + return deletePreviousCharacter(value, cursor); + } + + if (key === Key.optionBackspace) { + return deletePreviousWord(value, cursor); + } + + return undefined; +}; diff --git a/packages/tui/src/typed-value/edit/insertion.ts b/packages/tui/src/typed-value/edit/insertion.ts new file mode 100644 index 00000000..326ce822 --- /dev/null +++ b/packages/tui/src/typed-value/edit/insertion.ts @@ -0,0 +1,16 @@ +import { characters, isPrintable } from '#tui/typed-value/characters'; + +export const insertPrintableKey = (value: string[], cursor: number, key: string): number | undefined => { + if (!isPrintable(key)) { + return undefined; + } + + let nextCursor = cursor; + + for (const character of characters(key)) { + value.splice(nextCursor, 0, character); + nextCursor += 1; + } + + return nextCursor; +}; diff --git a/packages/tui/src/typed-value/edit/navigation.ts b/packages/tui/src/typed-value/edit/navigation.ts new file mode 100644 index 00000000..3db7c4bd --- /dev/null +++ b/packages/tui/src/typed-value/edit/navigation.ts @@ -0,0 +1,30 @@ +import { Key, oneOf } from '#tui/key'; +import { moveLine, moveToLineBoundary } from '#tui/typed-value/lines'; + +export const moveTypedValueCursor = (value: string[], cursor: number, key: string, allowNewLine: boolean): 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)) { + return moveLine(value, cursor, -1); + } + + if (allowNewLine && (key === Key.down || key === Key.downArrow)) { + return moveLine(value, cursor, 1); + } + + if (oneOf([Key.home, Key.ctrlA], key)) { + return allowNewLine ? moveToLineBoundary(value, cursor, 'start') : 0; + } + + if (oneOf([Key.end, Key.ctrlE], key)) { + return allowNewLine ? moveToLineBoundary(value, cursor, 'end') : value.length; + } + + return undefined; +}; diff --git a/packages/tui/src/typed-value/edit/result.ts b/packages/tui/src/typed-value/edit/result.ts new file mode 100644 index 00000000..3e4b06b0 --- /dev/null +++ b/packages/tui/src/typed-value/edit/result.ts @@ -0,0 +1,14 @@ +import { fromCharacters } from '#tui/typed-value/characters'; +import type { AppliedTypedKey } from '#tui/typed-value/types'; + +type TypedKeyResultOptions = { + cancelled?: boolean; + submitted?: boolean; +}; + +export const typedKeyResult = (value: string[], cursor: number, options: TypedKeyResultOptions = {}): AppliedTypedKey => ({ + cancelled: options.cancelled ?? false, + cursor, + submitted: options.submitted ?? false, + value: fromCharacters(value), +}); diff --git a/packages/tui/tests/typed-value.test.ts b/packages/tui/tests/typed-value.test.ts index b16b684e..631baed0 100644 --- a/packages/tui/tests/typed-value.test.ts +++ b/packages/tui/tests/typed-value.test.ts @@ -61,6 +61,15 @@ describe('typed value editing', () => { expect(apply(['J', 'z', 'e', Key.ctrlB, Key.ctrlH, Key.ctrlF, 's', 's'])).toBe('Jess'); }); + it('marks typed values as cancelled without changing the current value', () => { + expect(applyTypedKey({ cursor: 3, value: 'Ada' }, Key.ctrlC)).toEqual({ + cancelled: true, + cursor: 3, + submitted: false, + value: 'Ada', + }); + }); + it('clamps UTF-16 cursor offsets to character positions before editing', () => { expect(applyMultilineState('😀', '😀'.length, ['!'])).toEqual({ cursor: 2, From fb0358a933eb5c59a2e8b1d12b739bebae294de0 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 20:45:41 +0800 Subject: [PATCH 157/675] Render progress with boxed frames --- UPSTREAM.md | 3 ++- packages/acceptance/tests/helpers.test.ts | 3 ++- packages/tui/src/status/progress.ts | 13 ++++++++++-- packages/tui/src/status/progress/render.ts | 23 +++++++++++++++++---- packages/tui/src/status/progress/run.ts | 13 +++++++++--- packages/tui/src/theme/box.ts | 24 ++++++++++++++++++++++ packages/tui/tests/progress.test.ts | 24 +++++++++++++++++++--- 7 files changed, 89 insertions(+), 14 deletions(-) create mode 100644 packages/tui/src/theme/box.ts diff --git a/UPSTREAM.md b/UPSTREAM.md index 240f277c..b375093a 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -30,6 +30,7 @@ TypeScript and OpenTUI internals. | Tables, data tables, and grids | `packages/tui/src/output/table.ts`, `packages/tui/src/output/data-table.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` | +| Progress boxed frame rendering and formatted fractions | `packages/tui/src/status/progress/render.ts`, `packages/tui/src/theme/box.ts` | | ANSI, wrapping, truncation, Unicode width | `packages/tui/src/strings.ts` | | OpenTUI adapter | `packages/tui/src/opentui.ts` | | Type contracts | `packages/tui/src/types.ts`, `packages/tui/src/contracts/*` | @@ -44,7 +45,7 @@ later phases: - Multi-search full rendering and selected-result summary parity - Multi-select - Notifications -- Progress exact box drawing and signal handling parity +- Progress signal handling and terminal cleanup parity - Prompt lifecycle - Search full rendering and highlighted result label parity - Spinner diff --git a/packages/acceptance/tests/helpers.test.ts b/packages/acceptance/tests/helpers.test.ts index 445d6d83..9b7bd211 100644 --- a/packages/acceptance/tests/helpers.test.ts +++ b/packages/acceptance/tests/helpers.test.ts @@ -106,6 +106,7 @@ describe('prompt helpers', () => { const bar = progress(2, 'Files'); + bar.advance(); bar.advance(); bar.finish(); @@ -113,7 +114,7 @@ describe('prompt helpers', () => { }); expect(output.text()).toContain('Working'); - expect(output.text()).toContain('Files:'); + expect(output.text()).toContain('┌ Files '); expect(output.text()).toContain('2 / 2'); expect(output.text()).toContain('ab'); }); diff --git a/packages/tui/src/status/progress.ts b/packages/tui/src/status/progress.ts index cee154ad..d34a46c2 100644 --- a/packages/tui/src/status/progress.ts +++ b/packages/tui/src/status/progress.ts @@ -3,12 +3,14 @@ import { parseProgressStep, parseProgressTotal } from '#tui/status/validators/pr import { renderProgressFrame } from '#tui/status/progress/render'; import { runProgressSteps } from '#tui/status/progress/run'; import { progressValues } from '#tui/status/progress/steps'; +import type { ProgressFrameState } from '#tui/status/progress/render'; import type { MaybePromise } from '#tui/types'; export class Progress { #current = 0; #label: string; #hint: string; + #state: ProgressFrameState = 'active'; readonly total: number; constructor(total: number, message = 'Progress', hint = '') { @@ -18,16 +20,23 @@ export class Progress { } start(): void { + this.#state = 'active'; this.render(); } advance(step = 1): void { + this.#state = 'active'; this.#current = Math.max(0, Math.min(this.total, this.#current + parseProgressStep(step))); this.render(); } finish(): void { - this.#current = this.total; + this.#state = 'submit'; + this.render(); + } + + fail(): void { + this.#state = 'error'; this.render(); } @@ -56,7 +65,7 @@ export class Progress { } render(): void { - promptEnvironment().output.write(renderProgressFrame({ current: this.#current, hint: this.#hint, label: this.#label, total: this.total })); + promptEnvironment().output.write(renderProgressFrame({ current: this.#current, hint: this.#hint, label: this.#label, state: this.#state, total: this.total })); } } diff --git a/packages/tui/src/status/progress/render.ts b/packages/tui/src/status/progress/render.ts index 2c7a4150..fd0aa4e1 100644 --- a/packages/tui/src/status/progress/render.ts +++ b/packages/tui/src/status/progress/render.ts @@ -1,7 +1,12 @@ +import { renderBox } from '#tui/theme/box'; + +export type ProgressFrameState = 'active' | 'cancel' | 'error' | 'submit'; + export type ProgressFrameOptions = { current: number; hint: string; label: string; + state?: ProgressFrameState; total: number; width?: number; }; @@ -10,10 +15,20 @@ export const progressPercentage = (current: number, total: number): number => { return current / total; }; -export const renderProgressFrame = ({ current, hint, label, total, width = 20 }: ProgressFrameOptions): string => { +const formatProgressNumber = (value: number): string => new Intl.NumberFormat('en-US').format(value); + +export const formatProgressFraction = (current: number, total: number): string => { + return `${formatProgressNumber(current)} / ${formatProgressNumber(total)}`; +}; + +export const renderProgressFrame = ({ current, hint, label, state = 'active', total, width = 60 }: ProgressFrameOptions): string => { const filled = Math.ceil(width * progressPercentage(current, total)); - const bar = `${'█'.repeat(filled)}${' '.repeat(width - filled)}`; - const suffix = hint ? ` ${hint}` : ''; + const body = '█'.repeat(filled); + const lines = [renderBox({ body, info: formatProgressFraction(current, total), title: label, width })]; + + if (state === 'active') { + lines.push(hint ? ` ${hint}` : ''); + } - return `${label}: ${bar} ${current} / ${total}${suffix}\n`; + return `${lines.join('\n')}\n`; }; diff --git a/packages/tui/src/status/progress/run.ts b/packages/tui/src/status/progress/run.ts index ca824ff5..569599d5 100644 --- a/packages/tui/src/status/progress/run.ts +++ b/packages/tui/src/status/progress/run.ts @@ -2,6 +2,7 @@ import type { MaybePromise } from '#tui/types'; type ProgressRunner = { advance(): void; + fail(): void; finish(): void; start(): void; }; @@ -15,10 +16,16 @@ export const runProgressSteps = async ( progress.start(); - for (const value of values) { - results.push(await callback(value, progress)); + try { + for (const value of values) { + results.push(await callback(value, progress)); - progress.advance(); + progress.advance(); + } + } catch (error) { + progress.fail(); + + throw error; } progress.finish(); diff --git a/packages/tui/src/theme/box.ts b/packages/tui/src/theme/box.ts new file mode 100644 index 00000000..60cf5da2 --- /dev/null +++ b/packages/tui/src/theme/box.ts @@ -0,0 +1,24 @@ +import { truncate, visibleWidth } from '#tui/strings'; + +const DEFAULT_BOX_WIDTH = 60; + +type BoxOptions = { + body: string; + info?: string; + title?: string; + width?: number; +}; + +const padVisible = (value: string, width: number): string => `${value}${' '.repeat(Math.max(0, width - visibleWidth(value)))}`; + +export const renderBox = ({ body, info = '', title = '', width = DEFAULT_BOX_WIDTH }: BoxOptions): string => { + const bodyLines = body.split('\n'); + const contentWidth = Math.max(width, visibleWidth(title), ...bodyLines.map(visibleWidth)); + const titleWidth = visibleWidth(title); + const titleLabel = titleWidth > 0 ? ` ${title} ` : ''; + const topBorder = '─'.repeat(contentWidth - titleWidth + (titleWidth > 0 ? 0 : 2)); + const renderedInfo = info ? truncate(info, contentWidth - 1) : ''; + const bottomBorder = '─'.repeat(renderedInfo ? contentWidth - visibleWidth(renderedInfo) : contentWidth + 2); + + return [` ┌${titleLabel}${topBorder}┐`, ...bodyLines.map((line) => ` │ ${padVisible(line, contentWidth)} │`), ` └${bottomBorder}${renderedInfo ? ` ${renderedInfo} ` : ''}┘`].join('\n'); +}; diff --git a/packages/tui/tests/progress.test.ts b/packages/tui/tests/progress.test.ts index d24c297a..44a1308e 100644 --- a/packages/tui/tests/progress.test.ts +++ b/packages/tui/tests/progress.test.ts @@ -55,16 +55,19 @@ describe('progress helper', () => { expect(output.text()).toContain('1 / 100'); }); - it('renders a fully filled progress bar at completion', async () => { + it('renders a boxed progress bar with formatted fractions', async () => { const output = createMemoryOutput(); await withPromptEnvironment({ output, error: output }, async () => { - const bar = progress('Adding States', 2); + const bar = progress('Adding States', 1000); + bar.start(); bar.finish(); }); - expect(output.text()).toContain('████████████████████ 2 / 2'); + expect(output.text()).toContain(' ┌ Adding States '); + expect(output.text()).toContain(' │ │'); + expect(output.text()).toContain('└─────────────────────────────────────────────────── 0 / 1,000 ┘'); }); it('returns a completion value separately from the current count', async () => { @@ -118,4 +121,19 @@ describe('progress helper', () => { expect(() => progress('Adding States', -1)).toThrow('Progress bar must have at least one item.'); expect(() => progress('Adding States', Number.POSITIVE_INFINITY)).toThrow('Progress bar must have at least one item.'); }); + + it('renders an error frame before rethrowing callback failures', async () => { + const output = createMemoryOutput(); + + await expect(async () => { + await withPromptEnvironment({ output, error: output }, async () => { + await progress('Adding States', ['Alabama'], () => { + throw new Error('failed'); + }); + }); + }).rejects.toThrow('failed'); + + expect(output.text()).toContain('Adding States'); + expect(output.text()).toContain('0 / 1'); + }); }); From dd67e685c338443e5440e02b4ef97407aac5d72f Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 20:48:54 +0800 Subject: [PATCH 158/675] Render spinner static frame --- UPSTREAM.md | 3 ++- packages/tui/src/status/spinner.ts | 14 +++----------- packages/tui/src/status/spinner/render.ts | 5 +++++ packages/tui/tests/form.test.ts | 4 ++-- packages/tui/tests/spinner.test.ts | 14 +++++++------- 5 files changed, 19 insertions(+), 21 deletions(-) create mode 100644 packages/tui/src/status/spinner/render.ts diff --git a/UPSTREAM.md b/UPSTREAM.md index b375093a..7579db6e 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -31,6 +31,7 @@ TypeScript and OpenTUI internals. | Terminal title and clear output | `packages/tui/src/output/terminal.ts` | | Spinner, progress, task, stream | `packages/tui/src/status/*`, `packages/tui/src/status.ts` | | Progress boxed frame rendering and formatted fractions | `packages/tui/src/status/progress/render.ts`, `packages/tui/src/theme/box.ts` | +| Spinner static frame rendering | `packages/tui/src/status/spinner.ts`, `packages/tui/src/status/spinner/render.ts` | | ANSI, wrapping, truncation, Unicode width | `packages/tui/src/strings.ts` | | OpenTUI adapter | `packages/tui/src/opentui.ts` | | Type contracts | `packages/tui/src/types.ts`, `packages/tui/src/contracts/*` | @@ -48,7 +49,7 @@ later phases: - Progress signal handling and terminal cleanup parity - Prompt lifecycle - Search full rendering and highlighted result label parity -- Spinner +- Spinner animated frame and terminal cleanup parity - Stream - Task - Terminal diff --git a/packages/tui/src/status/spinner.ts b/packages/tui/src/status/spinner.ts index dd329112..cdf4f5c2 100644 --- a/packages/tui/src/status/spinner.ts +++ b/packages/tui/src/status/spinner.ts @@ -1,4 +1,5 @@ import { promptEnvironment } from '#tui/environment'; +import { renderSpinnerFrame } from '#tui/status/spinner/render'; import type { MaybePromise, StatusOptions } from '#tui/types'; export function spin(callback: () => MaybePromise, options?: StatusOptions): Promise; @@ -15,16 +16,7 @@ export async function spin(callbackOrMessage: (() => MaybePromise) | strin const message = typeof options === 'function' ? 'Loading' : options.message; - promptEnvironment().output.write(`${message}...\n`); + promptEnvironment().output.write(renderSpinnerFrame(message)); - try { - const result = await callback(); - - promptEnvironment().output.write(`Done: ${message}\n`); - - return result; - } catch (error) { - promptEnvironment().error.write(`Failed: ${message}\n`); - throw error; - } + return callback(); } diff --git a/packages/tui/src/status/spinner/render.ts b/packages/tui/src/status/spinner/render.ts new file mode 100644 index 00000000..7de4240b --- /dev/null +++ b/packages/tui/src/status/spinner/render.ts @@ -0,0 +1,5 @@ +const STATIC_SPINNER_FRAME = '⠶'; + +export const renderSpinnerFrame = (message: string): string => { + return ` ${STATIC_SPINNER_FRAME} ${message}\n`; +}; diff --git a/packages/tui/tests/form.test.ts b/packages/tui/tests/form.test.ts index 5e25d75d..753582bc 100644 --- a/packages/tui/tests/form.test.ts +++ b/packages/tui/tests/form.test.ts @@ -263,8 +263,8 @@ describe('form builder', () => { expect(responses.spin).toBe('spun'); expect(responses.progress).toEqual([2, 4]); - expect(output.text()).toContain('Loading...'); - expect(output.text()).toContain('Done: Loading'); + expect(output.text()).toContain(' ⠶ Loading'); + expect(output.text()).not.toContain('Done: Loading'); expect(output.text()).toContain('Files'); expect(output.text()).toContain('2 / 2'); }); diff --git a/packages/tui/tests/spinner.test.ts b/packages/tui/tests/spinner.test.ts index 54298e9b..4254ed01 100644 --- a/packages/tui/tests/spinner.test.ts +++ b/packages/tui/tests/spinner.test.ts @@ -10,8 +10,8 @@ describe('spinner helper', () => { }); expect(result).toBe('done'); - expect(output.text()).toContain('Working...'); - expect(output.text()).toContain('Done: Working'); + expect(output.text()).toContain(' ⠶ Working'); + expect(output.text()).not.toContain('Done: Working'); }); it('runs label-first spinner callbacks', async () => { @@ -22,11 +22,11 @@ describe('spinner helper', () => { }); expect(result).toBe('done'); - expect(output.text()).toContain('Working...'); - expect(output.text()).toContain('Done: Working'); + expect(output.text()).toContain(' ⠶ Working'); + expect(output.text()).not.toContain('Done: Working'); }); - it('writes failure output before rethrowing spinner errors', async () => { + it('rethrows spinner errors without synthetic failure output', async () => { const output = createMemoryOutput(); const failure = new Error('failed'); @@ -38,8 +38,8 @@ describe('spinner helper', () => { }), ).rejects.toBe(failure); - expect(output.text()).toContain('Working...'); - expect(output.text()).toContain('Failed: Working'); + expect(output.text()).toContain(' ⠶ Working'); + expect(output.text()).not.toContain('Failed: Working'); expect(output.text()).not.toContain('Done: Working'); }); }); From 710050a1a1694ba4c648313248411372f60bd39a Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 20:53:15 +0800 Subject: [PATCH 159/675] Render task static frames --- UPSTREAM.md | 3 +- packages/tui/src/status/task.ts | 49 +++++++++++++++------ packages/tui/src/status/task/logger.ts | 7 +++ packages/tui/src/status/task/render.ts | 60 ++++++++++++++++++++++++++ packages/tui/tests/task.test.ts | 27 ++++++------ 5 files changed, 121 insertions(+), 25 deletions(-) create mode 100644 packages/tui/src/status/task/render.ts diff --git a/UPSTREAM.md b/UPSTREAM.md index 7579db6e..408ee1c0 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -32,6 +32,7 @@ TypeScript and OpenTUI internals. | Spinner, progress, task, stream | `packages/tui/src/status/*`, `packages/tui/src/status.ts` | | Progress boxed frame rendering and formatted fractions | `packages/tui/src/status/progress/render.ts`, `packages/tui/src/theme/box.ts` | | Spinner static frame rendering | `packages/tui/src/status/spinner.ts`, `packages/tui/src/status/spinner/render.ts` | +| Task static frame rendering, bounded logs, and stable summary symbols | `packages/tui/src/status/task.ts`, `packages/tui/src/status/task/*` | | ANSI, wrapping, truncation, Unicode width | `packages/tui/src/strings.ts` | | OpenTUI adapter | `packages/tui/src/opentui.ts` | | Type contracts | `packages/tui/src/types.ts`, `packages/tui/src/contracts/*` | @@ -51,7 +52,7 @@ later phases: - Search full rendering and highlighted result label parity - Spinner animated frame and terminal cleanup parity - Stream -- Task +- Task animated frame, terminal cleanup, and process-log parity - Terminal - Textarea - Default theme diff --git a/packages/tui/src/status/task.ts b/packages/tui/src/status/task.ts index 33399f2a..a3000e3b 100644 --- a/packages/tui/src/status/task.ts +++ b/packages/tui/src/status/task.ts @@ -1,5 +1,6 @@ import { promptEnvironment } from '#tui/environment'; import { Logger } from '#tui/status/task/logger'; +import { renderTaskFrame } from '#tui/status/task/render'; import type { MaybePromise } from '#tui/types'; export type TaskDefinition = { @@ -28,21 +29,45 @@ export async function task(definitionOrLabel: TaskDefinition | string, cal const logger = new Logger(typeof definitionOrLabel === 'string' ? limit : (definitionOrLabel.limit ?? limit), title, subtitle ?? ''); - promptEnvironment().output.write(`${title}${subtitle ? ` ${subtitle}` : ''}\n`); + const output = promptEnvironment().output; - const result = await run(logger); + output.write( + renderTaskFrame({ + label: logger.labelValue, + limit: logger.limitValue, + lines: logger.lines, + stableMessages: logger.stableMessages, + subLabel: logger.subLabelValue, + }), + ); - for (const line of logger.lines) { - promptEnvironment().output.write(`${line}\n`); - } + try { + const result = await run(logger); - if (summary) { - for (const message of logger.stableMessages) { - promptEnvironment().output.write(`${message.type}: ${message.message}\n`); - } - } + output.write( + renderTaskFrame({ + finished: true, + keepSummary: summary, + label: logger.labelValue, + limit: logger.limitValue, + lines: logger.lines, + stableMessages: summary ? logger.stableMessages : [], + subLabel: logger.subLabelValue, + }), + ); - promptEnvironment().output.write(`Done: ${logger.labelValue}${logger.subLabelValue ? ` ${logger.subLabelValue}` : ''}\n`); + return result; + } catch (error) { + output.write( + renderTaskFrame({ + label: logger.labelValue, + limit: logger.limitValue, + lines: logger.lines, + stableMessages: logger.stableMessages, + subLabel: logger.subLabelValue, + }), + ); - return result; + throw error; + } } diff --git a/packages/tui/src/status/task/logger.ts b/packages/tui/src/status/task/logger.ts index ca6a707a..e118c0e6 100644 --- a/packages/tui/src/status/task/logger.ts +++ b/packages/tui/src/status/task/logger.ts @@ -19,6 +19,10 @@ export class Logger { this.subLabelValue = subLabel; } + get limitValue(): number { + return this.limit; + } + line(message: string): void { this.writeLines(message.trimEnd()); } @@ -70,6 +74,9 @@ export class Logger { private stable(type: StableTaskMessage['type'], message: string): void { this.stableMessages.push({ message, type }); + this.lines.splice(0); + this.#partialBuffer = ''; + this.#partialStartIndex = null; while (this.stableMessages.length > this.stableLimit) { this.stableMessages.shift(); diff --git a/packages/tui/src/status/task/render.ts b/packages/tui/src/status/task/render.ts new file mode 100644 index 00000000..9463ab4f --- /dev/null +++ b/packages/tui/src/status/task/render.ts @@ -0,0 +1,60 @@ +import { renderSpinnerFrame } from '#tui/status/spinner/render'; +import type { StableTaskMessage } from '#tui/status/task/messages'; + +const TASK_DIVIDER_WIDTH = 60; + +type TaskFrameOptions = { + 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, keepSummary = false, label, limit, lines, stableMessages, subLabel }: TaskFrameOptions): string => { + if (finished && keepSummary && stableMessages.length > 0) { + return [` • ${label}`, ...stableMessages.map(renderStableMessage), ''].join('\n'); + } + + const output = [renderSpinnerFrame(label).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(-limit); + + output.push(...visibleLines.map((line) => ` ${line}`)); + + for (let remaining = limit - visibleLines.length; remaining > 0; remaining -= 1) { + output.push(''); + } + + return `${output.join('\n')}\n`; +}; diff --git a/packages/tui/tests/task.test.ts b/packages/tui/tests/task.test.ts index cf3a1426..ee7368b0 100644 --- a/packages/tui/tests/task.test.ts +++ b/packages/tui/tests/task.test.ts @@ -43,7 +43,7 @@ describe('task helper', () => { expect(output.text()).toContain('Running...'); expect(output.text()).not.toContain('line one'); expect(output.text()).not.toContain('line two'); - expect(output.text()).toContain('Done: Running...'); + expect(output.text()).toContain(' ⠶ Running...'); }); it('strips cursor reset control sequences from log lines', async () => { @@ -100,10 +100,11 @@ describe('task helper', () => { ); }); - expect(output.text()).toContain('line one'); - expect(output.text()).toContain('success: created'); - expect(output.text()).toContain('warning: check this'); - expect(output.text()).toContain('error: failed optional step'); + expect(output.text()).not.toContain('line one'); + expect(output.text()).toContain(' • Running...'); + expect(output.text()).toContain('✔ created'); + expect(output.text()).toContain('⚠ check this'); + expect(output.text()).toContain('✘ failed optional step'); }); it('bounds stable task summaries with the task log limit', async () => { @@ -123,8 +124,8 @@ describe('task helper', () => { }); expect(output.text()).not.toContain('success: first'); - expect(output.text()).toContain('warning: second'); - expect(output.text()).toContain('error: third'); + expect(output.text()).toContain('⚠ second'); + expect(output.text()).toContain('✘ third'); }); it('omits stable task summaries by default', async () => { @@ -137,7 +138,8 @@ describe('task helper', () => { }); expect(output.text()).not.toContain('success: created'); - expect(output.text()).toContain('Done: Running...'); + expect(output.text()).not.toContain('✔ created'); + expect(output.text()).toContain(' ⠶ Running...'); }); it('accumulates partial task output until committed', async () => { @@ -196,7 +198,8 @@ describe('task helper', () => { }); expect(output.text()).toContain('Running...'); - expect(output.text()).toContain('Done: Building assets'); + expect(output.text()).toContain(' ⠶ Building'); + expect(output.text()).toContain('assets'); }); it('clears task sub-labels through the logger', async () => { @@ -215,8 +218,8 @@ describe('task helper', () => { ); }); - expect(output.text()).toContain('Running... assets'); - expect(output.text()).toContain('Done: Building'); - expect(output.text()).not.toContain('Done: Building assets'); + expect(output.text()).toContain(' ⠶ Running...\n assets'); + expect(output.text()).toContain(' ⠶ Building'); + expect(output.text()).not.toContain(' ⠶ Building\n assets'); }); }); From dac51dc3ff40265f2feb9a5cee750e4711ae871c Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 20:56:13 +0800 Subject: [PATCH 160/675] Render stream frames --- UPSTREAM.md | 3 +- packages/tui/src/status/stream.ts | 28 +++++++++++------ packages/tui/src/status/stream/buffer.ts | 30 ++++++++++++++++++ packages/tui/src/status/stream/render.ts | 16 ++++++++++ packages/tui/tests/stream.test.ts | 40 ++++++++++++++++++++++-- 5 files changed, 104 insertions(+), 13 deletions(-) create mode 100644 packages/tui/src/status/stream/buffer.ts create mode 100644 packages/tui/src/status/stream/render.ts diff --git a/UPSTREAM.md b/UPSTREAM.md index 408ee1c0..e78c129e 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -33,6 +33,7 @@ TypeScript and OpenTUI internals. | Progress boxed frame rendering and formatted fractions | `packages/tui/src/status/progress/render.ts`, `packages/tui/src/theme/box.ts` | | Spinner static frame rendering | `packages/tui/src/status/spinner.ts`, `packages/tui/src/status/spinner/render.ts` | | Task static frame rendering, bounded logs, and stable summary symbols | `packages/tui/src/status/task.ts`, `packages/tui/src/status/task/*` | +| Stream frame rendering, wrapping, and close flushing | `packages/tui/src/status/stream.ts`, `packages/tui/src/status/stream/*` | | ANSI, wrapping, truncation, Unicode width | `packages/tui/src/strings.ts` | | OpenTUI adapter | `packages/tui/src/opentui.ts` | | Type contracts | `packages/tui/src/types.ts`, `packages/tui/src/contracts/*` | @@ -51,7 +52,7 @@ later phases: - Prompt lifecycle - Search full rendering and highlighted result label parity - Spinner animated frame and terminal cleanup parity -- Stream +- Stream true-color fade and terminal cleanup parity - Task animated frame, terminal cleanup, and process-log parity - Terminal - Textarea diff --git a/packages/tui/src/status/stream.ts b/packages/tui/src/status/stream.ts index 586b01d3..1ecaccab 100644 --- a/packages/tui/src/status/stream.ts +++ b/packages/tui/src/status/stream.ts @@ -1,25 +1,31 @@ import { promptEnvironment } from '#tui/environment'; +import { StreamBuffer } from '#tui/status/stream/buffer'; +import { renderStreamFrame, streamLines } from '#tui/status/stream/render'; export class Stream { #closed = false; - #value = ''; + readonly #buffer = new StreamBuffer(10); write(content: string): this { + return this.append(content); + } + + append(content: string): this { if (this.#closed) { throw new Error('Stream is closed.'); } - this.#value += content; - promptEnvironment().output.write(content); + this.#buffer.append(content); + this.render(); return this; } - append(content: string): this { - return this.write(content); - } - close(): void { + while (this.#buffer.flushNext()) { + this.render(); + } + this.#closed = true; } @@ -28,7 +34,7 @@ export class Stream { } lines(): string[] { - return this.#value.split(/\r?\n/u); + return streamLines({ value: this.value() }); } async pipe(source: AsyncIterable | Iterable): Promise { @@ -46,7 +52,11 @@ export class Stream { } value(): string { - return this.#value; + return this.#buffer.value(); + } + + private render(): void { + promptEnvironment().output.write(renderStreamFrame({ value: this.value() })); } } diff --git a/packages/tui/src/status/stream/buffer.ts b/packages/tui/src/status/stream/buffer.ts new file mode 100644 index 00000000..520c5fbb --- /dev/null +++ b/packages/tui/src/status/stream/buffer.ts @@ -0,0 +1,30 @@ +export class StreamBuffer { + readonly fading: string[] = []; + #message = ''; + + constructor(private readonly fadeLimit: number) {} + + append(message: string): void { + this.fading.push(message); + + while (this.fading.length > this.fadeLimit) { + this.#message += this.fading.shift() ?? ''; + } + } + + flushNext(): boolean { + const next = this.fading.shift(); + + if (next === undefined) { + return false; + } + + this.#message += next; + + return true; + } + + value(): string { + return `${this.#message}${this.fading.join('')}`; + } +} diff --git a/packages/tui/src/status/stream/render.ts b/packages/tui/src/status/stream/render.ts new file mode 100644 index 00000000..c37997a6 --- /dev/null +++ b/packages/tui/src/status/stream/render.ts @@ -0,0 +1,16 @@ +import { wrap } from '#tui/strings'; + +type StreamFrameOptions = { + value: string; + width?: number; +}; + +export const streamLines = ({ value, width = 60 }: StreamFrameOptions): string[] => { + return value.split(/\r?\n/u).flatMap((line) => wrap(line, width)); +}; + +export const renderStreamFrame = (options: StreamFrameOptions): string => { + return `${streamLines(options) + .map((line) => ` ${line}`) + .join('\n')}\n`; +}; diff --git a/packages/tui/tests/stream.test.ts b/packages/tui/tests/stream.test.ts index c74ffc99..d6ba0565 100644 --- a/packages/tui/tests/stream.test.ts +++ b/packages/tui/tests/stream.test.ts @@ -17,7 +17,7 @@ describe('stream helper', () => { expect(outputStream.closed()).toBe(true); }); - expect(output.text()).toBe('hello world'); + expect(output.text()).toContain(' hello world'); }); it('pipes iterable content through a stateful stream', async () => { @@ -27,7 +27,8 @@ describe('stream helper', () => { await stream(['one', '\n', 'two']); }); - expect(output.text()).toBe('one\ntwo'); + expect(output.text()).toContain(' one'); + expect(output.text()).toContain(' two'); }); it('closes piped streams after the source is exhausted', async () => { @@ -57,7 +58,7 @@ describe('stream helper', () => { expect(outputStream.value()).toBe('finished'); }); - expect(output.text()).toBe('finished'); + expect(output.text()).toContain(' finished'); }); it('cannot be prompted for input', async () => { @@ -69,4 +70,37 @@ describe('stream helper', () => { expect(() => outputStream.prompt()).toThrow('Stream cannot be prompted'); }); }); + + it('wraps rendered stream lines with a leading gutter', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + const outputStream = stream(); + + outputStream.write('a '.repeat(40).trimEnd()); + + expect(outputStream.lines().length).toBeGreaterThan(1); + }); + + expect(output.text()).toContain('\n '); + }); + + it('flushes pending stream chunks before closing', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + const outputStream = stream(); + + for (let index = 0; index < 12; index += 1) { + outputStream.append(String(index)); + } + + outputStream.close(); + + expect(outputStream.value()).toBe('01234567891011'); + expect(outputStream.closed()).toBe(true); + }); + + expect(output.text()).toContain(' 01234567891011'); + }); }); From 1026401c679dc2f77ebd463fc5eb9c9e558dcc8b Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 20:59:24 +0800 Subject: [PATCH 161/675] Add terminal capability helpers --- UPSTREAM.md | 3 ++- packages/tui/src/index.ts | 2 +- packages/tui/src/terminal.ts | 14 ++++++++++++++ packages/tui/src/terminal/capabilities.ts | 20 ++++++++++++++++++++ packages/tui/tests/terminal.test.ts | 17 ++++++++++++++++- 5 files changed, 53 insertions(+), 3 deletions(-) create mode 100644 packages/tui/src/terminal/capabilities.ts diff --git a/UPSTREAM.md b/UPSTREAM.md index e78c129e..c382859e 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -34,6 +34,7 @@ TypeScript and OpenTUI internals. | Spinner static frame rendering | `packages/tui/src/status/spinner.ts`, `packages/tui/src/status/spinner/render.ts` | | Task static frame rendering, bounded logs, and stable summary symbols | `packages/tui/src/status/task.ts`, `packages/tui/src/status/task/*` | | Stream frame rendering, wrapping, and close flushing | `packages/tui/src/status/stream.ts`, `packages/tui/src/status/stream/*` | +| Terminal clear/title, true-color detection, and default colors | `packages/tui/src/terminal.ts`, `packages/tui/src/terminal/*` | | ANSI, wrapping, truncation, Unicode width | `packages/tui/src/strings.ts` | | OpenTUI adapter | `packages/tui/src/opentui.ts` | | Type contracts | `packages/tui/src/types.ts`, `packages/tui/src/contracts/*` | @@ -54,7 +55,7 @@ later phases: - Spinner animated frame and terminal cleanup parity - Stream true-color fade and terminal cleanup parity - Task animated frame, terminal cleanup, and process-log parity -- Terminal +- Terminal raw TTY restoration and interactive read parity - Textarea - Default theme - Feature parity tests diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index 4e6bb0a7..6e016ed2 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -8,7 +8,7 @@ export { alert, clear, dataTable, datatable, error, grid, info, intro, note, not export type { NotificationCommand } from '#tui/output'; export { PromptValidationError } from '#tui/prompt'; export { Logger, Progress, Stream, progress, spin, stream, task } from '#tui/status'; -export { clearTerminal, cursorToStart, eraseLine, setTerminalTitle, terminalSize } from '#tui/terminal'; +export { backgroundColor, clearTerminal, cursorToStart, eraseLine, foregroundColor, setTerminalTitle, supportsTrueColor, terminalSize } from '#tui/terminal'; export { applyTypedKey, readTypedValue } from '#tui/typed-value'; export { parseAnsiText, truncate, visibleWidth, wrap } from '#tui/strings'; export type { diff --git a/packages/tui/src/terminal.ts b/packages/tui/src/terminal.ts index ff6431d2..f62aebf4 100644 --- a/packages/tui/src/terminal.ts +++ b/packages/tui/src/terminal.ts @@ -1,4 +1,6 @@ import { promptEnvironment } from '#tui/environment'; +import { defaultBackgroundColor, defaultForegroundColor, parseTerminalColor, terminalSupportsTrueColor } from '#tui/terminal/capabilities'; +import type { TerminalColor } from '#tui/terminal/capabilities'; export type TerminalSize = { columns: number; @@ -25,3 +27,15 @@ export const eraseLine = (): void => { export const cursorToStart = (): void => { promptEnvironment().output.write('\r'); }; + +export const supportsTrueColor = (value?: string): boolean => { + return terminalSupportsTrueColor(value); +}; + +export const foregroundColor = (value?: unknown): TerminalColor => { + return parseTerminalColor(value, defaultForegroundColor()); +}; + +export const backgroundColor = (value?: unknown): TerminalColor => { + return parseTerminalColor(value, defaultBackgroundColor()); +}; diff --git a/packages/tui/src/terminal/capabilities.ts b/packages/tui/src/terminal/capabilities.ts new file mode 100644 index 00000000..4f0a1404 --- /dev/null +++ b/packages/tui/src/terminal/capabilities.ts @@ -0,0 +1,20 @@ +import { z } from 'zod'; + +export type TerminalColor = readonly [number, number, number]; + +const TRUE_COLOR_TERMS = new Set(['truecolor', '24bit']); +const colorSchema = z.tuple([z.number().int().min(0).max(255), z.number().int().min(0).max(255), z.number().int().min(0).max(255)]); + +export const defaultForegroundColor = (): TerminalColor => [204, 204, 204]; + +export const defaultBackgroundColor = (): TerminalColor => [0, 0, 0]; + +export const terminalSupportsTrueColor = (value = process.env.COLORTERM): boolean => { + return TRUE_COLOR_TERMS.has(value ?? ''); +}; + +export const parseTerminalColor = (value: unknown, fallback: TerminalColor): TerminalColor => { + const parsed = colorSchema.safeParse(value); + + return parsed.success ? parsed.data : fallback; +}; diff --git a/packages/tui/tests/terminal.test.ts b/packages/tui/tests/terminal.test.ts index f50e2e6b..a71dc24f 100644 --- a/packages/tui/tests/terminal.test.ts +++ b/packages/tui/tests/terminal.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { clear, clearTerminal, createMemoryOutput, cursorToStart, eraseLine, setTerminalTitle, title, withPromptEnvironment } from '#tui/index'; + +import { backgroundColor, clear, clearTerminal, createMemoryOutput, cursorToStart, eraseLine, foregroundColor, setTerminalTitle, supportsTrueColor, title, withPromptEnvironment } from '#tui/index'; describe('terminal helpers', () => { it('writes clear terminal control sequences', async () => { @@ -34,4 +35,18 @@ describe('terminal helpers', () => { expect(output.text()).toBe('\r\u001B[2K'); }); + + it('detects true color terminal modes', () => { + expect(supportsTrueColor('truecolor')).toBe(true); + expect(supportsTrueColor('24bit')).toBe(true); + expect(supportsTrueColor('256color')).toBe(false); + expect(supportsTrueColor(undefined)).toBe(supportsTrueColor(process.env.COLORTERM)); + }); + + it('parses terminal colors with default fallbacks', () => { + expect(foregroundColor([1, 2, 3])).toEqual([1, 2, 3]); + expect(backgroundColor([4, 5, 6])).toEqual([4, 5, 6]); + expect(foregroundColor(['1', 2, 3])).toEqual([204, 204, 204]); + expect(backgroundColor([0, 0, 300])).toEqual([0, 0, 0]); + }); }); From 1c5eb8793801c894c86a591d3268736156ed22e6 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 21:02:11 +0800 Subject: [PATCH 162/675] Align textarea control navigation --- UPSTREAM.md | 3 +- .../tui/src/typed-value/edit/navigation.ts | 4 +-- packages/tui/tests/textarea.test.ts | 32 +++++++++++++++++++ packages/tui/tests/typed-value.test.ts | 6 ++-- 4 files changed, 39 insertions(+), 6 deletions(-) diff --git a/UPSTREAM.md b/UPSTREAM.md index c382859e..7f793603 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -17,6 +17,7 @@ TypeScript and OpenTUI internals. | Terminal helpers | `packages/tui/src/terminal.ts` | | Text input | `packages/tui/src/prompts/text.ts`, `packages/tui/src/typed-value.ts` | | Textarea input | `packages/tui/src/prompts/textarea.ts`, `packages/tui/src/typed-value.ts` | +| Textarea control-key line navigation and empty-line movement | `packages/tui/src/typed-value/edit/navigation.ts`, `packages/tui/tests/textarea.test.ts` | | Password input | `packages/tui/src/prompts/password.ts`, `packages/tui/src/typed-value.ts` | | Number input | `packages/tui/src/prompts/number.ts`, `packages/tui/src/typed-value.ts` | | Confirm, select, multiselect | `packages/tui/src/prompts/select/*` | @@ -56,7 +57,7 @@ later phases: - Stream true-color fade and terminal cleanup parity - Task animated frame, terminal cleanup, and process-log parity - Terminal raw TTY restoration and interactive read parity -- Textarea +- Textarea boxed renderer, wrapped cursor, and scrollbar parity - Default theme - Feature parity tests diff --git a/packages/tui/src/typed-value/edit/navigation.ts b/packages/tui/src/typed-value/edit/navigation.ts index 3db7c4bd..82a2911c 100644 --- a/packages/tui/src/typed-value/edit/navigation.ts +++ b/packages/tui/src/typed-value/edit/navigation.ts @@ -10,11 +10,11 @@ export const moveTypedValueCursor = (value: string[], cursor: number, key: strin return Math.min(value.length, cursor + 1); } - if (allowNewLine && (key === Key.up || key === Key.upArrow)) { + if (allowNewLine && (key === Key.up || key === Key.upArrow || key === Key.ctrlP)) { return moveLine(value, cursor, -1); } - if (allowNewLine && (key === Key.down || key === Key.downArrow)) { + if (allowNewLine && (key === Key.down || key === Key.downArrow || key === Key.ctrlN)) { return moveLine(value, cursor, 1); } diff --git a/packages/tui/tests/textarea.test.ts b/packages/tui/tests/textarea.test.ts index b6fe7a97..54e0869d 100644 --- a/packages/tui/tests/textarea.test.ts +++ b/packages/tui/tests/textarea.test.ts @@ -54,4 +54,36 @@ describe('textarea prompt', () => { expect(latestFrame).toContain('B\nC'); expect(latestFrame).not.toContain('A\nB\nC'); }); + + it('uses control navigation keys to move between textarea lines', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['e', 's', 's', Key.enter, 'o', 'e', Key.ctrlP, Key.left, Key.left, 'J', Key.ctrlN, Key.left, 'J', Key.ctrlD]), + output, + error: output, + interactive: true, + }, + () => textarea('Description'), + ); + + expect(result).toBe('Jess\nJoe'); + }); + + it('can move back to an empty textarea line', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['J', 'e', 's', 's', Key.enter, Key.up, Key.down, 'J', 'o', 'e', Key.ctrlD]), + output, + error: output, + interactive: true, + }, + () => textarea('Description'), + ); + + expect(result).toBe('Jess\nJoe'); + }); }); diff --git a/packages/tui/tests/typed-value.test.ts b/packages/tui/tests/typed-value.test.ts index 631baed0..e6727299 100644 --- a/packages/tui/tests/typed-value.test.ts +++ b/packages/tui/tests/typed-value.test.ts @@ -119,14 +119,14 @@ describe('typed value editing', () => { }); }); - it('does not move between textarea lines with control navigation keys', () => { + it('moves between textarea lines with control navigation keys', () => { expect(applyMultilineState('abc\nde\nfghi', 6, [Key.ctrlP])).toEqual({ - cursor: 6, + cursor: 2, value: 'abc\nde\nfghi', }); expect(applyMultilineState('abc\nde\nfghi', 2, [Key.ctrlN])).toEqual({ - cursor: 2, + cursor: 6, value: 'abc\nde\nfghi', }); }); From 59684fbbf8f31013dc29fda84d4a1f90a1d51a51 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 21:06:13 +0800 Subject: [PATCH 163/675] Split notification command handling --- UPSTREAM.md | 3 +- packages/tui/src/index.ts | 2 +- packages/tui/src/output.ts | 2 +- packages/tui/src/output/notify.ts | 44 +++++++-------- packages/tui/src/output/notify/commands.ts | 53 +++++++++++++++++++ .../src/output/notify/validators/options.ts | 21 ++++++++ packages/tui/tests/notify.test.ts | 20 ++++++- 7 files changed, 116 insertions(+), 29 deletions(-) create mode 100644 packages/tui/src/output/notify/commands.ts create mode 100644 packages/tui/src/output/notify/validators/options.ts diff --git a/UPSTREAM.md b/UPSTREAM.md index 7f793603..8500c4f8 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -28,6 +28,7 @@ TypeScript and OpenTUI internals. | Form builder | `packages/tui/src/form.ts`, `packages/tui/src/form/builder/*` | | Notes and alert-style output | `packages/tui/src/output/notes.ts` | | Native notifications | `packages/tui/src/output/notify.ts` | +| Notification command construction and Linux fallback command ordering | `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/data-table.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` | @@ -49,7 +50,7 @@ later phases: - Form builder - Multi-search full rendering and selected-result summary parity - Multi-select -- Notifications +- Notifications executable discovery and process-result parity - Progress signal handling and terminal cleanup parity - Prompt lifecycle - Search full rendering and highlighted result label parity diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index 6e016ed2..3c8c5814 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -4,7 +4,7 @@ export { Key, keyFromEvent, oneOf } from '#tui/key'; export { renderOpenTuiTextFrame } from '#tui/opentui'; export { confirm, select, multiselect, suggest, search, multisearch, autocomplete, pause } from '#tui/prompts/choices'; export { number, password, text, textarea } from '#tui/prompts/basic'; -export { alert, clear, dataTable, datatable, error, grid, info, intro, note, notificationCommand, notify, notifyForPlatform, outro, table, title, warning } from '#tui/output'; +export { alert, clear, dataTable, datatable, error, grid, info, intro, note, notificationCommand, notificationCommands, notify, notifyForPlatform, outro, table, title, warning } from '#tui/output'; export type { NotificationCommand } from '#tui/output'; export { PromptValidationError } from '#tui/prompt'; export { Logger, Progress, Stream, progress, spin, stream, task } from '#tui/status'; diff --git a/packages/tui/src/output.ts b/packages/tui/src/output.ts index ee6782b7..b4ceacd7 100644 --- a/packages/tui/src/output.ts +++ b/packages/tui/src/output.ts @@ -1,6 +1,6 @@ export { alert, error, info, intro, note, outro, warning } from '#tui/output/notes'; export type { NoteType } from '#tui/output/notes'; -export { notificationCommand, notify, notifyForPlatform } from '#tui/output/notify'; +export { notificationCommand, notificationCommands, notify, notifyForPlatform } from '#tui/output/notify'; export type { NotificationCommand } from '#tui/output/notify'; export { datatable } from '#tui/output/data-table'; export { dataTable, grid, table } from '#tui/output/table'; diff --git a/packages/tui/src/output/notify.ts b/packages/tui/src/output/notify.ts index 7f11c535..aa485a6b 100644 --- a/packages/tui/src/output/notify.ts +++ b/packages/tui/src/output/notify.ts @@ -1,39 +1,33 @@ -import { spawn } from 'node:child_process'; +import { spawn, spawnSync } from 'node:child_process'; import { platform } from 'node:process'; import { note } from '#tui/output/notes'; +import { notificationCommands } from '#tui/output/notify/commands'; +import type { NotificationCommand } from '#tui/output/notify/commands'; -export type NotificationCommand = { - args: string[]; - bin: string; -}; +export type { NotificationCommand }; +export { notificationCommands }; -export const notificationCommand = (targetPlatform: NodeJS.Platform, title: string, body = '', subtitle = '', sound = '', icon = ''): NotificationCommand | null => { - if (targetPlatform === 'darwin') { - const script = [ - 'display notification', - JSON.stringify(body), - 'with title', - JSON.stringify(title), - subtitle ? `subtitle ${JSON.stringify(subtitle)}` : '', - sound ? `sound name ${JSON.stringify(sound)}` : '', - ] - .filter(Boolean) - .join(' '); - - return { args: ['-e', script], bin: 'osascript' }; - } +const commandExists = (bin: string): boolean => { + const result = spawnSync('command', ['-v', bin], { shell: true, stdio: 'ignore' }); - if (targetPlatform === 'linux') { - const args = icon ? ['--icon', icon, title, body] : [title, body]; + return result.status === 0; +}; - return { args, bin: 'notify-send' }; +const availableNotificationCommand = (targetPlatform: NodeJS.Platform, commands: NotificationCommand[]): NotificationCommand | null => { + if (targetPlatform !== 'linux') { + return commands.at(0) ?? null; } - return null; + return commands.find((command) => commandExists(command.bin)) ?? null; +}; + +export const notificationCommand = (targetPlatform: NodeJS.Platform, title: string, body = '', subtitle = '', sound = '', icon = ''): NotificationCommand | null => { + return notificationCommands(targetPlatform, { body, icon, sound, subtitle, title }).at(0) ?? null; }; export const notifyForPlatform = (targetPlatform: NodeJS.Platform, title: string, body = '', subtitle = '', sound = '', icon = ''): void => { - const command = notificationCommand(targetPlatform, title, body, subtitle, sound, icon); + const commands = notificationCommands(targetPlatform, { body, icon, sound, subtitle, title }); + const command = availableNotificationCommand(targetPlatform, commands); if (command) { spawn(command.bin, command.args, { detached: true, stdio: 'ignore' }).unref(); diff --git a/packages/tui/src/output/notify/commands.ts b/packages/tui/src/output/notify/commands.ts new file mode 100644 index 00000000..6ca4aa90 --- /dev/null +++ b/packages/tui/src/output/notify/commands.ts @@ -0,0 +1,53 @@ +import { parseNotificationOptions } from '#tui/output/notify/validators/options'; +import type { NotificationOptions } from '#tui/output/notify/validators/options'; + +export type NotificationCommand = { + args: string[]; + bin: string; +}; + +const macOsNotificationCommand = (options: NotificationOptions): NotificationCommand => { + const script = [ + 'display notification', + JSON.stringify(options.body), + 'with title', + JSON.stringify(options.title), + options.subtitle ? `subtitle ${JSON.stringify(options.subtitle)}` : '', + options.sound ? `sound name ${JSON.stringify(options.sound)}` : '', + ] + .filter(Boolean) + .join(' '); + + return { args: ['-e', script], bin: 'osascript' }; +}; + +const notifySendCommand = (options: NotificationOptions): NotificationCommand => { + const args = options.icon ? ['--icon', options.icon, options.title] : [options.title]; + + if (options.body) { + args.push(options.body); + } + + return { args, bin: 'notify-send' }; +}; + +const kdialogCommand = (options: NotificationOptions): NotificationCommand => { + const message = options.body ? `${options.title}: ${options.body}` : options.title; + const args = ['--passivepopup', message, '5', '--title', options.title]; + + return { args, bin: 'kdialog' }; +}; + +export const notificationCommands = (targetPlatform: NodeJS.Platform, options: unknown): NotificationCommand[] => { + const parsed = parseNotificationOptions(options); + + if (targetPlatform === 'darwin') { + return [macOsNotificationCommand(parsed)]; + } + + if (targetPlatform === 'linux') { + return [notifySendCommand(parsed), kdialogCommand(parsed)]; + } + + return []; +}; diff --git a/packages/tui/src/output/notify/validators/options.ts b/packages/tui/src/output/notify/validators/options.ts new file mode 100644 index 00000000..bcca0f69 --- /dev/null +++ b/packages/tui/src/output/notify/validators/options.ts @@ -0,0 +1,21 @@ +import { z } from 'zod'; + +export type NotificationOptions = { + body: string; + icon: string; + sound: string; + subtitle: string; + title: string; +}; + +const notificationOptionsSchema = z.object({ + body: z.string().default(''), + icon: z.string().default(''), + sound: z.string().default(''), + subtitle: z.string().default(''), + title: z.string(), +}); + +export const parseNotificationOptions = (options: unknown): NotificationOptions => { + return notificationOptionsSchema.parse(options); +}; diff --git a/packages/tui/tests/notify.test.ts b/packages/tui/tests/notify.test.ts index da219058..55e72e50 100644 --- a/packages/tui/tests/notify.test.ts +++ b/packages/tui/tests/notify.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { createMemoryOutput, notificationCommand, notifyForPlatform, withPromptEnvironment } from '#tui/index'; +import { createMemoryOutput, notificationCommand, notificationCommands, notifyForPlatform, withPromptEnvironment } from '#tui/index'; describe('notify helper', () => { it('builds macOS notification commands', () => { @@ -19,12 +19,30 @@ describe('notify helper', () => { bin: 'notify-send', }); + expect(notificationCommand('linux', 'Deploy')).toEqual({ + args: ['Deploy'], + bin: 'notify-send', + }); + expect(notificationCommand('linux', 'Deploy', 'Done', '', '', '/tmp/icon.png')).toEqual({ args: ['--icon', '/tmp/icon.png', 'Deploy', 'Done'], bin: 'notify-send', }); }); + it('builds Linux fallback notification commands in availability order', () => { + expect(notificationCommands('linux', { title: 'Deploy', body: 'Done' })).toEqual([ + { + args: ['Deploy', 'Done'], + bin: 'notify-send', + }, + { + args: ['--passivepopup', 'Deploy: Done', '5', '--title', 'Deploy'], + bin: 'kdialog', + }, + ]); + }); + it('falls back to note output on unsupported platforms', async () => { const output = createMemoryOutput(); From 18f3172a28f6433d53f455f4ea38d9253c274d52 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 21:10:59 +0800 Subject: [PATCH 164/675] Align autocomplete ghost rendering --- UPSTREAM.md | 3 ++- .../tui/src/prompts/suggest/autocomplete.ts | 4 ++-- .../tui/src/prompts/suggest/ghost-text.ts | 24 +++++++++++++++++++ .../src/prompts/suggest/read-autocomplete.ts | 10 ++++---- .../prompts/suggest/render-autocomplete.ts | 22 ++++------------- packages/tui/tests/suggest.test.ts | 19 +++++++++++++++ 6 files changed, 56 insertions(+), 26 deletions(-) create mode 100644 packages/tui/src/prompts/suggest/ghost-text.ts diff --git a/UPSTREAM.md b/UPSTREAM.md index 8500c4f8..6548ac75 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -22,6 +22,7 @@ TypeScript and OpenTUI internals. | Number input | `packages/tui/src/prompts/number.ts`, `packages/tui/src/typed-value.ts` | | Confirm, select, multiselect | `packages/tui/src/prompts/select/*` | | Suggest, autocomplete | `packages/tui/src/prompts/suggest/*` | +| Autocomplete 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/tests/suggest.test.ts` | | Search, multisearch | `packages/tui/src/prompts/search/*` | | Pause | `packages/tui/src/prompts/pause.ts` | | Choice normalization, matching, navigation, and list rendering concerns | `packages/tui/src/concerns/choices.ts` | @@ -46,7 +47,7 @@ TypeScript and OpenTUI internals. The following reference areas still need full line-by-line behavioral mapping in later phases: -- Autocomplete advanced ghost-text rendering +- Autocomplete styled inverse/dim ghost-text rendering - Form builder - Multi-search full rendering and selected-result summary parity - Multi-select diff --git a/packages/tui/src/prompts/suggest/autocomplete.ts b/packages/tui/src/prompts/suggest/autocomplete.ts index cbc8102b..c0c5976e 100644 --- a/packages/tui/src/prompts/suggest/autocomplete.ts +++ b/packages/tui/src/prompts/suggest/autocomplete.ts @@ -24,9 +24,9 @@ export const acceptAutocompleteMatch = (state: TypedValueState, match: string | return null; } - if (requireGrowth && match.length <= state.value.length) { + if (requireGrowth && characterLength(match) <= characterLength(state.value)) { return null; } - return { cursor: match.length, value: match }; + return { cursor: characterLength(match), value: match }; }; diff --git a/packages/tui/src/prompts/suggest/ghost-text.ts b/packages/tui/src/prompts/suggest/ghost-text.ts new file mode 100644 index 00000000..dfedbad8 --- /dev/null +++ b/packages/tui/src/prompts/suggest/ghost-text.ts @@ -0,0 +1,24 @@ +import { characterLength, characters, fromCharacters } from '#tui/typed-value/characters'; +import type { TypedValueState } from '#tui/typed-value/types'; + +const startsWithInput = (match: string, value: string): boolean => match.toLowerCase().startsWith(value.toLowerCase()); + +export const autocompleteGhostText = (value: string, match: string | undefined): string => { + if (value.length === 0 || match === undefined || !startsWithInput(match, value) || characterLength(match) <= characterLength(value)) { + return ''; + } + + return fromCharacters(characters(match).slice(characterLength(value))); +}; + +export const autocompleteDisplayValue = (state: TypedValueState, match: string | undefined, placeholder = ''): string => { + if (state.value.length === 0) { + return placeholder; + } + + if (state.cursor < characterLength(state.value)) { + return state.value; + } + + return `${state.value}${autocompleteGhostText(state.value, match)}`; +}; diff --git a/packages/tui/src/prompts/suggest/read-autocomplete.ts b/packages/tui/src/prompts/suggest/read-autocomplete.ts index 5c14b8fa..5b58c0fb 100644 --- a/packages/tui/src/prompts/suggest/read-autocomplete.ts +++ b/packages/tui/src/prompts/suggest/read-autocomplete.ts @@ -23,7 +23,7 @@ export const readAutocompleteValue = async (options: SuggestOptions): Promise { - return match.toLowerCase().startsWith(value.toLowerCase()); -}; - -const completionValue = (value: string, match: string | undefined, placeholder = ''): string => { - if (value.length === 0) { - return placeholder; - } - - if (match === undefined || !startsWithInput(match, value) || match.length <= value.length) { - return value; - } - - return value + match.slice(value.length); -}; - -export const renderAutocomplete = (message: string, value: string, matches: string[], highlighted: number, hint = '', placeholder = ''): void => { - promptEnvironment().output.write(`${renderQuestion(message, hint)}${completionValue(value, matches[highlighted], placeholder)}\n`); +export const renderAutocomplete = (message: string, state: TypedValueState, matches: string[], highlighted: number, hint = '', placeholder = ''): void => { + promptEnvironment().output.write(`${renderQuestion(message, hint)}${autocompleteDisplayValue(state, matches[highlighted], placeholder)}\n`); }; diff --git a/packages/tui/tests/suggest.test.ts b/packages/tui/tests/suggest.test.ts index 2c4d63ed..5afd9632 100644 --- a/packages/tui/tests/suggest.test.ts +++ b/packages/tui/tests/suggest.test.ts @@ -177,6 +177,25 @@ describe('autocomplete prompt', () => { expect(result).toBe('b'); }); + it('hides autocomplete ghost text while editing before the end', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment( + { + input: createScriptedInput(['b', Key.left, Key.enter]), + output, + error: output, + interactive: true, + }, + () => autocomplete('Favorite color?', ['Blue']), + ); + + const frames = output.text().trimEnd().split('\n'); + + expect(frames.at(-1)).toContain('Favorite color? b'); + expect(frames.at(-1)).not.toContain('Blue'); + }); + it('accepts same-length autocomplete matches with right arrow', async () => { const output = createMemoryOutput(); From 961b0b1939c258afcaf0ae16a19319c9c78ddb51 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 21:14:17 +0800 Subject: [PATCH 165/675] Report notification execution results --- UPSTREAM.md | 3 +- packages/tui/src/index.ts | 24 +++++++++++++-- packages/tui/src/output.ts | 4 +-- packages/tui/src/output/notify.ts | 34 +++++++--------------- packages/tui/src/output/notify/executor.ts | 27 +++++++++++++++++ packages/tui/tests/notify.test.ts | 32 +++++++++++++++++++- 6 files changed, 94 insertions(+), 30 deletions(-) create mode 100644 packages/tui/src/output/notify/executor.ts diff --git a/UPSTREAM.md b/UPSTREAM.md index 6548ac75..88e5b553 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -29,7 +29,7 @@ TypeScript and OpenTUI internals. | Form builder | `packages/tui/src/form.ts`, `packages/tui/src/form/builder/*` | | Notes and alert-style output | `packages/tui/src/output/notes.ts` | | Native notifications | `packages/tui/src/output/notify.ts` | -| Notification command construction and Linux fallback command ordering | `packages/tui/src/output/notify.ts`, `packages/tui/src/output/notify/*` | +| 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/data-table.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` | @@ -51,7 +51,6 @@ later phases: - Form builder - Multi-search full rendering and selected-result summary parity - Multi-select -- Notifications executable discovery and process-result parity - Progress signal handling and terminal cleanup parity - Prompt lifecycle - Search full rendering and highlighted result label parity diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index 3c8c5814..0000653c 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -4,8 +4,28 @@ export { Key, keyFromEvent, oneOf } from '#tui/key'; export { renderOpenTuiTextFrame } from '#tui/opentui'; export { confirm, select, multiselect, suggest, search, multisearch, autocomplete, pause } from '#tui/prompts/choices'; export { number, password, text, textarea } from '#tui/prompts/basic'; -export { alert, clear, dataTable, datatable, error, grid, info, intro, note, notificationCommand, notificationCommands, notify, notifyForPlatform, outro, table, title, warning } from '#tui/output'; -export type { NotificationCommand } from '#tui/output'; +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 { NotificationCommand, NotificationRuntime } from '#tui/output'; export { PromptValidationError } from '#tui/prompt'; export { Logger, Progress, Stream, progress, spin, stream, task } from '#tui/status'; export { backgroundColor, clearTerminal, cursorToStart, eraseLine, foregroundColor, setTerminalTitle, supportsTrueColor, terminalSize } from '#tui/terminal'; diff --git a/packages/tui/src/output.ts b/packages/tui/src/output.ts index b4ceacd7..348e1e0a 100644 --- a/packages/tui/src/output.ts +++ b/packages/tui/src/output.ts @@ -1,7 +1,7 @@ export { alert, error, info, intro, note, outro, warning } from '#tui/output/notes'; export type { NoteType } from '#tui/output/notes'; -export { notificationCommand, notificationCommands, notify, notifyForPlatform } from '#tui/output/notify'; -export type { NotificationCommand } from '#tui/output/notify'; +export { commandExists, executeNotificationCommand, notificationCommand, notificationCommands, notify, notifyForPlatform } from '#tui/output/notify'; +export type { NotificationCommand, 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/notify.ts b/packages/tui/src/output/notify.ts index aa485a6b..1609fe1f 100644 --- a/packages/tui/src/output/notify.ts +++ b/packages/tui/src/output/notify.ts @@ -1,43 +1,31 @@ -import { spawn, spawnSync } from 'node:child_process'; import { platform } from 'node:process'; import { note } from '#tui/output/notes'; import { notificationCommands } from '#tui/output/notify/commands'; +import { availableNotificationCommand, commandExists, executeNotificationCommand } from '#tui/output/notify/executor'; import type { NotificationCommand } from '#tui/output/notify/commands'; +import type { NotificationRuntime } from '#tui/output/notify/executor'; +export type { NotificationRuntime }; export type { NotificationCommand }; -export { notificationCommands }; - -const commandExists = (bin: string): boolean => { - const result = spawnSync('command', ['-v', bin], { shell: true, stdio: 'ignore' }); - - return result.status === 0; -}; - -const availableNotificationCommand = (targetPlatform: NodeJS.Platform, commands: NotificationCommand[]): NotificationCommand | null => { - if (targetPlatform !== 'linux') { - return commands.at(0) ?? null; - } - - return commands.find((command) => commandExists(command.bin)) ?? null; -}; +export { commandExists, executeNotificationCommand, notificationCommands }; export const notificationCommand = (targetPlatform: NodeJS.Platform, title: string, body = '', subtitle = '', sound = '', icon = ''): NotificationCommand | null => { return notificationCommands(targetPlatform, { body, icon, sound, subtitle, title }).at(0) ?? null; }; -export const notifyForPlatform = (targetPlatform: NodeJS.Platform, title: string, body = '', subtitle = '', sound = '', icon = ''): void => { +export const notifyForPlatform = (targetPlatform: NodeJS.Platform, title: string, body = '', subtitle = '', sound = '', icon = '', runtime: NotificationRuntime = {}): boolean => { const commands = notificationCommands(targetPlatform, { body, icon, sound, subtitle, title }); - const command = availableNotificationCommand(targetPlatform, commands); + const command = availableNotificationCommand(targetPlatform, commands, runtime.commandExists ?? commandExists); if (command) { - spawn(command.bin, command.args, { detached: true, stdio: 'ignore' }).unref(); - - return; + return (runtime.execute ?? executeNotificationCommand)(command); } note(body ? `${title}: ${body}` : title, 'info'); + + return false; }; -export const notify = (title: string, body = '', subtitle = '', sound = '', icon = ''): void => { - notifyForPlatform(platform, title, body, subtitle, sound, icon); +export const notify = (title: string, body = '', subtitle = '', sound = '', icon = ''): boolean => { + return notifyForPlatform(platform, title, body, subtitle, sound, icon); }; diff --git a/packages/tui/src/output/notify/executor.ts b/packages/tui/src/output/notify/executor.ts new file mode 100644 index 00000000..bf282cc9 --- /dev/null +++ b/packages/tui/src/output/notify/executor.ts @@ -0,0 +1,27 @@ +import { spawnSync } from 'node:child_process'; +import type { NotificationCommand } from '#tui/output/notify/commands'; + +export type NotificationRuntime = { + commandExists?: (bin: string) => boolean; + execute?: (command: NotificationCommand) => boolean; +}; + +export const commandExists = (bin: string): boolean => { + const result = spawnSync('command', ['-v', bin], { shell: true, stdio: 'ignore' }); + + return result.status === 0; +}; + +export const executeNotificationCommand = (command: NotificationCommand): boolean => { + const result = spawnSync(command.bin, command.args, { stdio: 'ignore' }); + + return result.status === 0; +}; + +export const availableNotificationCommand = (targetPlatform: NodeJS.Platform, commands: NotificationCommand[], exists: (bin: string) => boolean = commandExists): NotificationCommand | null => { + if (targetPlatform !== 'linux') { + return commands.at(0) ?? null; + } + + return commands.find((command) => exists(command.bin)) ?? null; +}; diff --git a/packages/tui/tests/notify.test.ts b/packages/tui/tests/notify.test.ts index 55e72e50..d3243e67 100644 --- a/packages/tui/tests/notify.test.ts +++ b/packages/tui/tests/notify.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import { createMemoryOutput, notificationCommand, notificationCommands, notifyForPlatform, withPromptEnvironment } from '#tui/index'; +import type { NotificationCommand } from '#tui/index'; describe('notify helper', () => { it('builds macOS notification commands', () => { @@ -47,9 +48,38 @@ describe('notify helper', () => { const output = createMemoryOutput(); await withPromptEnvironment({ output, error: output }, async () => { - notifyForPlatform('freebsd', 'Deploy', 'Done'); + expect(notifyForPlatform('freebsd', 'Deploy', 'Done')).toBe(false); }); expect(output.text()).toContain('Deploy: Done'); }); + + it('executes the first available Linux notifier and reports the process result', () => { + const executed: NotificationCommand[] = []; + + const result = notifyForPlatform('linux', 'Deploy', 'Done', '', '', '', { + commandExists: (bin) => bin === 'kdialog', + execute: (command) => { + executed.push(command); + + return true; + }, + }); + + expect(result).toBe(true); + expect(executed).toEqual([ + { + args: ['--passivepopup', 'Deploy: Done', '5', '--title', 'Deploy'], + bin: 'kdialog', + }, + ]); + }); + + it('returns false when notification command execution fails', () => { + const result = notifyForPlatform('darwin', 'Deploy', 'Done', '', '', '', { + execute: () => false, + }); + + expect(result).toBe(false); + }); }); From 6e15e1835fd59d9c3b8ffea1c46b92f2e649e939 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 21:16:37 +0800 Subject: [PATCH 166/675] Restore raw key input on lifecycle events --- UPSTREAM.md | 4 +- packages/tui/src/environment/default-input.ts | 27 +------- packages/tui/src/environment/raw-key.ts | 62 ++++++++++++++++++ packages/tui/tests/environment.test.ts | 65 +++++++++++++++++++ 4 files changed, 131 insertions(+), 27 deletions(-) create mode 100644 packages/tui/src/environment/raw-key.ts diff --git a/UPSTREAM.md b/UPSTREAM.md index 88e5b553..23dbeb57 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -37,7 +37,7 @@ TypeScript and OpenTUI internals. | Spinner static frame rendering | `packages/tui/src/status/spinner.ts`, `packages/tui/src/status/spinner/render.ts` | | Task static frame rendering, bounded logs, and stable summary symbols | `packages/tui/src/status/task.ts`, `packages/tui/src/status/task/*` | | Stream frame rendering, wrapping, and close flushing | `packages/tui/src/status/stream.ts`, `packages/tui/src/status/stream/*` | -| Terminal clear/title, true-color detection, and default colors | `packages/tui/src/terminal.ts`, `packages/tui/src/terminal/*` | +| Terminal clear/title, 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` | | ANSI, wrapping, truncation, Unicode width | `packages/tui/src/strings.ts` | | OpenTUI adapter | `packages/tui/src/opentui.ts` | | Type contracts | `packages/tui/src/types.ts`, `packages/tui/src/contracts/*` | @@ -57,7 +57,7 @@ later phases: - Spinner animated frame and terminal cleanup parity - Stream true-color fade and terminal cleanup parity - Task animated frame, terminal cleanup, and process-log parity -- Terminal raw TTY restoration and interactive read parity +- Terminal interactive read parity - Textarea boxed renderer, wrapped cursor, and scrollbar parity - Default theme - Feature parity tests diff --git a/packages/tui/src/environment/default-input.ts b/packages/tui/src/environment/default-input.ts index d5f01227..8786924b 100644 --- a/packages/tui/src/environment/default-input.ts +++ b/packages/tui/src/environment/default-input.ts @@ -1,34 +1,11 @@ import { stdin as defaultStdin, stdout as defaultStdout } from 'node:process'; import { createInterface } from 'node:readline/promises'; +import { readRawKey } from '#tui/environment/raw-key'; import type { PromptInput } from '#tui/types'; export const defaultInput: PromptInput = { async readKey(): Promise { - return new Promise((resolve) => { - const input = defaultStdin; - const wasRaw = input.isRaw; - - const cleanup = (): void => { - input.off('data', onData); - - if (input.isTTY) { - input.setRawMode(wasRaw); - input.pause(); - } - }; - - const onData = (chunk: Buffer): void => { - cleanup(); - resolve(chunk.toString('utf8')); - }; - - input.once('data', onData); - - if (input.isTTY) { - input.setRawMode(true); - input.resume(); - } - }); + return readRawKey(defaultStdin); }, async readLine(message: string): Promise { const readline = createInterface({ diff --git a/packages/tui/src/environment/raw-key.ts b/packages/tui/src/environment/raw-key.ts new file mode 100644 index 00000000..3c734cb1 --- /dev/null +++ b/packages/tui/src/environment/raw-key.ts @@ -0,0 +1,62 @@ +import type { EventEmitter } from 'node:events'; + +export type RawKeyInput = Pick & { + isRaw?: boolean; + isTTY?: boolean; + pause(): unknown; + resume(): unknown; + setRawMode?(mode: boolean): unknown; +}; + +const restoreInput = (input: RawKeyInput, wasRaw: boolean): void => { + if (!input.isTTY) { + return; + } + + try { + input.setRawMode?.(wasRaw); + } finally { + input.pause(); + } +}; + +export const readRawKey = async (input: RawKeyInput): Promise => + new Promise((resolve, reject) => { + const wasRaw = Boolean(input.isRaw); + + const cleanup = (): void => { + input.off('data', onData); + input.off('end', onEnd); + input.off('error', onError); + restoreInput(input, wasRaw); + }; + + const onData = (chunk: Buffer | string): void => { + cleanup(); + resolve(Buffer.isBuffer(chunk) ? chunk.toString('utf8') : chunk); + }; + + const onEnd = (): void => { + cleanup(); + resolve(null); + }; + + const onError = (error: Error): void => { + cleanup(); + reject(error); + }; + + input.once('data', onData); + input.once('end', onEnd); + input.once('error', onError); + + if (input.isTTY) { + try { + input.setRawMode?.(true); + input.resume(); + } catch (error) { + cleanup(); + reject(error); + } + } + }); diff --git a/packages/tui/tests/environment.test.ts b/packages/tui/tests/environment.test.ts index 6e8b2fdd..049ea4f0 100644 --- a/packages/tui/tests/environment.test.ts +++ b/packages/tui/tests/environment.test.ts @@ -1,5 +1,28 @@ 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'; + +class FakeRawInput extends EventEmitter { + isRaw = false; + isTTY = true; + paused = 0; + rawModes: boolean[] = []; + resumed = 0; + + pause(): void { + this.paused += 1; + } + + resume(): void { + this.resumed += 1; + } + + setRawMode(mode: boolean): void { + this.rawModes.push(mode); + this.isRaw = mode; + } +} describe('prompt environment', () => { it('restores the previous environment when a scoped callback fails', async () => { @@ -70,4 +93,46 @@ describe('prompt environment', () => { await expect(input.readLine?.('Question')).resolves.toBe(''); }); + + it('restores raw input mode after reading a key', async () => { + const input = new FakeRawInput(); + const key = readRawKey(input); + + expect(input.rawModes).toEqual([true]); + expect(input.resumed).toBe(1); + + input.emit('data', Buffer.from('x')); + + await expect(key).resolves.toBe('x'); + + expect(input.rawModes).toEqual([true, false]); + expect(input.paused).toBe(1); + expect(input.listenerCount('data')).toBe(0); + expect(input.listenerCount('end')).toBe(0); + expect(input.listenerCount('error')).toBe(0); + }); + + it('restores raw input mode when key input ends', async () => { + const input = new FakeRawInput(); + const key = readRawKey(input); + + input.emit('end'); + + await expect(key).resolves.toBeNull(); + + expect(input.rawModes).toEqual([true, false]); + expect(input.paused).toBe(1); + }); + + it('restores raw input mode when key input errors', async () => { + const input = new FakeRawInput(); + const key = readRawKey(input); + + input.emit('error', new Error('read failed')); + + await expect(key).rejects.toThrow('read failed'); + + expect(input.rawModes).toEqual([true, false]); + expect(input.paused).toBe(1); + }); }); From f670365ff8fb2ce7cba633f79ecf59b374f027fc Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 21:19:36 +0800 Subject: [PATCH 167/675] Style autocomplete ghost text --- UPSTREAM.md | 3 +-- packages/tui/src/prompts/suggest/ghost-text.ts | 6 +++++- packages/tui/src/theme/styles.ts | 11 +++++++++++ packages/tui/tests/suggest.test.ts | 5 +++-- 4 files changed, 20 insertions(+), 5 deletions(-) create mode 100644 packages/tui/src/theme/styles.ts diff --git a/UPSTREAM.md b/UPSTREAM.md index 23dbeb57..8c48dc9a 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -22,7 +22,7 @@ TypeScript and OpenTUI internals. | Number input | `packages/tui/src/prompts/number.ts`, `packages/tui/src/typed-value.ts` | | Confirm, select, multiselect | `packages/tui/src/prompts/select/*` | | Suggest, autocomplete | `packages/tui/src/prompts/suggest/*` | -| Autocomplete 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/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` | | Search, multisearch | `packages/tui/src/prompts/search/*` | | Pause | `packages/tui/src/prompts/pause.ts` | | Choice normalization, matching, navigation, and list rendering concerns | `packages/tui/src/concerns/choices.ts` | @@ -47,7 +47,6 @@ TypeScript and OpenTUI internals. The following reference areas still need full line-by-line behavioral mapping in later phases: -- Autocomplete styled inverse/dim ghost-text rendering - Form builder - Multi-search full rendering and selected-result summary parity - Multi-select diff --git a/packages/tui/src/prompts/suggest/ghost-text.ts b/packages/tui/src/prompts/suggest/ghost-text.ts index dfedbad8..428153f5 100644 --- a/packages/tui/src/prompts/suggest/ghost-text.ts +++ b/packages/tui/src/prompts/suggest/ghost-text.ts @@ -1,3 +1,4 @@ +import { dim, inverse } from '#tui/theme/styles'; import { characterLength, characters, fromCharacters } from '#tui/typed-value/characters'; import type { TypedValueState } from '#tui/typed-value/types'; @@ -20,5 +21,8 @@ export const autocompleteDisplayValue = (state: TypedValueState, match: string | return state.value; } - return `${state.value}${autocompleteGhostText(state.value, match)}`; + const ghostText = autocompleteGhostText(state.value, match); + const [cursorCharacter = '', ...remainingGhost] = characters(ghostText); + + return `${state.value}${inverse(cursorCharacter)}${dim(fromCharacters(remainingGhost))}`; }; diff --git a/packages/tui/src/theme/styles.ts b/packages/tui/src/theme/styles.ts new file mode 100644 index 00000000..49fd51c4 --- /dev/null +++ b/packages/tui/src/theme/styles.ts @@ -0,0 +1,11 @@ +const ansi = (open: number, close: number, value: string): string => { + if (value.length === 0) { + return ''; + } + + return `\u001B[${open}m${value}\u001B[${close}m`; +}; + +export const dim = (value: string): string => ansi(2, 22, value); + +export const inverse = (value: string): string => ansi(7, 27, value); diff --git a/packages/tui/tests/suggest.test.ts b/packages/tui/tests/suggest.test.ts index 5afd9632..272455d1 100644 --- a/packages/tui/tests/suggest.test.ts +++ b/packages/tui/tests/suggest.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { autocomplete, createMemoryOutput, createScriptedInput, Key, suggest, withPromptEnvironment } from '#tui/index'; +import { autocomplete, createMemoryOutput, createScriptedInput, Key, parseAnsiText, suggest, withPromptEnvironment } from '#tui/index'; const runSuggest = async (keys: string[], options: string[] | ((query: string) => string[])): Promise<{ output: string; result: string }> => { const output = createMemoryOutput(); @@ -140,7 +140,8 @@ describe('autocomplete prompt', () => { () => autocomplete('Favorite color?', ['Red', 'Green', 'Blue']), ); - expect(output.text()).toContain('Favorite color? blue'); + expect(parseAnsiText(output.text())).toContain('Favorite color? blue'); + expect(output.text()).toContain('b\u001B[7ml\u001B[27m\u001B[2mue\u001B[22m'); expect(output.text()).not.toContain('›'); }); From f4c097e014f1010e9977838f4a5b54532fbd4b3b Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 21:23:09 +0800 Subject: [PATCH 168/675] Render empty search results --- UPSTREAM.md | 1 + packages/tui/src/prompts/search/render.ts | 5 ++++ packages/tui/tests/search.test.ts | 36 ++++++++++++++++++++++- 3 files changed, 41 insertions(+), 1 deletion(-) diff --git a/UPSTREAM.md b/UPSTREAM.md index 8c48dc9a..cb7a3b0f 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -24,6 +24,7 @@ TypeScript and OpenTUI internals. | Suggest, autocomplete | `packages/tui/src/prompts/suggest/*` | | 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` | | Search, multisearch | `packages/tui/src/prompts/search/*` | +| Search and multisearch empty-result rendering | `packages/tui/src/prompts/search/render.ts`, `packages/tui/tests/search.test.ts` | | Pause | `packages/tui/src/prompts/pause.ts` | | Choice normalization, matching, navigation, and list rendering concerns | `packages/tui/src/concerns/choices.ts` | | Form builder | `packages/tui/src/form.ts`, `packages/tui/src/form/builder/*` | diff --git a/packages/tui/src/prompts/search/render.ts b/packages/tui/src/prompts/search/render.ts index 1d5e0b10..8031971f 100644 --- a/packages/tui/src/prompts/search/render.ts +++ b/packages/tui/src/prompts/search/render.ts @@ -2,6 +2,7 @@ import { promptEnvironment } from '#tui/environment'; import { renderInteractiveChoices } from '#tui/concerns/choices'; import { resolveInfo } from '#tui/concerns/info'; import { searchMessage } from '#tui/prompts/search/choices'; +import { dim } from '#tui/theme/styles'; import type { Choice, MultiSearchPromptOptions, SearchPromptOptions } from '#tui/types'; export const renderSearchChoices = ( @@ -17,6 +18,10 @@ export const renderSearchChoices = ( ): void => { renderInteractiveChoices(searchMessage(message, query), choices, highlighted ?? 0, marked, scroll); + if (query.length > 0 && choices.length === 0) { + promptEnvironment().output.write(`${dim(' No results.')}\n`); + } + const text = resolveInfo(info, highlighted === null ? null : (choices[highlighted]?.value ?? null)); const summary = showSelectedSummary ? selectedSummary(selectedLabels.length, selectedLabels.length - marked.size) : ''; const details = [text, summary].filter((part) => part.length > 0).join(' · '); diff --git a/packages/tui/tests/search.test.ts b/packages/tui/tests/search.test.ts index dbf63b5b..7f506311 100644 --- a/packages/tui/tests/search.test.ts +++ b/packages/tui/tests/search.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { createMemoryOutput, createScriptedInput, Key, multisearch, PromptValidationError, search, withPromptEnvironment } from '#tui/index'; +import { createMemoryOutput, createScriptedInput, Key, multisearch, parseAnsiText, PromptValidationError, search, withPromptEnvironment } from '#tui/index'; import type { SearchPromptOptions } from '#tui/index'; const colors = (value: string): Record => { @@ -160,6 +160,23 @@ describe('search prompt', () => { expect(output.text()).toContain('About red'); }); + it('renders an empty result message for unmatched search queries', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment( + { + input: createScriptedInput(['z', Key.ctrlC]), + output, + error: output, + interactive: true, + }, + () => search({ message: 'Favorite color?', options: colors, default: 'red' }), + ); + + expect(parseAnsiText(output.text())).toContain('No results.'); + expect(output.text()).toContain('\u001B[2m No results.\u001B[22m'); + }); + it('skips disabled search results while navigating', async () => { const output = createMemoryOutput(); @@ -512,6 +529,23 @@ describe('multisearch prompt', () => { expect(output.text()).toContain('0 selected'); }); + it('renders an empty result message for unmatched multisearch queries', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment( + { + input: createScriptedInput(['z', Key.enter]), + output, + error: output, + interactive: true, + }, + () => multisearch({ message: 'Favorite colors?', options: colors }), + ); + + expect(parseAnsiText(output.text())).toContain('No results.'); + expect(output.text()).toContain('\u001B[2m No results.\u001B[22m'); + }); + it('renders hidden selected counts when multisearch results are filtered', async () => { const output = createMemoryOutput(); From bb88c4ce53107398c8873411f1df6efcdf665862 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 21:25:39 +0800 Subject: [PATCH 169/675] Render multiselect checklist markers --- UPSTREAM.md | 1 + packages/tui/src/concerns/choices.ts | 19 +++++++++++++++++++ packages/tui/src/prompts/select/render.ts | 4 ++-- packages/tui/tests/choices.test.ts | 18 ++++++++++++++++++ 4 files changed, 40 insertions(+), 2 deletions(-) diff --git a/UPSTREAM.md b/UPSTREAM.md index cb7a3b0f..105f3e8b 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -21,6 +21,7 @@ TypeScript and OpenTUI internals. | Password input | `packages/tui/src/prompts/password.ts`, `packages/tui/src/typed-value.ts` | | Number input | `packages/tui/src/prompts/number.ts`, `packages/tui/src/typed-value.ts` | | Confirm, select, multiselect | `packages/tui/src/prompts/select/*` | +| Multi-select checklist markers | `packages/tui/src/concerns/choices.ts`, `packages/tui/src/prompts/select/render.ts`, `packages/tui/tests/choices.test.ts` | | Suggest, autocomplete | `packages/tui/src/prompts/suggest/*` | | 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` | | Search, multisearch | `packages/tui/src/prompts/search/*` | diff --git a/packages/tui/src/concerns/choices.ts b/packages/tui/src/concerns/choices.ts index a3dc92f7..a9a61e3e 100644 --- a/packages/tui/src/concerns/choices.ts +++ b/packages/tui/src/concerns/choices.ts @@ -94,3 +94,22 @@ export const renderInteractiveChoices = (message: string, choices: Array(message: string, choices: Array>, selected: number, marked: Set, scroll?: number): void => { + const environment = promptEnvironment(); + const window = choiceWindow(choices.length, selected, scroll); + + environment.output.write(`${message}\n`); + + for (const [offset, choice] of choices.slice(window.start, window.end).entries()) { + const index = window.start + offset; + const active = index === selected; + const checked = marked.has(index); + const pointer = active ? '›' : ' '; + const marker = checked ? '◼' : '◻'; + const disabled = choice.disabled ? ` (${typeof choice.disabled === 'string' ? choice.disabled : 'disabled'})` : ''; + const hint = choice.hint ? ` ${choice.hint}` : ''; + + environment.output.write(`${pointer} ${marker} ${choice.label}${hint}${disabled}\n`); + } +}; diff --git a/packages/tui/src/prompts/select/render.ts b/packages/tui/src/prompts/select/render.ts index 27fe18f4..7c53240d 100644 --- a/packages/tui/src/prompts/select/render.ts +++ b/packages/tui/src/prompts/select/render.ts @@ -1,5 +1,5 @@ import { promptEnvironment } from '#tui/environment'; -import { renderInteractiveChoices } from '#tui/concerns/choices'; +import { renderInteractiveChecklist, renderInteractiveChoices } from '#tui/concerns/choices'; import { resolveInfo } from '#tui/concerns/info'; import type { Choice, MultiSelectPromptOptions, SelectPromptOptions } from '#tui/types'; @@ -14,7 +14,7 @@ export const renderSelectedChoice = (message: string, choices: Array(message: string, choices: Array>, selected: number, marked: Set, scroll?: number, info?: MultiSelectPromptOptions['info']): void => { - renderInteractiveChoices(message, choices, selected, marked, scroll); + renderInteractiveChecklist(message, choices, selected, marked, scroll); const text = resolveInfo(info, choices[selected]?.value ?? null); const summary = scroll !== undefined && choices.length > scroll ? `${marked.size} selected` : ''; diff --git a/packages/tui/tests/choices.test.ts b/packages/tui/tests/choices.test.ts index b41c3082..6daddfd1 100644 --- a/packages/tui/tests/choices.test.ts +++ b/packages/tui/tests/choices.test.ts @@ -408,6 +408,24 @@ describe('choice prompts', () => { expect(output.text()).toContain('Selected: first, second'); }); + it('renders multiselect options as a checklist', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment( + { + input: createScriptedInput([Key.space, Key.enter]), + output, + error: output, + interactive: true, + }, + () => multiselect({ message: 'Pick many', options: ['first', 'second'] }), + ); + + expect(output.text()).toContain('› ◻ first'); + expect(output.text()).toContain(' ◻ second'); + expect(output.text()).toContain('› ◼ first'); + }); + it('returns keys from keyed multiselect options', async () => { const output = createMemoryOutput(); From a7f52deac94053dc877ec947237f494541ebc56b Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 21:27:34 +0800 Subject: [PATCH 170/675] Add deterministic spinner frames --- UPSTREAM.md | 4 ++-- packages/tui/src/status/spinner/render.ts | 16 +++++++++++++--- packages/tui/tests/spinner.test.ts | 7 +++++++ 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/UPSTREAM.md b/UPSTREAM.md index 105f3e8b..2f99b443 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -36,7 +36,7 @@ TypeScript and OpenTUI internals. | Terminal title and clear output | `packages/tui/src/output/terminal.ts` | | Spinner, progress, task, stream | `packages/tui/src/status/*`, `packages/tui/src/status.ts` | | Progress boxed frame rendering and formatted fractions | `packages/tui/src/status/progress/render.ts`, `packages/tui/src/theme/box.ts` | -| Spinner static frame rendering | `packages/tui/src/status/spinner.ts`, `packages/tui/src/status/spinner/render.ts` | +| Spinner static and deterministic animated frame rendering | `packages/tui/src/status/spinner.ts`, `packages/tui/src/status/spinner/render.ts` | | Task static frame rendering, bounded logs, and stable summary symbols | `packages/tui/src/status/task.ts`, `packages/tui/src/status/task/*` | | Stream frame rendering, wrapping, and close flushing | `packages/tui/src/status/stream.ts`, `packages/tui/src/status/stream/*` | | Terminal clear/title, 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` | @@ -55,7 +55,7 @@ later phases: - Progress signal handling and terminal cleanup parity - Prompt lifecycle - Search full rendering and highlighted result label parity -- Spinner animated frame and terminal cleanup parity +- Spinner terminal cleanup parity - Stream true-color fade and terminal cleanup parity - Task animated frame, terminal cleanup, and process-log parity - Terminal interactive read parity diff --git a/packages/tui/src/status/spinner/render.ts b/packages/tui/src/status/spinner/render.ts index 7de4240b..362f613d 100644 --- a/packages/tui/src/status/spinner/render.ts +++ b/packages/tui/src/status/spinner/render.ts @@ -1,5 +1,15 @@ -const STATIC_SPINNER_FRAME = '⠶'; +export const SPINNER_FRAMES = ['⠂', '⠒', '⠐', '⠰', '⠠', '⠤', '⠄', '⠆'] as const; -export const renderSpinnerFrame = (message: string): string => { - return ` ${STATIC_SPINNER_FRAME} ${message}\n`; +export const STATIC_SPINNER_FRAME = '⠶'; + +export const spinnerFrame = (count: number): string => { + const index = ((count % SPINNER_FRAMES.length) + SPINNER_FRAMES.length) % SPINNER_FRAMES.length; + + return SPINNER_FRAMES[index] ?? STATIC_SPINNER_FRAME; +}; + +export const renderSpinnerFrame = (message: string, count?: number): string => { + const frame = count === undefined ? STATIC_SPINNER_FRAME : spinnerFrame(count); + + return ` ${frame} ${message}\n`; }; diff --git a/packages/tui/tests/spinner.test.ts b/packages/tui/tests/spinner.test.ts index 4254ed01..c12b37f2 100644 --- a/packages/tui/tests/spinner.test.ts +++ b/packages/tui/tests/spinner.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import { createMemoryOutput, spin, withPromptEnvironment } from '#tui/index'; +import { renderSpinnerFrame, spinnerFrame } from '#tui/status/spinner/render'; describe('spinner helper', () => { it('runs callback-first spinner callbacks', async () => { @@ -42,4 +43,10 @@ describe('spinner helper', () => { expect(output.text()).not.toContain('Failed: Working'); expect(output.text()).not.toContain('Done: Working'); }); + + it('renders deterministic animated spinner frames', () => { + expect(Array.from({ length: 10 }, (_, index) => spinnerFrame(index))).toEqual(['⠂', '⠒', '⠐', '⠰', '⠠', '⠤', '⠄', '⠆', '⠂', '⠒']); + expect(renderSpinnerFrame('Working', 3)).toBe(' ⠰ Working\n'); + expect(renderSpinnerFrame('Working')).toBe(' ⠶ Working\n'); + }); }); From c19fa0aaa87b1ab8b792fec4a3ce4c93ee618f5a Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 21:30:27 +0800 Subject: [PATCH 171/675] Render stream fade styles --- UPSTREAM.md | 4 +-- packages/tui/src/status/stream.ts | 10 +++++- packages/tui/src/status/stream/buffer.ts | 4 +++ packages/tui/src/status/stream/fade.ts | 38 ++++++++++++++++++++++ packages/tui/src/status/stream/render.ts | 11 ++++++- packages/tui/src/theme/styles.ts | 8 +++++ packages/tui/tests/stream.test.ts | 41 ++++++++++++++++++++---- 7 files changed, 106 insertions(+), 10 deletions(-) create mode 100644 packages/tui/src/status/stream/fade.ts diff --git a/UPSTREAM.md b/UPSTREAM.md index 2f99b443..3cae3d13 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -38,7 +38,7 @@ TypeScript and OpenTUI internals. | Progress boxed frame rendering and formatted fractions | `packages/tui/src/status/progress/render.ts`, `packages/tui/src/theme/box.ts` | | Spinner static and deterministic animated frame rendering | `packages/tui/src/status/spinner.ts`, `packages/tui/src/status/spinner/render.ts` | | Task static frame rendering, bounded logs, and stable summary symbols | `packages/tui/src/status/task.ts`, `packages/tui/src/status/task/*` | -| Stream frame rendering, wrapping, and close flushing | `packages/tui/src/status/stream.ts`, `packages/tui/src/status/stream/*` | +| Stream frame rendering, wrapping, fade styles, and close flushing | `packages/tui/src/status/stream.ts`, `packages/tui/src/status/stream/*` | | Terminal clear/title, 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` | | ANSI, wrapping, truncation, Unicode width | `packages/tui/src/strings.ts` | | OpenTUI adapter | `packages/tui/src/opentui.ts` | @@ -56,7 +56,7 @@ later phases: - Prompt lifecycle - Search full rendering and highlighted result label parity - Spinner terminal cleanup parity -- Stream true-color fade and terminal cleanup parity +- Stream terminal cleanup parity - Task animated frame, terminal cleanup, and process-log parity - Terminal interactive read parity - Textarea boxed renderer, wrapped cursor, and scrollbar parity diff --git a/packages/tui/src/status/stream.ts b/packages/tui/src/status/stream.ts index 1ecaccab..b36a30a1 100644 --- a/packages/tui/src/status/stream.ts +++ b/packages/tui/src/status/stream.ts @@ -1,10 +1,12 @@ import { promptEnvironment } from '#tui/environment'; import { StreamBuffer } from '#tui/status/stream/buffer'; +import { streamFadeStyles } from '#tui/status/stream/fade'; import { renderStreamFrame, streamLines } from '#tui/status/stream/render'; export class Stream { #closed = false; readonly #buffer = new StreamBuffer(10); + readonly #fadeStyles = streamFadeStyles(); write(content: string): this { return this.append(content); @@ -56,7 +58,13 @@ export class Stream { } private render(): void { - promptEnvironment().output.write(renderStreamFrame({ value: this.value() })); + promptEnvironment().output.write( + renderStreamFrame({ + fading: this.#buffer.fading, + fadeStyles: this.#fadeStyles, + value: this.#buffer.stableValue(), + }), + ); } } diff --git a/packages/tui/src/status/stream/buffer.ts b/packages/tui/src/status/stream/buffer.ts index 520c5fbb..71c645c1 100644 --- a/packages/tui/src/status/stream/buffer.ts +++ b/packages/tui/src/status/stream/buffer.ts @@ -27,4 +27,8 @@ export class StreamBuffer { value(): string { return `${this.#message}${this.fading.join('')}`; } + + stableValue(): string { + return this.#message; + } } diff --git a/packages/tui/src/status/stream/fade.ts b/packages/tui/src/status/stream/fade.ts new file mode 100644 index 00000000..f556d8f8 --- /dev/null +++ b/packages/tui/src/status/stream/fade.ts @@ -0,0 +1,38 @@ +import { backgroundColor, foregroundColor, supportsTrueColor } from '#tui/terminal'; +import { dim, foregroundRgb } from '#tui/theme/styles'; +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 = options.trueColor ?? supportsTrueColor(); + + if (!trueColor) { + return [(value) => value, dim]; + } + + const steps = options.steps ?? 10; + const foreground = options.foreground ?? foregroundColor(); + const background = 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/render.ts b/packages/tui/src/status/stream/render.ts index c37997a6..61b72a0e 100644 --- a/packages/tui/src/status/stream/render.ts +++ b/packages/tui/src/status/stream/render.ts @@ -1,16 +1,25 @@ import { wrap } from '#tui/strings'; +import type { StreamFadeStyle } from '#tui/status/stream/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 = 60 }: StreamFrameOptions): string[] => { return value.split(/\r?\n/u).flatMap((line) => wrap(line, width)); }; export const renderStreamFrame = (options: StreamFrameOptions): string => { - return `${streamLines(options) + 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/theme/styles.ts b/packages/tui/src/theme/styles.ts index 49fd51c4..645f3fb5 100644 --- a/packages/tui/src/theme/styles.ts +++ b/packages/tui/src/theme/styles.ts @@ -9,3 +9,11 @@ const ansi = (open: number, close: number, value: string): string => { export const dim = (value: string): string => ansi(2, 22, value); export const inverse = (value: string): string => ansi(7, 27, value); + +export const foregroundRgb = (value: string, red: number, green: number, blue: number): string => { + if (value.length === 0) { + return ''; + } + + return `\u001B[38;2;${red};${green};${blue}m${value}\u001B[0m`; +}; diff --git a/packages/tui/tests/stream.test.ts b/packages/tui/tests/stream.test.ts index d6ba0565..34df5f98 100644 --- a/packages/tui/tests/stream.test.ts +++ b/packages/tui/tests/stream.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from 'vitest'; -import { createMemoryOutput, stream, withPromptEnvironment } from '#tui/index'; +import { createMemoryOutput, parseAnsiText, stream, withPromptEnvironment } from '#tui/index'; +import { streamFadeStyles } from '#tui/status/stream/fade'; +import { renderStreamFrame } from '#tui/status/stream/render'; describe('stream helper', () => { it('appends streamed content and returns the accumulated value', async () => { @@ -17,7 +19,7 @@ describe('stream helper', () => { expect(outputStream.closed()).toBe(true); }); - expect(output.text()).toContain(' hello world'); + expect(parseAnsiText(output.text())).toContain(' hello world'); }); it('pipes iterable content through a stateful stream', async () => { @@ -27,8 +29,8 @@ describe('stream helper', () => { await stream(['one', '\n', 'two']); }); - expect(output.text()).toContain(' one'); - expect(output.text()).toContain(' two'); + expect(parseAnsiText(output.text())).toContain(' one'); + expect(parseAnsiText(output.text())).toContain(' two'); }); it('closes piped streams after the source is exhausted', async () => { @@ -58,7 +60,7 @@ describe('stream helper', () => { expect(outputStream.value()).toBe('finished'); }); - expect(output.text()).toContain(' finished'); + expect(parseAnsiText(output.text())).toContain(' finished'); }); it('cannot be prompted for input', async () => { @@ -101,6 +103,33 @@ describe('stream helper', () => { expect(outputStream.closed()).toBe(true); }); - expect(output.text()).toContain(' 01234567891011'); + expect(parseAnsiText(output.text())).toContain(' 01234567891011'); + }); + + it('renders pending stream chunks with fallback fade styles', () => { + const frame = renderStreamFrame({ + fading: ['fresh', ' fading'], + fadeStyles: streamFadeStyles({ trueColor: false }), + value: 'stable ', + }); + + expect(parseAnsiText(frame)).toBe(' stable fresh fading\n'); + expect(frame).toContain('stable fresh\u001B[2m fading\u001B[22m'); + }); + + it('renders true-color stream fade styles', () => { + const frame = renderStreamFrame({ + fading: ['fresh', ' fading'], + fadeStyles: streamFadeStyles({ + background: [0, 0, 0], + foreground: [100, 50, 0], + steps: 2, + trueColor: true, + }), + value: '', + }); + + expect(frame).toContain('\u001B[38;2;100;50;0mfresh\u001B[0m'); + expect(frame).toContain('\u001B[38;2;50;25;0m fading\u001B[0m'); }); }); From 8790f43f845b0ea87eb4856b96e2297fc8f514d8 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 21:33:08 +0800 Subject: [PATCH 172/675] Add deterministic task frames --- UPSTREAM.md | 4 ++-- packages/tui/src/status/task/render.ts | 5 +++-- packages/tui/tests/task.test.ts | 22 ++++++++++++++++++++++ 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/UPSTREAM.md b/UPSTREAM.md index 3cae3d13..cd56b0ca 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -37,7 +37,7 @@ TypeScript and OpenTUI internals. | Spinner, progress, task, stream | `packages/tui/src/status/*`, `packages/tui/src/status.ts` | | Progress boxed frame rendering and formatted fractions | `packages/tui/src/status/progress/render.ts`, `packages/tui/src/theme/box.ts` | | Spinner static and deterministic animated frame rendering | `packages/tui/src/status/spinner.ts`, `packages/tui/src/status/spinner/render.ts` | -| Task static frame rendering, bounded logs, and stable summary symbols | `packages/tui/src/status/task.ts`, `packages/tui/src/status/task/*` | +| Task static and deterministic animated frame rendering, bounded logs, and stable summary symbols | `packages/tui/src/status/task.ts`, `packages/tui/src/status/task/*` | | Stream frame rendering, wrapping, fade styles, and close flushing | `packages/tui/src/status/stream.ts`, `packages/tui/src/status/stream/*` | | Terminal clear/title, 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` | | ANSI, wrapping, truncation, Unicode width | `packages/tui/src/strings.ts` | @@ -57,7 +57,7 @@ later phases: - Search full rendering and highlighted result label parity - Spinner terminal cleanup parity - Stream terminal cleanup parity -- Task animated frame, terminal cleanup, and process-log parity +- Task terminal cleanup and process-log parity - Terminal interactive read parity - Textarea boxed renderer, wrapped cursor, and scrollbar parity - Default theme diff --git a/packages/tui/src/status/task/render.ts b/packages/tui/src/status/task/render.ts index 9463ab4f..728871ad 100644 --- a/packages/tui/src/status/task/render.ts +++ b/packages/tui/src/status/task/render.ts @@ -4,6 +4,7 @@ import type { StableTaskMessage } from '#tui/status/task/messages'; const TASK_DIVIDER_WIDTH = 60; type TaskFrameOptions = { + frameCount?: number; finished?: boolean; keepSummary?: boolean; label: string; @@ -29,12 +30,12 @@ const renderStableMessage = (message: StableTaskMessage): string => { return ` ${stableMessageSymbol(message.type)} ${message.message}`; }; -export const renderTaskFrame = ({ finished = false, keepSummary = false, label, limit, lines, stableMessages, subLabel }: TaskFrameOptions): string => { +export const renderTaskFrame = ({ finished = false, frameCount, keepSummary = false, label, limit, lines, stableMessages, subLabel }: TaskFrameOptions): string => { if (finished && keepSummary && stableMessages.length > 0) { return [` • ${label}`, ...stableMessages.map(renderStableMessage), ''].join('\n'); } - const output = [renderSpinnerFrame(label).trimEnd()]; + const output = [renderSpinnerFrame(label, frameCount).trimEnd()]; if (subLabel) { output.push(` ${subLabel}`); diff --git a/packages/tui/tests/task.test.ts b/packages/tui/tests/task.test.ts index ee7368b0..9cd71c85 100644 --- a/packages/tui/tests/task.test.ts +++ b/packages/tui/tests/task.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import { createMemoryOutput, task, withPromptEnvironment } from '#tui/index'; +import { renderTaskFrame } from '#tui/status/task/render'; describe('task helper', () => { it('passes a bounded logger to the callback and returns the callback value', async () => { @@ -222,4 +223,25 @@ describe('task helper', () => { expect(output.text()).toContain(' ⠶ Building'); expect(output.text()).not.toContain(' ⠶ Building\n assets'); }); + + it('renders deterministic animated task frames', () => { + expect( + renderTaskFrame({ + frameCount: 2, + label: 'Running...', + limit: 2, + lines: ['line one'], + stableMessages: [], + }), + ).toContain(' ⠐ Running...'); + + expect( + renderTaskFrame({ + label: 'Running...', + limit: 2, + lines: [], + stableMessages: [], + }), + ).toContain(' ⠶ Running...'); + }); }); From 9bde01de9bf254754072a5e3bfd43c597dab9953 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 21:37:13 +0800 Subject: [PATCH 173/675] Restore status cursors --- UPSTREAM.md | 8 ++++---- packages/tui/src/index.ts | 2 +- packages/tui/src/status/spinner.ts | 8 +++++++- packages/tui/src/status/stream.ts | 10 ++++++++++ packages/tui/src/status/task.ts | 4 ++++ packages/tui/src/terminal.ts | 8 ++++++++ packages/tui/tests/spinner.test.ts | 3 +++ packages/tui/tests/stream.test.ts | 4 ++++ packages/tui/tests/task.test.ts | 17 +++++++++++++++++ packages/tui/tests/terminal.test.ts | 20 ++++++++++++++++++-- 10 files changed, 76 insertions(+), 8 deletions(-) diff --git a/UPSTREAM.md b/UPSTREAM.md index cd56b0ca..9688198e 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -39,7 +39,8 @@ TypeScript and OpenTUI internals. | Spinner static and deterministic animated frame rendering | `packages/tui/src/status/spinner.ts`, `packages/tui/src/status/spinner/render.ts` | | Task static and deterministic animated frame rendering, bounded logs, and stable summary symbols | `packages/tui/src/status/task.ts`, `packages/tui/src/status/task/*` | | Stream frame rendering, wrapping, fade styles, and close flushing | `packages/tui/src/status/stream.ts`, `packages/tui/src/status/stream/*` | -| Terminal clear/title, 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` | +| Status cursor visibility cleanup | `packages/tui/src/status/spinner.ts`, `packages/tui/src/status/stream.ts`, `packages/tui/src/status/task.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` | | ANSI, wrapping, truncation, Unicode width | `packages/tui/src/strings.ts` | | OpenTUI adapter | `packages/tui/src/opentui.ts` | | Type contracts | `packages/tui/src/types.ts`, `packages/tui/src/contracts/*` | @@ -55,9 +56,8 @@ later phases: - Progress signal handling and terminal cleanup parity - Prompt lifecycle - Search full rendering and highlighted result label parity -- Spinner terminal cleanup parity -- Stream terminal cleanup parity -- Task terminal cleanup and process-log parity +- Spinner erase-rendered-lines cleanup parity +- Task erase-rendered-lines cleanup and process-log parity - Terminal interactive read parity - Textarea boxed renderer, wrapped cursor, and scrollbar parity - Default theme diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index 0000653c..e9d02793 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -28,7 +28,7 @@ export { export type { NotificationCommand, NotificationRuntime } from '#tui/output'; export { PromptValidationError } from '#tui/prompt'; export { Logger, Progress, Stream, progress, spin, stream, task } from '#tui/status'; -export { backgroundColor, clearTerminal, cursorToStart, eraseLine, foregroundColor, setTerminalTitle, supportsTrueColor, terminalSize } from '#tui/terminal'; +export { backgroundColor, clearTerminal, cursorToStart, eraseLine, foregroundColor, hideCursor, setTerminalTitle, showCursor, supportsTrueColor, terminalSize } from '#tui/terminal'; export { applyTypedKey, readTypedValue } from '#tui/typed-value'; export { parseAnsiText, truncate, visibleWidth, wrap } from '#tui/strings'; export type { diff --git a/packages/tui/src/status/spinner.ts b/packages/tui/src/status/spinner.ts index cdf4f5c2..4f762220 100644 --- a/packages/tui/src/status/spinner.ts +++ b/packages/tui/src/status/spinner.ts @@ -1,5 +1,6 @@ import { promptEnvironment } from '#tui/environment'; import { renderSpinnerFrame } from '#tui/status/spinner/render'; +import { hideCursor, showCursor } from '#tui/terminal'; import type { MaybePromise, StatusOptions } from '#tui/types'; export function spin(callback: () => MaybePromise, options?: StatusOptions): Promise; @@ -16,7 +17,12 @@ export async function spin(callbackOrMessage: (() => MaybePromise) | strin const message = typeof options === 'function' ? 'Loading' : options.message; + hideCursor(); promptEnvironment().output.write(renderSpinnerFrame(message)); - return callback(); + try { + return await callback(); + } finally { + showCursor(); + } } diff --git a/packages/tui/src/status/stream.ts b/packages/tui/src/status/stream.ts index b36a30a1..a62679b6 100644 --- a/packages/tui/src/status/stream.ts +++ b/packages/tui/src/status/stream.ts @@ -2,12 +2,17 @@ import { promptEnvironment } from '#tui/environment'; import { StreamBuffer } from '#tui/status/stream/buffer'; import { streamFadeStyles } from '#tui/status/stream/fade'; import { renderStreamFrame, streamLines } from '#tui/status/stream/render'; +import { hideCursor, showCursor } from '#tui/terminal'; export class Stream { #closed = false; readonly #buffer = new StreamBuffer(10); readonly #fadeStyles = streamFadeStyles(); + constructor() { + hideCursor(); + } + write(content: string): this { return this.append(content); } @@ -24,11 +29,16 @@ export class Stream { } close(): void { + if (this.#closed) { + return; + } + while (this.#buffer.flushNext()) { this.render(); } this.#closed = true; + showCursor(); } closed(): boolean { diff --git a/packages/tui/src/status/task.ts b/packages/tui/src/status/task.ts index a3000e3b..393a2cce 100644 --- a/packages/tui/src/status/task.ts +++ b/packages/tui/src/status/task.ts @@ -1,6 +1,7 @@ import { promptEnvironment } from '#tui/environment'; import { Logger } from '#tui/status/task/logger'; import { renderTaskFrame } from '#tui/status/task/render'; +import { hideCursor, showCursor } from '#tui/terminal'; import type { MaybePromise } from '#tui/types'; export type TaskDefinition = { @@ -31,6 +32,7 @@ export async function task(definitionOrLabel: TaskDefinition | string, cal const output = promptEnvironment().output; + hideCursor(); output.write( renderTaskFrame({ label: logger.labelValue, @@ -69,5 +71,7 @@ export async function task(definitionOrLabel: TaskDefinition | string, cal ); throw error; + } finally { + showCursor(); } } diff --git a/packages/tui/src/terminal.ts b/packages/tui/src/terminal.ts index f62aebf4..21144269 100644 --- a/packages/tui/src/terminal.ts +++ b/packages/tui/src/terminal.ts @@ -28,6 +28,14 @@ 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); }; diff --git a/packages/tui/tests/spinner.test.ts b/packages/tui/tests/spinner.test.ts index c12b37f2..e3c5f50c 100644 --- a/packages/tui/tests/spinner.test.ts +++ b/packages/tui/tests/spinner.test.ts @@ -11,6 +11,8 @@ describe('spinner helper', () => { }); expect(result).toBe('done'); + expect(output.text()).toContain('\u001B[?25l'); + expect(output.text()).toContain('\u001B[?25h'); expect(output.text()).toContain(' ⠶ Working'); expect(output.text()).not.toContain('Done: Working'); }); @@ -40,6 +42,7 @@ describe('spinner helper', () => { ).rejects.toBe(failure); expect(output.text()).toContain(' ⠶ Working'); + expect(output.text()).toContain('\u001B[?25h'); expect(output.text()).not.toContain('Failed: Working'); expect(output.text()).not.toContain('Done: Working'); }); diff --git a/packages/tui/tests/stream.test.ts b/packages/tui/tests/stream.test.ts index 34df5f98..3c99f3c9 100644 --- a/packages/tui/tests/stream.test.ts +++ b/packages/tui/tests/stream.test.ts @@ -19,6 +19,8 @@ describe('stream helper', () => { expect(outputStream.closed()).toBe(true); }); + expect(output.text()).toContain('\u001B[?25l'); + expect(output.text()).toContain('\u001B[?25h'); expect(parseAnsiText(output.text())).toContain(' hello world'); }); @@ -45,6 +47,8 @@ describe('stream helper', () => { expect(outputStream.value()).toBe('done'); expect(() => outputStream.write(' again')).toThrow('Stream is closed.'); }); + + expect(output.text()).toContain('\u001B[?25h'); }); it('rejects writes after close without losing buffered content', async () => { diff --git a/packages/tui/tests/task.test.ts b/packages/tui/tests/task.test.ts index 9cd71c85..e3a4ed29 100644 --- a/packages/tui/tests/task.test.ts +++ b/packages/tui/tests/task.test.ts @@ -21,6 +21,8 @@ describe('task helper', () => { }); expect(result).toBe('done'); + expect(output.text()).toContain('\u001B[?25l'); + expect(output.text()).toContain('\u001B[?25h'); expect(output.text()).toContain('Running...'); expect(output.text()).not.toContain('line one'); expect(output.text()).toContain('line two'); @@ -244,4 +246,19 @@ describe('task helper', () => { }), ).toContain(' ⠶ Running...'); }); + + it('restores the cursor when task callbacks fail', async () => { + const output = createMemoryOutput(); + const failure = new Error('failed'); + + await expect( + withPromptEnvironment({ output, error: output }, async () => { + await task('Running...', () => { + throw failure; + }); + }), + ).rejects.toBe(failure); + + expect(output.text()).toContain('\u001B[?25h'); + }); }); diff --git a/packages/tui/tests/terminal.test.ts b/packages/tui/tests/terminal.test.ts index a71dc24f..3b4ae5a9 100644 --- a/packages/tui/tests/terminal.test.ts +++ b/packages/tui/tests/terminal.test.ts @@ -1,6 +1,20 @@ import { describe, expect, it } from 'vitest'; -import { backgroundColor, clear, clearTerminal, createMemoryOutput, cursorToStart, eraseLine, foregroundColor, setTerminalTitle, supportsTrueColor, title, withPromptEnvironment } from '#tui/index'; +import { + backgroundColor, + clear, + clearTerminal, + createMemoryOutput, + cursorToStart, + eraseLine, + foregroundColor, + hideCursor, + setTerminalTitle, + showCursor, + supportsTrueColor, + title, + withPromptEnvironment, +} from '#tui/index'; describe('terminal helpers', () => { it('writes clear terminal control sequences', async () => { @@ -31,9 +45,11 @@ describe('terminal helpers', () => { await withPromptEnvironment({ output, error: output }, async () => { cursorToStart(); eraseLine(); + hideCursor(); + showCursor(); }); - expect(output.text()).toBe('\r\u001B[2K'); + expect(output.text()).toBe('\r\u001B[2K\u001B[?25l\u001B[?25h'); }); it('detects true color terminal modes', () => { From 65774cccf77743930e6982a1d5d89fbaeb133baa Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 21:40:34 +0800 Subject: [PATCH 174/675] Erase completed status frames --- UPSTREAM.md | 4 +- packages/tui/src/index.ts | 14 ++++++- packages/tui/src/status/frame.ts | 15 ++++++++ packages/tui/src/status/spinner.ts | 6 ++- packages/tui/src/status/task.ts | 60 +++++++++++++++-------------- packages/tui/src/terminal.ts | 10 +++++ packages/tui/tests/spinner.test.ts | 2 + packages/tui/tests/task.test.ts | 22 +++++++++++ packages/tui/tests/terminal.test.ts | 5 ++- 9 files changed, 104 insertions(+), 34 deletions(-) create mode 100644 packages/tui/src/status/frame.ts diff --git a/UPSTREAM.md b/UPSTREAM.md index 9688198e..39e4d203 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -40,6 +40,7 @@ TypeScript and OpenTUI internals. | Task static and deterministic animated frame rendering, bounded logs, and stable summary symbols | `packages/tui/src/status/task.ts`, `packages/tui/src/status/task/*` | | Stream frame rendering, wrapping, fade styles, and close flushing | `packages/tui/src/status/stream.ts`, `packages/tui/src/status/stream/*` | | Status cursor visibility cleanup | `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` | | 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` | | ANSI, wrapping, truncation, Unicode width | `packages/tui/src/strings.ts` | | OpenTUI adapter | `packages/tui/src/opentui.ts` | @@ -56,8 +57,7 @@ later phases: - Progress signal handling and terminal cleanup parity - Prompt lifecycle - Search full rendering and highlighted result label parity -- Spinner erase-rendered-lines cleanup parity -- Task erase-rendered-lines cleanup and process-log parity +- Task process-log parity - Terminal interactive read parity - Textarea boxed renderer, wrapped cursor, and scrollbar parity - Default theme diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index e9d02793..75748751 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -28,7 +28,19 @@ export { export type { NotificationCommand, NotificationRuntime } from '#tui/output'; export { PromptValidationError } from '#tui/prompt'; export { Logger, Progress, Stream, progress, spin, stream, task } from '#tui/status'; -export { backgroundColor, clearTerminal, cursorToStart, eraseLine, foregroundColor, hideCursor, setTerminalTitle, showCursor, supportsTrueColor, terminalSize } from '#tui/terminal'; +export { + backgroundColor, + clearTerminal, + cursorToStart, + eraseLine, + erasePreviousLines, + foregroundColor, + hideCursor, + setTerminalTitle, + showCursor, + supportsTrueColor, + terminalSize, +} from '#tui/terminal'; export { applyTypedKey, readTypedValue } from '#tui/typed-value'; export { parseAnsiText, truncate, visibleWidth, wrap } from '#tui/strings'; export type { diff --git a/packages/tui/src/status/frame.ts b/packages/tui/src/status/frame.ts new file mode 100644 index 00000000..8949913e --- /dev/null +++ b/packages/tui/src/status/frame.ts @@ -0,0 +1,15 @@ +import { erasePreviousLines } from '#tui/terminal'; + +export const renderedFrameLineCount = (frame: string): number => { + if (frame.length === 0) { + return 0; + } + + const lines = frame.split('\n').length; + + return frame.endsWith('\n') ? lines - 1 : lines; +}; + +export const eraseRenderedFrame = (frame: string): void => { + erasePreviousLines(renderedFrameLineCount(frame)); +}; diff --git a/packages/tui/src/status/spinner.ts b/packages/tui/src/status/spinner.ts index 4f762220..24dfa4e7 100644 --- a/packages/tui/src/status/spinner.ts +++ b/packages/tui/src/status/spinner.ts @@ -1,4 +1,5 @@ import { promptEnvironment } from '#tui/environment'; +import { eraseRenderedFrame } from '#tui/status/frame'; import { renderSpinnerFrame } from '#tui/status/spinner/render'; import { hideCursor, showCursor } from '#tui/terminal'; import type { MaybePromise, StatusOptions } from '#tui/types'; @@ -16,13 +17,16 @@ export async function spin(callbackOrMessage: (() => MaybePromise) | strin } const message = typeof options === 'function' ? 'Loading' : options.message; + const output = promptEnvironment().output; + const frame = renderSpinnerFrame(message); hideCursor(); - promptEnvironment().output.write(renderSpinnerFrame(message)); + output.write(frame); try { return await callback(); } finally { + eraseRenderedFrame(frame); showCursor(); } } diff --git a/packages/tui/src/status/task.ts b/packages/tui/src/status/task.ts index 393a2cce..d7673b1d 100644 --- a/packages/tui/src/status/task.ts +++ b/packages/tui/src/status/task.ts @@ -1,4 +1,5 @@ import { promptEnvironment } from '#tui/environment'; +import { eraseRenderedFrame } from '#tui/status/frame'; import { Logger } from '#tui/status/task/logger'; import { renderTaskFrame } from '#tui/status/task/render'; import { hideCursor, showCursor } from '#tui/terminal'; @@ -33,42 +34,43 @@ export async function task(definitionOrLabel: TaskDefinition | string, cal const output = promptEnvironment().output; hideCursor(); - output.write( - renderTaskFrame({ - label: logger.labelValue, - limit: logger.limitValue, - lines: logger.lines, - stableMessages: logger.stableMessages, - subLabel: logger.subLabelValue, - }), - ); + + let frame = renderTaskFrame({ + label: logger.labelValue, + limit: logger.limitValue, + lines: logger.lines, + stableMessages: logger.stableMessages, + subLabel: logger.subLabelValue, + }); + + output.write(frame); try { const result = await run(logger); - output.write( - renderTaskFrame({ - finished: true, - keepSummary: summary, - label: logger.labelValue, - limit: logger.limitValue, - lines: logger.lines, - stableMessages: summary ? logger.stableMessages : [], - subLabel: logger.subLabelValue, - }), - ); + eraseRenderedFrame(frame); + frame = renderTaskFrame({ + finished: true, + keepSummary: summary, + label: logger.labelValue, + limit: logger.limitValue, + lines: logger.lines, + stableMessages: summary ? logger.stableMessages : [], + subLabel: logger.subLabelValue, + }); + output.write(frame); return result; } catch (error) { - output.write( - renderTaskFrame({ - label: logger.labelValue, - limit: logger.limitValue, - lines: logger.lines, - stableMessages: logger.stableMessages, - subLabel: logger.subLabelValue, - }), - ); + eraseRenderedFrame(frame); + frame = renderTaskFrame({ + label: logger.labelValue, + limit: logger.limitValue, + lines: logger.lines, + stableMessages: logger.stableMessages, + subLabel: logger.subLabelValue, + }); + output.write(frame); throw error; } finally { diff --git a/packages/tui/src/terminal.ts b/packages/tui/src/terminal.ts index 21144269..c1b7bb26 100644 --- a/packages/tui/src/terminal.ts +++ b/packages/tui/src/terminal.ts @@ -24,6 +24,16 @@ export const eraseLine = (): void => { promptEnvironment().output.write('\u001B[2K'); }; +export const erasePreviousLines = (count: number): void => { + const lines = Math.max(0, Math.floor(count)); + + if (lines === 0) { + return; + } + + promptEnvironment().output.write('\u001B[1A\u001B[2K'.repeat(lines)); +}; + export const cursorToStart = (): void => { promptEnvironment().output.write('\r'); }; diff --git a/packages/tui/tests/spinner.test.ts b/packages/tui/tests/spinner.test.ts index e3c5f50c..a47a85d8 100644 --- a/packages/tui/tests/spinner.test.ts +++ b/packages/tui/tests/spinner.test.ts @@ -12,6 +12,7 @@ describe('spinner helper', () => { expect(result).toBe('done'); expect(output.text()).toContain('\u001B[?25l'); + expect(output.text()).toContain('\u001B[1A\u001B[2K'); expect(output.text()).toContain('\u001B[?25h'); expect(output.text()).toContain(' ⠶ Working'); expect(output.text()).not.toContain('Done: Working'); @@ -42,6 +43,7 @@ describe('spinner helper', () => { ).rejects.toBe(failure); expect(output.text()).toContain(' ⠶ Working'); + expect(output.text()).toContain('\u001B[1A\u001B[2K'); expect(output.text()).toContain('\u001B[?25h'); expect(output.text()).not.toContain('Failed: Working'); expect(output.text()).not.toContain('Done: Working'); diff --git a/packages/tui/tests/task.test.ts b/packages/tui/tests/task.test.ts index e3a4ed29..4a83e417 100644 --- a/packages/tui/tests/task.test.ts +++ b/packages/tui/tests/task.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import { createMemoryOutput, task, withPromptEnvironment } from '#tui/index'; +import { eraseRenderedFrame, renderedFrameLineCount } from '#tui/status/frame'; import { renderTaskFrame } from '#tui/status/task/render'; describe('task helper', () => { @@ -22,6 +23,7 @@ describe('task helper', () => { expect(result).toBe('done'); expect(output.text()).toContain('\u001B[?25l'); + expect(output.text()).toContain('\u001B[1A\u001B[2K'); expect(output.text()).toContain('\u001B[?25h'); expect(output.text()).toContain('Running...'); expect(output.text()).not.toContain('line one'); @@ -247,6 +249,25 @@ describe('task helper', () => { ).toContain(' ⠶ Running...'); }); + it('counts and erases rendered task frames', async () => { + const frame = renderTaskFrame({ + label: 'Running...', + limit: 2, + lines: ['line one'], + stableMessages: [], + }); + + const output = createMemoryOutput(); + + expect(renderedFrameLineCount(frame)).toBe(4); + + await withPromptEnvironment({ output, error: output }, async () => { + eraseRenderedFrame(frame); + }); + + expect(output.text()).toBe('\u001B[1A\u001B[2K'.repeat(4)); + }); + it('restores the cursor when task callbacks fail', async () => { const output = createMemoryOutput(); const failure = new Error('failed'); @@ -260,5 +281,6 @@ describe('task helper', () => { ).rejects.toBe(failure); expect(output.text()).toContain('\u001B[?25h'); + expect(output.text()).toContain('\u001B[1A\u001B[2K'); }); }); diff --git a/packages/tui/tests/terminal.test.ts b/packages/tui/tests/terminal.test.ts index 3b4ae5a9..03a73bf7 100644 --- a/packages/tui/tests/terminal.test.ts +++ b/packages/tui/tests/terminal.test.ts @@ -7,6 +7,7 @@ import { createMemoryOutput, cursorToStart, eraseLine, + erasePreviousLines, foregroundColor, hideCursor, setTerminalTitle, @@ -45,11 +46,13 @@ describe('terminal helpers', () => { await withPromptEnvironment({ output, error: output }, async () => { cursorToStart(); eraseLine(); + erasePreviousLines(2); + erasePreviousLines(-1); hideCursor(); showCursor(); }); - expect(output.text()).toBe('\r\u001B[2K\u001B[?25l\u001B[?25h'); + expect(output.text()).toBe('\r\u001B[2K\u001B[1A\u001B[2K\u001B[1A\u001B[2K\u001B[?25l\u001B[?25h'); }); it('detects true color terminal modes', () => { From 2c49f14a9a8402a60b4c3404fed20e97b7f9b192 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 21:42:10 +0800 Subject: [PATCH 175/675] Clean progress status frames --- UPSTREAM.md | 3 ++- packages/tui/src/status/progress.ts | 32 ++++++++++++++++++++++++++++- packages/tui/tests/progress.test.ts | 5 +++++ 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/UPSTREAM.md b/UPSTREAM.md index 39e4d203..579cc33b 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -41,6 +41,7 @@ TypeScript and OpenTUI internals. | Stream frame rendering, wrapping, fade styles, and close flushing | `packages/tui/src/status/stream.ts`, `packages/tui/src/status/stream/*` | | Status cursor visibility cleanup | `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.ts`, `packages/tui/src/status/frame.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` | | ANSI, wrapping, truncation, Unicode width | `packages/tui/src/strings.ts` | | OpenTUI adapter | `packages/tui/src/opentui.ts` | @@ -54,7 +55,7 @@ later phases: - Form builder - Multi-search full rendering and selected-result summary parity - Multi-select -- Progress signal handling and terminal cleanup parity +- Progress signal handling parity - Prompt lifecycle - Search full rendering and highlighted result label parity - Task process-log parity diff --git a/packages/tui/src/status/progress.ts b/packages/tui/src/status/progress.ts index d34a46c2..4c15ec1b 100644 --- a/packages/tui/src/status/progress.ts +++ b/packages/tui/src/status/progress.ts @@ -1,8 +1,10 @@ import { promptEnvironment } from '#tui/environment'; +import { eraseRenderedFrame } from '#tui/status/frame'; import { parseProgressStep, parseProgressTotal } from '#tui/status/validators/progress'; import { renderProgressFrame } from '#tui/status/progress/render'; import { runProgressSteps } from '#tui/status/progress/run'; import { progressValues } from '#tui/status/progress/steps'; +import { hideCursor, showCursor } from '#tui/terminal'; import type { ProgressFrameState } from '#tui/status/progress/render'; import type { MaybePromise } from '#tui/types'; @@ -11,6 +13,8 @@ export class Progress { #label: string; #hint: string; #state: ProgressFrameState = 'active'; + #renderedFrame: string | null = null; + #cursorHidden = false; readonly total: number; constructor(total: number, message = 'Progress', hint = '') { @@ -33,11 +37,13 @@ export class Progress { finish(): void { this.#state = 'submit'; this.render(); + this.#restoreTerminal(); } fail(): void { this.#state = 'error'; this.render(); + this.#restoreTerminal(); } label(value: string): this { @@ -65,7 +71,31 @@ export class Progress { } render(): void { - promptEnvironment().output.write(renderProgressFrame({ current: this.#current, hint: this.#hint, label: this.#label, state: this.#state, total: this.total })); + const frame = renderProgressFrame({ current: this.#current, hint: this.#hint, label: this.#label, state: this.#state, total: this.total }); + + if (!this.#cursorHidden) { + hideCursor(); + this.#cursorHidden = true; + } + + if (this.#renderedFrame) { + eraseRenderedFrame(this.#renderedFrame); + } + + promptEnvironment().output.write(frame); + this.#renderedFrame = frame; + } + + #restoreTerminal(): void { + if (this.#renderedFrame) { + eraseRenderedFrame(this.#renderedFrame); + this.#renderedFrame = null; + } + + if (this.#cursorHidden) { + showCursor(); + this.#cursorHidden = false; + } } } diff --git a/packages/tui/tests/progress.test.ts b/packages/tui/tests/progress.test.ts index 44a1308e..b9d837e8 100644 --- a/packages/tui/tests/progress.test.ts +++ b/packages/tui/tests/progress.test.ts @@ -12,6 +12,8 @@ describe('progress helper', () => { expect(result).toEqual(['ALABAMA', 'ALASKA']); expect(output.text()).toContain('Uppercasing States'); expect(output.text()).toContain('2 / 2'); + expect(output.text()).toContain('\u001B[?25l'); + expect(output.text()).toContain('\u001B[?25h'); }); it('passes the current count to progress callbacks before advancing', async () => { @@ -39,6 +41,7 @@ describe('progress helper', () => { expect(output.text()).toContain('Adding States'); expect(output.text()).toContain('ALABAMA'); expect(output.text()).toContain('alaska'); + expect(output.text()).toContain('\u001B[1A\u001B[2K'); }); it('renders visible progress for small non-zero percentages', async () => { @@ -135,5 +138,7 @@ describe('progress helper', () => { expect(output.text()).toContain('Adding States'); expect(output.text()).toContain('0 / 1'); + expect(output.text()).toContain('\u001B[1A\u001B[2K'); + expect(output.text()).toContain('\u001B[?25h'); }); }); From d20060206d6062eac0f4081b451fe6c8ef13da02 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 21:44:32 +0800 Subject: [PATCH 176/675] Style search result rows --- UPSTREAM.md | 3 +- packages/tui/src/concerns/choices.ts | 2 +- packages/tui/src/prompts/search/render.ts | 53 +++++++++++++++++++++-- packages/tui/src/theme/styles.ts | 2 + packages/tui/tests/search.test.ts | 23 +++++++++- 5 files changed, 77 insertions(+), 6 deletions(-) diff --git a/UPSTREAM.md b/UPSTREAM.md index 579cc33b..11f1481b 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -26,6 +26,7 @@ TypeScript and OpenTUI internals. | 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` | | Search, multisearch | `packages/tui/src/prompts/search/*` | | Search and multisearch empty-result rendering | `packages/tui/src/prompts/search/render.ts`, `packages/tui/tests/search.test.ts` | +| Search highlighted-result row styling | `packages/tui/src/prompts/search/render.ts`, `packages/tui/src/theme/styles.ts`, `packages/tui/tests/search.test.ts` | | Pause | `packages/tui/src/prompts/pause.ts` | | Choice normalization, matching, navigation, and list rendering concerns | `packages/tui/src/concerns/choices.ts` | | Form builder | `packages/tui/src/form.ts`, `packages/tui/src/form/builder/*` | @@ -57,7 +58,7 @@ later phases: - Multi-select - Progress signal handling parity - Prompt lifecycle -- Search full rendering and highlighted result label parity +- Search boxed layout, scrollbar, and submit/cancel frame parity - Task process-log parity - Terminal interactive read parity - Textarea boxed renderer, wrapped cursor, and scrollbar parity diff --git a/packages/tui/src/concerns/choices.ts b/packages/tui/src/concerns/choices.ts index a9a61e3e..7985bf2c 100644 --- a/packages/tui/src/concerns/choices.ts +++ b/packages/tui/src/concerns/choices.ts @@ -65,7 +65,7 @@ export const nextEnabledIndex = (choices: Array>, current: number, return current; }; -const choiceWindow = (total: number, selected: number, scroll?: number): { end: number; start: number } => { +export const choiceWindow = (total: number, selected: number, scroll?: number): { end: number; start: number } => { const size = parseOptionalScrollSize(scroll); if (size === undefined || size >= total) { diff --git a/packages/tui/src/prompts/search/render.ts b/packages/tui/src/prompts/search/render.ts index 8031971f..4ba28b98 100644 --- a/packages/tui/src/prompts/search/render.ts +++ b/packages/tui/src/prompts/search/render.ts @@ -1,8 +1,8 @@ import { promptEnvironment } from '#tui/environment'; -import { renderInteractiveChoices } from '#tui/concerns/choices'; +import { choiceWindow } from '#tui/concerns/choices'; import { resolveInfo } from '#tui/concerns/info'; import { searchMessage } from '#tui/prompts/search/choices'; -import { dim } from '#tui/theme/styles'; +import { cyan, dim } from '#tui/theme/styles'; import type { Choice, MultiSearchPromptOptions, SearchPromptOptions } from '#tui/types'; export const renderSearchChoices = ( @@ -16,7 +16,7 @@ export const renderSearchChoices = ( info?: SearchPromptOptions['info'] | MultiSearchPromptOptions['info'], showSelectedSummary = false, ): void => { - renderInteractiveChoices(searchMessage(message, query), choices, highlighted ?? 0, marked, scroll); + renderSearchRows(searchMessage(message, query), choices, highlighted, marked, scroll, showSelectedSummary); if (query.length > 0 && choices.length === 0) { promptEnvironment().output.write(`${dim(' No results.')}\n`); @@ -35,6 +35,53 @@ export const renderSearchChoices = ( } }; +const renderSearchRows = (message: string, choices: Array>, highlighted: number | null, marked: Set, scroll: number | undefined, multiple: boolean): void => { + const environment = promptEnvironment(); + const window = choiceWindow(choices.length, highlighted ?? 0, scroll); + + environment.output.write(`${message}\n`); + + for (const [offset, choice] of choices.slice(window.start, window.end).entries()) { + const index = window.start + offset; + const active = highlighted === index; + const selected = marked.has(index); + const label = choiceLabel(choice); + + environment.output.write(`${multiple ? multiSearchRow(label, active, selected) : searchRow(label, active)}\n`); + } +}; + +const choiceLabel = (choice: Choice): string => { + const disabled = choice.disabled ? ` (${typeof choice.disabled === 'string' ? choice.disabled : 'disabled'})` : ''; + const hint = choice.hint ? ` ${choice.hint}` : ''; + + return `${choice.label}${hint}${disabled}`; +}; + +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)} `; +}; + const selectedSummary = (selectedCount: number, hiddenCount: number): string => { const hidden = hiddenCount > 0 ? ` (${hiddenCount} hidden)` : ''; diff --git a/packages/tui/src/theme/styles.ts b/packages/tui/src/theme/styles.ts index 645f3fb5..7291ff9f 100644 --- a/packages/tui/src/theme/styles.ts +++ b/packages/tui/src/theme/styles.ts @@ -8,6 +8,8 @@ const ansi = (open: number, close: number, value: string): string => { export const dim = (value: string): string => ansi(2, 22, value); +export const cyan = (value: string): string => ansi(36, 39, value); + export const inverse = (value: string): string => ansi(7, 27, value); export const foregroundRgb = (value: string, red: number, green: number, blue: number): string => { diff --git a/packages/tui/tests/search.test.ts b/packages/tui/tests/search.test.ts index 7f506311..7641e8c8 100644 --- a/packages/tui/tests/search.test.ts +++ b/packages/tui/tests/search.test.ts @@ -66,7 +66,27 @@ describe('search prompt', () => { ); expect(output.text()).toContain('Favorite color? r'); - expect(output.text()).toContain('›'); + expect(output.text()).toContain('\u001B[36m›\u001B[39m Red'); + expect(output.text()).toContain('\u001B[2mGreen\u001B[22m'); + }); + + it('does not render an active search row before navigation highlights a result', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment( + { + input: createScriptedInput([Key.ctrlC]), + output, + error: output, + interactive: true, + }, + () => search({ message: 'Favorite color?', options: colors, default: 'red' }), + ); + + const firstFrame = output.text().split('Favorite color?\n')[1] ?? ''; + + expect(firstFrame).not.toContain('›'); + expect(firstFrame).toContain('\u001B[2mRed\u001B[22m'); }); it('respects scroll windows when rendering results', async () => { @@ -404,6 +424,7 @@ describe('multisearch prompt', () => { ); expect(result).toEqual(['violet', 'green']); + expect(output.text()).toContain('\u001B[36m› ◼\u001B[39m Violet'); expect(output.text()).toContain('Selected: Violet'); expect(output.text()).toContain('Selected: Violet, Green'); }); From 9979b0b6d9940be75a5f046075e495ed8dbab5a9 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 21:47:30 +0800 Subject: [PATCH 177/675] Render textarea frames --- UPSTREAM.md | 3 +- packages/tui/src/typed-value/render.ts | 12 +++++++ .../tui/src/typed-value/textarea-frame.ts | 26 +++++++++++++++ packages/tui/tests/textarea.test.ts | 32 ++++++++++++++++--- 4 files changed, 68 insertions(+), 5 deletions(-) create mode 100644 packages/tui/src/typed-value/textarea-frame.ts diff --git a/UPSTREAM.md b/UPSTREAM.md index 11f1481b..8bd26a7c 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -18,6 +18,7 @@ TypeScript and OpenTUI internals. | Text input | `packages/tui/src/prompts/text.ts`, `packages/tui/src/typed-value.ts` | | Textarea input | `packages/tui/src/prompts/textarea.ts`, `packages/tui/src/typed-value.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` | | Password input | `packages/tui/src/prompts/password.ts`, `packages/tui/src/typed-value.ts` | | Number input | `packages/tui/src/prompts/number.ts`, `packages/tui/src/typed-value.ts` | | Confirm, select, multiselect | `packages/tui/src/prompts/select/*` | @@ -61,7 +62,7 @@ later phases: - Search boxed layout, scrollbar, and submit/cancel frame parity - Task process-log parity - Terminal interactive read parity -- Textarea boxed renderer, wrapped cursor, and scrollbar parity +- Textarea wrapping, cursor, and scrollbar parity - Default theme - Feature parity tests diff --git a/packages/tui/src/typed-value/render.ts b/packages/tui/src/typed-value/render.ts index 81daac42..c60caa6c 100644 --- a/packages/tui/src/typed-value/render.ts +++ b/packages/tui/src/typed-value/render.ts @@ -1,10 +1,22 @@ import { promptEnvironment } from '#tui/environment'; import { renderQuestion } from '#tui/theme'; +import { dim } from '#tui/theme/styles'; import { visibleLines } 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): void => { const displayValue = state.value.length > 0 ? visibleLines(state.value, state.cursor, options.rows) : (options.placeholder ?? ''); + if (options.allowNewLine) { + promptEnvironment().output.write(`${renderTextareaFrame(message, displayValue, options)}\n`); + + if (options.hint) { + promptEnvironment().output.write(`${dim(options.hint)}\n`); + } + + return; + } + promptEnvironment().output.write(`${renderQuestion(message, options.hint)}${displayValue}\n`); }; diff --git a/packages/tui/src/typed-value/textarea-frame.ts b/packages/tui/src/typed-value/textarea-frame.ts new file mode 100644 index 00000000..a697b8a3 --- /dev/null +++ b/packages/tui/src/typed-value/textarea-frame.ts @@ -0,0 +1,26 @@ +import { renderBox } from '#tui/theme/box'; +import { dim } from '#tui/theme/styles'; +import type { TypedValueOptions } from '#tui/typed-value/types'; + +export const renderTextareaFrame = (message: string, displayValue: string, options: TypedValueOptions): string => { + return renderBox({ body: textareaBody(displayValue, options), title: message }); +}; + +const textareaBody = (displayValue: string, options: TypedValueOptions): string => { + const rows = options.rows === undefined || options.rows <= 0 ? undefined : Math.floor(options.rows); + const lines = displayValue.split('\n'); + + if (isPlaceholder(displayValue, options)) { + lines[0] = dim(displayValue); + } + + if (rows === undefined) { + return lines.join('\n'); + } + + return [...lines.slice(0, rows), ...Array.from({ length: Math.max(0, rows - lines.length) }, () => '')].join('\n'); +}; + +const isPlaceholder = (displayValue: string, options: TypedValueOptions): boolean => { + return displayValue.length > 0 && displayValue === options.placeholder; +}; diff --git a/packages/tui/tests/textarea.test.ts b/packages/tui/tests/textarea.test.ts index 54e0869d..e1d9bfdb 100644 --- a/packages/tui/tests/textarea.test.ts +++ b/packages/tui/tests/textarea.test.ts @@ -17,7 +17,9 @@ describe('textarea prompt', () => { expect(result).toBe('A\nB'); expect(output.text()).toContain('Description'); - expect(output.text()).toContain('? Description A\nB'); + expect(output.text()).toContain('┌ Description '); + expect(output.text()).toContain('│ A'); + expect(output.text()).toContain('│ B'); }); it('renders textarea placeholders before input', async () => { @@ -33,7 +35,8 @@ describe('textarea prompt', () => { () => textarea('Description', 'Type here'), ); - expect(output.text()).toContain('? Description Type here'); + expect(output.text()).toContain('┌ Description '); + expect(output.text()).toContain('\u001B[2mType here\u001B[22m'); }); it('keeps rendered textarea output within the configured row window', async () => { @@ -49,12 +52,33 @@ describe('textarea prompt', () => { () => textarea('Description', '', '', false, undefined, '', 2), ); - const latestFrame = output.text().split('? Description ').at(-1) ?? ''; + const latestFrame = output.text().split('┌ Description ').at(-1) ?? ''; - expect(latestFrame).toContain('B\nC'); + expect(latestFrame).toContain('│ B'); + expect(latestFrame).toContain('│ C'); expect(latestFrame).not.toContain('A\nB\nC'); }); + it('pads textarea frames to the configured rows', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment( + { + input: createScriptedInput(['A', Key.ctrlD]), + output, + error: output, + interactive: true, + }, + () => textarea('Description', '', '', false, undefined, 'Use full sentences.', 3), + ); + + const latestFrame = output.text().split('┌ Description ').at(-1) ?? ''; + + expect(latestFrame).toContain('│ A'); + expect(latestFrame).toContain('│ │'); + expect(output.text()).toContain('\u001B[2mUse full sentences.\u001B[22m'); + }); + it('uses control navigation keys to move between textarea lines', async () => { const output = createMemoryOutput(); From 2d644824e66e998e9d244bd9b68480d349af9e40 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 21:50:15 +0800 Subject: [PATCH 178/675] Capture task process output --- UPSTREAM.md | 2 +- packages/tui/src/status/task.ts | 5 ++ .../tui/src/status/task/process-output.ts | 78 +++++++++++++++++++ packages/tui/tests/task.test.ts | 29 +++++++ 4 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 packages/tui/src/status/task/process-output.ts diff --git a/UPSTREAM.md b/UPSTREAM.md index 8bd26a7c..918d228e 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -40,6 +40,7 @@ TypeScript and OpenTUI internals. | Progress boxed frame rendering and formatted fractions | `packages/tui/src/status/progress/render.ts`, `packages/tui/src/theme/box.ts` | | Spinner static and deterministic animated frame rendering | `packages/tui/src/status/spinner.ts`, `packages/tui/src/status/spinner/render.ts` | | Task static and deterministic animated frame rendering, bounded logs, and stable summary symbols | `packages/tui/src/status/task.ts`, `packages/tui/src/status/task/*` | +| Task process output capture | `packages/tui/src/status/task/process-output.ts`, `packages/tui/tests/task.test.ts` | | Stream frame rendering, wrapping, fade styles, and close flushing | `packages/tui/src/status/stream.ts`, `packages/tui/src/status/stream/*` | | Status cursor visibility cleanup | `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` | @@ -60,7 +61,6 @@ later phases: - Progress signal handling parity - Prompt lifecycle - Search boxed layout, scrollbar, and submit/cancel frame parity -- Task process-log parity - Terminal interactive read parity - Textarea wrapping, cursor, and scrollbar parity - Default theme diff --git a/packages/tui/src/status/task.ts b/packages/tui/src/status/task.ts index d7673b1d..8cbd2fde 100644 --- a/packages/tui/src/status/task.ts +++ b/packages/tui/src/status/task.ts @@ -1,6 +1,7 @@ import { promptEnvironment } from '#tui/environment'; import { eraseRenderedFrame } from '#tui/status/frame'; import { Logger } from '#tui/status/task/logger'; +import { captureTaskProcessOutput } from '#tui/status/task/process-output'; import { renderTaskFrame } from '#tui/status/task/render'; import { hideCursor, showCursor } from '#tui/terminal'; import type { MaybePromise } from '#tui/types'; @@ -45,9 +46,12 @@ export async function task(definitionOrLabel: TaskDefinition | string, cal output.write(frame); + const processOutput = captureTaskProcessOutput(logger); + try { const result = await run(logger); + processOutput.stop(); eraseRenderedFrame(frame); frame = renderTaskFrame({ finished: true, @@ -62,6 +66,7 @@ export async function task(definitionOrLabel: TaskDefinition | string, cal return result; } catch (error) { + processOutput.stop(); eraseRenderedFrame(frame); frame = renderTaskFrame({ label: logger.labelValue, diff --git a/packages/tui/src/status/task/process-output.ts b/packages/tui/src/status/task/process-output.ts new file mode 100644 index 00000000..cc65aa6f --- /dev/null +++ b/packages/tui/src/status/task/process-output.ts @@ -0,0 +1,78 @@ +import type { Logger } from '#tui/status/task/logger'; + +type WritableProcessStream = { + write: NodeJS.WriteStream['write']; +}; + +type TaskProcessOutputCapture = { + stop(): void; +}; + +export const captureTaskProcessOutput = (logger: Logger): TaskProcessOutputCapture => { + const stdout = process.stdout as WritableProcessStream; + const stderr = process.stderr as WritableProcessStream; + const originalStdoutWrite = stdout.write; + const originalStderrWrite = stderr.write; + const buffer = new ProcessOutputBuffer(logger); + + let stopped = false; + + stdout.write = processOutputWriter(buffer) as NodeJS.WriteStream['write']; + stderr.write = processOutputWriter(buffer) as NodeJS.WriteStream['write']; + + return { + stop(): void { + if (stopped) { + return; + } + + stopped = true; + buffer.flush(); + stdout.write = originalStdoutWrite; + stderr.write = originalStderrWrite; + }, + }; +}; + +class ProcessOutputBuffer { + #pending = ''; + readonly #logger: Logger; + + constructor(logger: Logger) { + this.#logger = logger; + } + + write(chunk: string): void { + const lines = `${this.#pending}${chunk}`.split(/\r?\n/u); + + 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 = ''; + } +} + +const processOutputWriter = (buffer: ProcessOutputBuffer) => { + return (chunk: string | Uint8Array, encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void), callback?: (error?: Error | null) => void): boolean => { + const encoding = typeof encodingOrCallback === 'string' ? encodingOrCallback : undefined; + const done = typeof encodingOrCallback === 'function' ? encodingOrCallback : callback; + const content = typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString(encoding); + + buffer.write(content); + done?.(); + + return true; + }; +}; diff --git a/packages/tui/tests/task.test.ts b/packages/tui/tests/task.test.ts index 4a83e417..7f0035ab 100644 --- a/packages/tui/tests/task.test.ts +++ b/packages/tui/tests/task.test.ts @@ -192,6 +192,35 @@ describe('task helper', () => { expect(output.text()).toContain('two'); }); + it('captures process output while task callbacks run', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + await task('Running...', () => { + process.stdout.write('stdout line\n'); + process.stderr.write('stderr partial'); + }); + }); + + expect(output.text()).toContain('stdout line'); + expect(output.text()).toContain('stderr partial'); + }); + + it('restores process output writers after task callbacks finish', async () => { + const output = createMemoryOutput(); + const stdoutWrite = process.stdout.write; + const stderrWrite = process.stderr.write; + + await withPromptEnvironment({ output, error: output }, async () => { + await task('Running...', () => { + process.stdout.write('inside\n'); + }); + }); + + expect(process.stdout.write).toBe(stdoutWrite); + expect(process.stderr.write).toBe(stderrWrite); + }); + it('updates task labels through the logger', async () => { const output = createMemoryOutput(); From 4998485e177a091c70c42477708a9574a615f105 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 21:52:04 +0800 Subject: [PATCH 179/675] Style multiselect rows --- UPSTREAM.md | 3 ++- packages/tui/src/concerns/choices.ts | 19 ++++++++++++++++++- packages/tui/tests/choices.test.ts | 6 +++--- 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/UPSTREAM.md b/UPSTREAM.md index 918d228e..b13632af 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -23,6 +23,7 @@ TypeScript and OpenTUI internals. | Number input | `packages/tui/src/prompts/number.ts`, `packages/tui/src/typed-value.ts` | | Confirm, select, multiselect | `packages/tui/src/prompts/select/*` | | Multi-select checklist markers | `packages/tui/src/concerns/choices.ts`, `packages/tui/src/prompts/select/render.ts`, `packages/tui/tests/choices.test.ts` | +| Multi-select active and selected row styling | `packages/tui/src/concerns/choices.ts`, `packages/tui/tests/choices.test.ts` | | Suggest, autocomplete | `packages/tui/src/prompts/suggest/*` | | 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` | | Search, multisearch | `packages/tui/src/prompts/search/*` | @@ -57,7 +58,7 @@ later phases: - Form builder - Multi-search full rendering and selected-result summary parity -- Multi-select +- Multi-select boxed layout and submit/cancel frame parity - Progress signal handling parity - Prompt lifecycle - Search boxed layout, scrollbar, and submit/cancel frame parity diff --git a/packages/tui/src/concerns/choices.ts b/packages/tui/src/concerns/choices.ts index 7985bf2c..03f625be 100644 --- a/packages/tui/src/concerns/choices.ts +++ b/packages/tui/src/concerns/choices.ts @@ -2,6 +2,7 @@ import { promptEnvironment } from '#tui/environment'; import { parseChoiceAnswerIndex, parseChoiceRecordKey } from '#tui/concerns/validators/choice-answer'; import { parseChoice, parseChoiceRecord } from '#tui/concerns/validators/choice'; import { parseOptionalScrollSize } from '#tui/concerns/validators/scroll'; +import { cyan, dim } from '#tui/theme/styles'; import type { Choice, ChoiceOptions } from '#tui/types'; export const normalizeChoices = (options: ChoiceOptions): Array> => { @@ -109,7 +110,23 @@ export const renderInteractiveChecklist = (message: string, choices: Array { () => multiselect({ message: 'Pick many', options: ['first', 'second'] }), ); - expect(output.text()).toContain('› ◻ first'); - expect(output.text()).toContain(' ◻ second'); - expect(output.text()).toContain('› ◼ first'); + expect(output.text()).toContain('\u001B[36m›\u001B[39m ◻ first'); + expect(output.text()).toContain(' \u001B[2m◻\u001B[22m \u001B[2msecond\u001B[22m'); + expect(output.text()).toContain('\u001B[36m› ◼\u001B[39m first'); }); it('returns keys from keyed multiselect options', async () => { From 570ab96fea131b08430e71ea231fe1934c585226 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 21:55:41 +0800 Subject: [PATCH 180/675] Add form search overloads --- UPSTREAM.md | 3 +- .../tui/src/form/builder/prompts/choices.ts | 61 +++++++++++++++++-- .../form/builder/prompts/contracts/choices.ts | 24 ++++++++ .../form/builder/prompts/validators/search.ts | 7 +++ packages/tui/tests/form.test.ts | 47 ++++++++++++++ 5 files changed, 137 insertions(+), 5 deletions(-) create mode 100644 packages/tui/src/form/builder/prompts/validators/search.ts diff --git a/UPSTREAM.md b/UPSTREAM.md index b13632af..68ec715b 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -38,6 +38,7 @@ TypeScript and OpenTUI internals. | Tables, data tables, and grids | `packages/tui/src/output/table.ts`, `packages/tui/src/output/data-table.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/tests/form.test.ts` | | Progress boxed frame rendering and formatted fractions | `packages/tui/src/status/progress/render.ts`, `packages/tui/src/theme/box.ts` | | Spinner static and deterministic animated frame rendering | `packages/tui/src/status/spinner.ts`, `packages/tui/src/status/spinner/render.ts` | | Task static and deterministic animated frame rendering, bounded logs, and stable summary symbols | `packages/tui/src/status/task.ts`, `packages/tui/src/status/task/*` | @@ -56,7 +57,7 @@ TypeScript and OpenTUI internals. The following reference areas still need full line-by-line behavioral mapping in later phases: -- Form builder +- Form builder remaining method-surface and validation parity - Multi-search full rendering and selected-result summary parity - Multi-select boxed layout and submit/cancel frame parity - Progress signal handling parity diff --git a/packages/tui/src/form/builder/prompts/choices.ts b/packages/tui/src/form/builder/prompts/choices.ts index d8539c83..490f3a6e 100644 --- a/packages/tui/src/form/builder/prompts/choices.ts +++ b/packages/tui/src/form/builder/prompts/choices.ts @@ -1,5 +1,6 @@ import { autocomplete, confirm, multiselect, multisearch, pause, search, select, suggest } from '#tui/prompts/choices'; import { previousArray, previousString } from '#tui/form/builder/previous'; +import { isSearchPromptLabel } from '#tui/form/builder/prompts/validators/search'; import type { FormBuilder } from '#tui/form/builder/index'; import type { ChoicePromptBuilderMethods } from '#tui/form/builder/prompts/types'; import type { ChoiceOptions, MultiSearchPromptOptions, SearchPromptOptions } from '#tui/types'; @@ -11,8 +12,34 @@ export const choicePromptBuilderMethods: ChoicePromptBuilderMethods & ThisType confirm(label, defaultValue, yes, no, required, validate, hint, transform), name); }, - multisearch(options: MultiSearchPromptOptions, name?: string) { - return this.add(() => multisearch(options), name); + multisearch( + 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, + ) { + if (!isSearchPromptLabel(optionsOrLabel)) { + return this.add(() => multisearch(optionsOrLabel), options as string | undefined); + } + + const promptOptions: MultiSearchPromptOptions = { + message: optionsOrLabel, + label: optionsOrLabel, + options: options as MultiSearchPromptOptions['options'], + placeholder, + scroll, + required, + validate, + hint, + transform, + }; + + return this.add(() => multisearch(promptOptions), name); }, multiselect( label: string, @@ -30,8 +57,34 @@ export const choicePromptBuilderMethods: ChoicePromptBuilderMethods & ThisType pause(message), name); }, - search(options: SearchPromptOptions, name?: string) { - return this.add(() => search(options), name); + search( + 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, + ) { + if (!isSearchPromptLabel(optionsOrLabel)) { + return this.add(() => search(optionsOrLabel), options as string | undefined); + } + + const promptOptions: SearchPromptOptions = { + message: optionsOrLabel, + label: optionsOrLabel, + options: options as SearchPromptOptions['options'], + placeholder, + scroll, + validate, + hint, + required, + transform, + }; + + return this.add(() => search(promptOptions), name); }, select(label: string, options: ChoiceOptions, defaultValue?: T, scroll = 5, validate = undefined, hint = '', required: boolean | string = true, name?: string, transform = undefined) { return this.add((_, previous) => select({ message: label, options, default: previous === undefined ? defaultValue : (previous as T), scroll, validate, hint, required, transform }), name); diff --git a/packages/tui/src/form/builder/prompts/contracts/choices.ts b/packages/tui/src/form/builder/prompts/contracts/choices.ts index 35c0729f..38d269d5 100644 --- a/packages/tui/src/form/builder/prompts/contracts/choices.ts +++ b/packages/tui/src/form/builder/prompts/contracts/choices.ts @@ -27,6 +27,18 @@ export type ChoicePromptBuilderMethods = { transform?: (value: boolean) => MaybePromise, ): FormBuilder; 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'], + ): FormBuilder; multiselect( this: FormBuilder, label: string, @@ -41,6 +53,18 @@ export type ChoicePromptBuilderMethods = { ): FormBuilder; pause(this: FormBuilder, message?: string, name?: string): FormBuilder; 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'], + ): FormBuilder; select( this: FormBuilder, label: string, diff --git a/packages/tui/src/form/builder/prompts/validators/search.ts b/packages/tui/src/form/builder/prompts/validators/search.ts new file mode 100644 index 00000000..dfaa8be0 --- /dev/null +++ b/packages/tui/src/form/builder/prompts/validators/search.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; + +const searchLabelSchema = z.string(); + +export const isSearchPromptLabel = (value: unknown): value is string => { + return searchLabelSchema.safeParse(value).success; +}; diff --git a/packages/tui/tests/form.test.ts b/packages/tui/tests/form.test.ts index 753582bc..c73492d5 100644 --- a/packages/tui/tests/form.test.ts +++ b/packages/tui/tests/form.test.ts @@ -193,6 +193,53 @@ describe('form builder', () => { expect(responses.many).toEqual(['red']); }); + it('runs label-first search and multisearch form steps', async () => { + const output = createMemoryOutput(); + + const responses = await withPromptEnvironment( + { + input: createScriptedInput(['g', Key.down, Key.enter, 'r', Key.down, Key.space, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + form() + .search( + 'Searched color', + (value) => { + const options = { green: 'Green', blue: 'Blue', red: 'Red' }; + + return Object.fromEntries(Object.entries(options).filter(([, label]) => label.toLowerCase().includes(value.toLowerCase()))); + }, + '', + 5, + undefined, + '', + true, + 'searched', + ) + .multisearch( + 'Many colors', + (value) => { + const options = { red: 'Red', green: 'Green', blue: 'Blue' }; + + return Object.fromEntries(Object.entries(options).filter(([, label]) => label.toLowerCase().includes(value.toLowerCase()))); + }, + '', + 5, + false, + undefined, + 'Use the space bar to select options.', + 'many', + ) + .submit(), + ); + + expect(responses.searched).toBe('green'); + expect(responses.many).toEqual(['red']); + }); + it('runs password and textarea form steps', async () => { const output = createMemoryOutput(); From 7172e67de4dbaabf9bfc4c0bb9a8e9fa7f054ea6 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 21:57:40 +0800 Subject: [PATCH 181/675] Normalize raw terminal keys --- UPSTREAM.md | 3 ++- packages/tui/src/environment/raw-key.ts | 3 ++- .../tui/src/environment/raw-key/normalize.ts | 10 ++++++++++ packages/tui/tests/environment.test.ts | 17 +++++++++++++++++ 4 files changed, 31 insertions(+), 2 deletions(-) create mode 100644 packages/tui/src/environment/raw-key/normalize.ts diff --git a/UPSTREAM.md b/UPSTREAM.md index 68ec715b..db58dac6 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -48,6 +48,7 @@ TypeScript and OpenTUI internals. | 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.ts`, `packages/tui/src/status/frame.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` | +| Terminal raw key alias normalization | `packages/tui/src/environment/raw-key/normalize.ts`, `packages/tui/tests/environment.test.ts` | | ANSI, wrapping, truncation, Unicode width | `packages/tui/src/strings.ts` | | OpenTUI adapter | `packages/tui/src/opentui.ts` | | Type contracts | `packages/tui/src/types.ts`, `packages/tui/src/contracts/*` | @@ -63,7 +64,7 @@ later phases: - Progress signal handling parity - Prompt lifecycle - Search boxed layout, scrollbar, and submit/cancel frame parity -- Terminal interactive read parity +- Terminal interactive escape-sequence read parity - Textarea wrapping, cursor, and scrollbar parity - Default theme - Feature parity tests diff --git a/packages/tui/src/environment/raw-key.ts b/packages/tui/src/environment/raw-key.ts index 3c734cb1..5504a518 100644 --- a/packages/tui/src/environment/raw-key.ts +++ b/packages/tui/src/environment/raw-key.ts @@ -1,4 +1,5 @@ import type { EventEmitter } from 'node:events'; +import { normalizeRawKey } from '#tui/environment/raw-key/normalize'; export type RawKeyInput = Pick & { isRaw?: boolean; @@ -33,7 +34,7 @@ export const readRawKey = async (input: RawKeyInput): Promise => const onData = (chunk: Buffer | string): void => { cleanup(); - resolve(Buffer.isBuffer(chunk) ? chunk.toString('utf8') : chunk); + resolve(normalizeRawKey(Buffer.isBuffer(chunk) ? chunk.toString('utf8') : chunk)); }; const onEnd = (): void => { diff --git a/packages/tui/src/environment/raw-key/normalize.ts b/packages/tui/src/environment/raw-key/normalize.ts new file mode 100644 index 00000000..c20eab40 --- /dev/null +++ b/packages/tui/src/environment/raw-key/normalize.ts @@ -0,0 +1,10 @@ +import { Key } from '#tui/key'; + +const rawKeyAliases = new Map([ + ['\r', Key.enter], + ['\u0008', Key.backspace], +]); + +export const normalizeRawKey = (value: string): string => { + return rawKeyAliases.get(value) ?? value; +}; diff --git a/packages/tui/tests/environment.test.ts b/packages/tui/tests/environment.test.ts index 049ea4f0..a26c541e 100644 --- a/packages/tui/tests/environment.test.ts +++ b/packages/tui/tests/environment.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { EventEmitter } from 'node:events'; import { configurePrompts, createMemoryOutput, createScriptedInput, promptEnvironment, withPromptEnvironment } from '#tui/environment'; import { readRawKey } from '#tui/environment/raw-key'; +import { Key } from '#tui/key'; class FakeRawInput extends EventEmitter { isRaw = false; @@ -112,6 +113,22 @@ describe('prompt environment', () => { expect(input.listenerCount('error')).toBe(0); }); + it('normalizes raw terminal key aliases', async () => { + const input = new FakeRawInput(); + const enter = readRawKey(input); + + input.emit('data', Buffer.from('\r')); + + await expect(enter).resolves.toBe(Key.enter); + + const backspaceInput = new FakeRawInput(); + const backspace = readRawKey(backspaceInput); + + backspaceInput.emit('data', Buffer.from('\u0008')); + + await expect(backspace).resolves.toBe(Key.backspace); + }); + it('restores raw input mode when key input ends', async () => { const input = new FakeRawInput(); const key = readRawKey(input); From 7b5678ceef58f68e9136ef8ee2cc2f2ef66b4fd4 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 22:00:01 +0800 Subject: [PATCH 182/675] Buffer raw escape sequences --- UPSTREAM.md | 2 +- packages/tui/src/environment/raw-key.ts | 29 +++++++++++++++---- .../tui/src/environment/raw-key/normalize.ts | 10 +++++++ packages/tui/tests/environment.test.ts | 23 +++++++++++++++ 4 files changed, 57 insertions(+), 7 deletions(-) diff --git a/UPSTREAM.md b/UPSTREAM.md index db58dac6..50e4232b 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -49,6 +49,7 @@ TypeScript and OpenTUI internals. | Progress cursor visibility and rendered-frame redraw cleanup | `packages/tui/src/status/progress.ts`, `packages/tui/src/status/frame.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` | | 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/normalize.ts`, `packages/tui/tests/environment.test.ts` | | ANSI, wrapping, truncation, Unicode width | `packages/tui/src/strings.ts` | | OpenTUI adapter | `packages/tui/src/opentui.ts` | | Type contracts | `packages/tui/src/types.ts`, `packages/tui/src/contracts/*` | @@ -64,7 +65,6 @@ later phases: - Progress signal handling parity - Prompt lifecycle - Search boxed layout, scrollbar, and submit/cancel frame parity -- Terminal interactive escape-sequence read parity - Textarea wrapping, cursor, and scrollbar parity - Default theme - Feature parity tests diff --git a/packages/tui/src/environment/raw-key.ts b/packages/tui/src/environment/raw-key.ts index 5504a518..252a6701 100644 --- a/packages/tui/src/environment/raw-key.ts +++ b/packages/tui/src/environment/raw-key.ts @@ -1,7 +1,7 @@ import type { EventEmitter } from 'node:events'; -import { normalizeRawKey } from '#tui/environment/raw-key/normalize'; +import { isCompleteRawKey, normalizeRawKey } from '#tui/environment/raw-key/normalize'; -export type RawKeyInput = Pick & { +export type RawKeyInput = Pick & { isRaw?: boolean; isTTY?: boolean; pause(): unknown; @@ -25,21 +25,38 @@ export const readRawKey = async (input: RawKeyInput): Promise => new Promise((resolve, reject) => { const wasRaw = Boolean(input.isRaw); + let buffer = ''; + let escapeTimer: NodeJS.Timeout | undefined; + const cleanup = (): void => { + clearTimeout(escapeTimer); input.off('data', onData); input.off('end', onEnd); input.off('error', onError); restoreInput(input, wasRaw); }; - const onData = (chunk: Buffer | string): void => { + const resolveBufferedKey = (): void => { cleanup(); - resolve(normalizeRawKey(Buffer.isBuffer(chunk) ? chunk.toString('utf8') : chunk)); + resolve(normalizeRawKey(buffer)); + }; + + const onData = (chunk: Buffer | string): void => { + buffer += Buffer.isBuffer(chunk) ? chunk.toString('utf8') : chunk; + + if (isCompleteRawKey(buffer)) { + resolveBufferedKey(); + + return; + } + + clearTimeout(escapeTimer); + escapeTimer = setTimeout(resolveBufferedKey, 25); }; const onEnd = (): void => { cleanup(); - resolve(null); + resolve(buffer.length > 0 ? normalizeRawKey(buffer) : null); }; const onError = (error: Error): void => { @@ -47,7 +64,7 @@ export const readRawKey = async (input: RawKeyInput): Promise => reject(error); }; - input.once('data', onData); + input.on('data', onData); input.once('end', onEnd); input.once('error', onError); diff --git a/packages/tui/src/environment/raw-key/normalize.ts b/packages/tui/src/environment/raw-key/normalize.ts index c20eab40..fc1aed9d 100644 --- a/packages/tui/src/environment/raw-key/normalize.ts +++ b/packages/tui/src/environment/raw-key/normalize.ts @@ -1,5 +1,7 @@ import { Key } from '#tui/key'; +const knownRawKeys: string[] = Object.values(Key).flatMap((value) => (typeof value === 'string' ? [value] : [...value])); + const rawKeyAliases = new Map([ ['\r', Key.enter], ['\u0008', Key.backspace], @@ -8,3 +10,11 @@ const rawKeyAliases = new Map([ export const normalizeRawKey = (value: string): string => { return rawKeyAliases.get(value) ?? value; }; + +export const isCompleteRawKey = (value: string): boolean => { + return rawKeyAliases.has(value) || !isPartialRawKey(value); +}; + +export const isPartialRawKey = (value: string): boolean => { + return knownRawKeys.some((key) => key !== value && key.startsWith(value)); +}; diff --git a/packages/tui/tests/environment.test.ts b/packages/tui/tests/environment.test.ts index a26c541e..060aac5b 100644 --- a/packages/tui/tests/environment.test.ts +++ b/packages/tui/tests/environment.test.ts @@ -129,6 +129,29 @@ describe('prompt environment', () => { await expect(backspace).resolves.toBe(Key.backspace); }); + it('buffers split raw terminal escape sequences', async () => { + const input = new FakeRawInput(); + const key = readRawKey(input); + + input.emit('data', Buffer.from('\u001B')); + input.emit('data', Buffer.from('[')); + input.emit('data', Buffer.from('A')); + + await expect(key).resolves.toBe(Key.up); + + expect(input.rawModes).toEqual([true, false]); + }); + + it('returns buffered partial escape sequences when input ends', async () => { + const input = new FakeRawInput(); + const key = readRawKey(input); + + input.emit('data', Buffer.from('\u001B')); + input.emit('end'); + + await expect(key).resolves.toBe(Key.escape); + }); + it('restores raw input mode when key input ends', async () => { const input = new FakeRawInput(); const key = readRawKey(input); From 025cc04bbaaa1e20f74c7748c9938121d5553ce4 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 22:02:35 +0800 Subject: [PATCH 183/675] Restore progress on signals --- UPSTREAM.md | 2 +- packages/tui/src/status/progress.ts | 42 ++++++++++++++++++++++++++++- packages/tui/tests/progress.test.ts | 33 ++++++++++++++++++++++- 3 files changed, 74 insertions(+), 3 deletions(-) diff --git a/UPSTREAM.md b/UPSTREAM.md index 50e4232b..61ef8425 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -47,6 +47,7 @@ TypeScript and OpenTUI internals. | Status cursor visibility cleanup | `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.ts`, `packages/tui/src/status/frame.ts` | +| Progress signal cleanup | `packages/tui/src/status/progress.ts`, `packages/tui/tests/progress.test.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` | | 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/normalize.ts`, `packages/tui/tests/environment.test.ts` | @@ -62,7 +63,6 @@ later phases: - Form builder remaining method-surface and validation parity - Multi-search full rendering and selected-result summary parity - Multi-select boxed layout and submit/cancel frame parity -- Progress signal handling parity - Prompt lifecycle - Search boxed layout, scrollbar, and submit/cancel frame parity - Textarea wrapping, cursor, and scrollbar parity diff --git a/packages/tui/src/status/progress.ts b/packages/tui/src/status/progress.ts index 4c15ec1b..3f5e0e1d 100644 --- a/packages/tui/src/status/progress.ts +++ b/packages/tui/src/status/progress.ts @@ -8,6 +8,13 @@ import { hideCursor, showCursor } from '#tui/terminal'; import type { ProgressFrameState } from '#tui/status/progress/render'; import type { MaybePromise } from '#tui/types'; +const progressSignals = ['SIGINT', 'SIGTERM'] as const; + +type ProgressSignalTarget = { + off(signal: string, listener: () => void): unknown; + on(signal: string, listener: () => void): unknown; +}; + export class Progress { #current = 0; #label: string; @@ -15,12 +22,18 @@ export class Progress { #state: ProgressFrameState = 'active'; #renderedFrame: string | null = null; #cursorHidden = false; + #signalsAttached = false; + readonly #signalTarget: ProgressSignalTarget; readonly total: number; + #handleSignal = (): void => { + this.fail(); + }; - constructor(total: number, message = 'Progress', hint = '') { + constructor(total: number, message = 'Progress', hint = '', signalTarget: ProgressSignalTarget = process) { this.total = parseProgressTotal(total); this.#label = message; this.#hint = hint; + this.#signalTarget = signalTarget; } start(): void { @@ -76,6 +89,7 @@ export class Progress { if (!this.#cursorHidden) { hideCursor(); this.#cursorHidden = true; + this.#attachSignalHandlers(); } if (this.#renderedFrame) { @@ -96,6 +110,32 @@ export class Progress { showCursor(); this.#cursorHidden = false; } + + this.#detachSignalHandlers(); + } + + #attachSignalHandlers(): void { + if (this.#signalsAttached) { + return; + } + + for (const signal of progressSignals) { + this.#signalTarget.on(signal, this.#handleSignal); + } + + this.#signalsAttached = true; + } + + #detachSignalHandlers(): void { + if (!this.#signalsAttached) { + return; + } + + for (const signal of progressSignals) { + this.#signalTarget.off(signal, this.#handleSignal); + } + + this.#signalsAttached = false; } } diff --git a/packages/tui/tests/progress.test.ts b/packages/tui/tests/progress.test.ts index b9d837e8..1d115824 100644 --- a/packages/tui/tests/progress.test.ts +++ b/packages/tui/tests/progress.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { createMemoryOutput, progress, withPromptEnvironment } from '#tui/index'; +import { EventEmitter } from 'node:events'; +import { createMemoryOutput, Progress, progress, withPromptEnvironment } from '#tui/index'; describe('progress helper', () => { it('maps iterable steps and returns callback results', async () => { @@ -52,6 +53,7 @@ describe('progress helper', () => { bar.start(); bar.advance(); + bar.finish(); }); expect(output.text()).toContain('█'); @@ -84,6 +86,8 @@ describe('progress helper', () => { expect(bar.current()).toBe(1); expect(bar.value()).toBe(true); + + bar.finish(); }); }); @@ -98,6 +102,8 @@ describe('progress helper', () => { bar.advance(-5); expect(bar.current()).toBe(0); + + bar.finish(); }); expect(output.text()).toContain('2 / 2'); @@ -115,6 +121,8 @@ describe('progress helper', () => { bar.advance(Number.NaN); expect(bar.current()).toBe(0); + + bar.finish(); }); expect(output.text()).toContain('0 / 2'); @@ -141,4 +149,27 @@ describe('progress helper', () => { expect(output.text()).toContain('\u001B[1A\u001B[2K'); expect(output.text()).toContain('\u001B[?25h'); }); + + it('restores progress terminal state when process signals are received', async () => { + const output = createMemoryOutput(); + const signals = new EventEmitter(); + + await withPromptEnvironment({ output, error: output }, async () => { + const bar = new Progress(2, 'Adding States', '', signals); + + bar.start(); + + expect(signals.listenerCount('SIGINT')).toBe(1); + expect(signals.listenerCount('SIGTERM')).toBe(1); + + signals.emit('SIGINT'); + + expect(signals.listenerCount('SIGINT')).toBe(0); + expect(signals.listenerCount('SIGTERM')).toBe(0); + }); + + expect(output.text()).toContain('\u001B[?25l'); + expect(output.text()).toContain('\u001B[?25h'); + expect(output.text()).toContain('\u001B[1A\u001B[2K'); + }); }); From 83ecf5c3651091ccf59d49a263c788e2a1afbead Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 22:04:52 +0800 Subject: [PATCH 184/675] Render choice scrollbars --- UPSTREAM.md | 3 +- packages/tui/src/concerns/choices.ts | 26 +++++++++------ packages/tui/src/concerns/scrollbar.ts | 39 +++++++++++++++++++++++ packages/tui/src/prompts/search/render.ts | 9 ++++-- packages/tui/tests/choices.test.ts | 4 +++ packages/tui/tests/search.test.ts | 4 +++ 6 files changed, 72 insertions(+), 13 deletions(-) create mode 100644 packages/tui/src/concerns/scrollbar.ts diff --git a/UPSTREAM.md b/UPSTREAM.md index 61ef8425..6a9ceda2 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -31,6 +31,7 @@ TypeScript and OpenTUI internals. | Search highlighted-result row styling | `packages/tui/src/prompts/search/render.ts`, `packages/tui/src/theme/styles.ts`, `packages/tui/tests/search.test.ts` | | Pause | `packages/tui/src/prompts/pause.ts` | | Choice normalization, matching, navigation, and list rendering concerns | `packages/tui/src/concerns/choices.ts` | +| Choice and search scrollbar rendering | `packages/tui/src/concerns/scrollbar.ts`, `packages/tui/src/concerns/choices.ts`, `packages/tui/src/prompts/search/render.ts` | | Form builder | `packages/tui/src/form.ts`, `packages/tui/src/form/builder/*` | | Notes and alert-style output | `packages/tui/src/output/notes.ts` | | Native notifications | `packages/tui/src/output/notify.ts` | @@ -64,7 +65,7 @@ later phases: - Multi-search full rendering and selected-result summary parity - Multi-select boxed layout and submit/cancel frame parity - Prompt lifecycle -- Search boxed layout, scrollbar, and submit/cancel frame parity +- Search boxed layout and submit/cancel frame parity - Textarea wrapping, cursor, and scrollbar parity - Default theme - Feature parity tests diff --git a/packages/tui/src/concerns/choices.ts b/packages/tui/src/concerns/choices.ts index 03f625be..b1a0c99e 100644 --- a/packages/tui/src/concerns/choices.ts +++ b/packages/tui/src/concerns/choices.ts @@ -2,6 +2,7 @@ import { promptEnvironment } from '#tui/environment'; import { parseChoiceAnswerIndex, parseChoiceRecordKey } from '#tui/concerns/validators/choice-answer'; import { parseChoice, parseChoiceRecord } from '#tui/concerns/validators/choice'; import { parseOptionalScrollSize } from '#tui/concerns/validators/scroll'; +import { renderScrollbarRows } from '#tui/concerns/scrollbar'; import { cyan, dim } from '#tui/theme/styles'; import type { Choice, ChoiceOptions } from '#tui/types'; @@ -85,14 +86,18 @@ export const renderInteractiveChoices = (message: string, choices: Array { const index = window.start + offset; const pointer = index === selected ? '›' : ' '; const checked = marked.size > 0 ? (marked.has(index) ? '[x]' : '[ ]') : ' '; const disabled = choice.disabled ? ` (${typeof choice.disabled === 'string' ? choice.disabled : 'disabled'})` : ''; const hint = choice.hint ? ` ${choice.hint}` : ''; - environment.output.write(`${pointer} ${checked} ${choice.label}${hint}${disabled}\n`); + return `${pointer} ${checked} ${choice.label}${hint}${disabled}`; + }); + + for (const row of renderScrollbarRows(rows, window.start, window.end - window.start, choices.length)) { + environment.output.write(`${row}\n`); } }; @@ -102,7 +107,7 @@ export const renderInteractiveChecklist = (message: string, choices: Array { const index = window.start + offset; const active = index === selected; const checked = marked.has(index); @@ -113,20 +118,21 @@ export const renderInteractiveChecklist = (message: string, choices: Array { + 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): string[] => { + if (height >= total || rows.length === 0) { + return rows; + } + + const position = scrollbarPosition(firstVisible, height, total); + const width = Math.max(...rows.map((row) => visibleWidth(row))); + + return rows.map((row, index) => { + const bar = index === position ? cyan('┃') : 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/prompts/search/render.ts b/packages/tui/src/prompts/search/render.ts index 4ba28b98..70339f38 100644 --- a/packages/tui/src/prompts/search/render.ts +++ b/packages/tui/src/prompts/search/render.ts @@ -1,6 +1,7 @@ import { promptEnvironment } from '#tui/environment'; import { choiceWindow } from '#tui/concerns/choices'; import { resolveInfo } from '#tui/concerns/info'; +import { renderScrollbarRows } from '#tui/concerns/scrollbar'; import { searchMessage } from '#tui/prompts/search/choices'; import { cyan, dim } from '#tui/theme/styles'; import type { Choice, MultiSearchPromptOptions, SearchPromptOptions } from '#tui/types'; @@ -41,13 +42,17 @@ const renderSearchRows = (message: string, choices: Array>, highlig environment.output.write(`${message}\n`); - for (const [offset, choice] of choices.slice(window.start, window.end).entries()) { + 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); - environment.output.write(`${multiple ? multiSearchRow(label, active, selected) : searchRow(label, active)}\n`); + return multiple ? multiSearchRow(label, active, selected) : searchRow(label, active); + }); + + for (const row of renderScrollbarRows(rows, window.start, window.end - window.start, choices.length)) { + environment.output.write(`${row}\n`); } }; diff --git a/packages/tui/tests/choices.test.ts b/packages/tui/tests/choices.test.ts index 2bb006a2..b0f2255c 100644 --- a/packages/tui/tests/choices.test.ts +++ b/packages/tui/tests/choices.test.ts @@ -193,6 +193,8 @@ describe('choice prompts', () => { expect(latestFrame).toContain('second'); expect(latestFrame).toContain('third'); expect(latestFrame).toContain('fourth'); + expect(latestFrame).toContain('┃'); + expect(latestFrame).toContain('│'); }); it('supports alternate select navigation keys', async () => { @@ -603,6 +605,8 @@ describe('choice prompts', () => { expect(output.text()).toContain('0 selected'); expect(output.text()).toContain('1 selected'); + expect(output.text()).toContain('┃'); + expect(output.text()).toContain('│'); }); it('supports page multiselect navigation keys', async () => { diff --git a/packages/tui/tests/search.test.ts b/packages/tui/tests/search.test.ts index 7641e8c8..80474e2b 100644 --- a/packages/tui/tests/search.test.ts +++ b/packages/tui/tests/search.test.ts @@ -114,6 +114,8 @@ describe('search prompt', () => { expect(latestFrame).toContain('two'); expect(latestFrame).toContain('three'); expect(latestFrame).toContain('four'); + expect(latestFrame).toContain('┃'); + expect(latestFrame).toContain('│'); }); it('supports page search navigation keys', async () => { @@ -752,6 +754,8 @@ describe('multisearch prompt', () => { expect(result).toEqual(['blue', 'red']); expect(output.text()).toContain('Selected: Blue, Red'); + expect(output.text()).toContain('┃'); + expect(output.text()).toContain('│'); }); it('skips disabled multisearch results while navigating and toggling all', async () => { From cf04dc5c650688da2505c8078dcd4b9ac8c5ed9c Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 22:06:44 +0800 Subject: [PATCH 185/675] Render textarea scrollbars --- UPSTREAM.md | 3 +- packages/tui/src/typed-value/lines.ts | 30 +++++++++++++---- packages/tui/src/typed-value/render.ts | 6 ++-- .../tui/src/typed-value/textarea-frame.ts | 33 ++++++++++++------- packages/tui/tests/textarea.test.ts | 2 ++ packages/tui/tests/typed-value.test.ts | 9 +++++ 6 files changed, 61 insertions(+), 22 deletions(-) diff --git a/UPSTREAM.md b/UPSTREAM.md index 6a9ceda2..03d2abb7 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -19,6 +19,7 @@ TypeScript and OpenTUI internals. | Textarea input | `packages/tui/src/prompts/textarea.ts`, `packages/tui/src/typed-value.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/tests/textarea.test.ts` | | Password input | `packages/tui/src/prompts/password.ts`, `packages/tui/src/typed-value.ts` | | Number input | `packages/tui/src/prompts/number.ts`, `packages/tui/src/typed-value.ts` | | Confirm, select, multiselect | `packages/tui/src/prompts/select/*` | @@ -66,7 +67,7 @@ later phases: - Multi-select boxed layout and submit/cancel frame parity - Prompt lifecycle - Search boxed layout and submit/cancel frame parity -- Textarea wrapping, cursor, and scrollbar parity +- Textarea wrapping and cursor parity - Default theme - Feature parity tests diff --git a/packages/tui/src/typed-value/lines.ts b/packages/tui/src/typed-value/lines.ts index 8f988da4..1da657f4 100644 --- a/packages/tui/src/typed-value/lines.ts +++ b/packages/tui/src/typed-value/lines.ts @@ -5,6 +5,12 @@ type LineRange = { start: number; }; +export type VisibleLineWindow = { + lines: string[]; + start: number; + total: number; +}; + const lineRanges = (value: string[]): LineRange[] => { const ranges: LineRange[] = []; @@ -29,20 +35,30 @@ const currentLine = (ranges: LineRange[], cursor: number): number => { }; export const visibleLines = (value: string, cursor: number, rows: number | undefined): string => { - if (rows === undefined || rows <= 0) { - return value; - } + return visibleLineWindow(value, cursor, rows).lines.join('\n'); +}; +export const visibleLineWindow = (value: string, cursor: number, rows: number | undefined): VisibleLineWindow => { const valueCharacters = [...value]; const ranges = lineRanges(valueCharacters); + + if (rows === undefined || rows <= 0) { + return { + lines: ranges.map((range) => fromCharacters(valueCharacters.slice(range.start, range.end))), + start: 0, + total: ranges.length, + }; + } + const line = currentLine(ranges, cursor); const start = Math.max(0, line - rows + 1); const end = start + rows; - return ranges - .slice(start, end) - .map((range) => fromCharacters(valueCharacters.slice(range.start, range.end))) - .join('\n'); + return { + lines: ranges.slice(start, end).map((range) => fromCharacters(valueCharacters.slice(range.start, range.end))), + start, + total: ranges.length, + }; }; export const moveLine = (value: string[], cursor: number, direction: 1 | -1): number => { diff --git a/packages/tui/src/typed-value/render.ts b/packages/tui/src/typed-value/render.ts index c60caa6c..2b6bd791 100644 --- a/packages/tui/src/typed-value/render.ts +++ b/packages/tui/src/typed-value/render.ts @@ -6,10 +6,8 @@ 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): void => { - const displayValue = state.value.length > 0 ? visibleLines(state.value, state.cursor, options.rows) : (options.placeholder ?? ''); - if (options.allowNewLine) { - promptEnvironment().output.write(`${renderTextareaFrame(message, displayValue, options)}\n`); + promptEnvironment().output.write(`${renderTextareaFrame(message, state, options)}\n`); if (options.hint) { promptEnvironment().output.write(`${dim(options.hint)}\n`); @@ -18,5 +16,7 @@ export const renderTypedValue = (message: string, state: TypedValueState, option return; } + const displayValue = state.value.length > 0 ? visibleLines(state.value, state.cursor, options.rows) : (options.placeholder ?? ''); + promptEnvironment().output.write(`${renderQuestion(message, options.hint)}${displayValue}\n`); }; diff --git a/packages/tui/src/typed-value/textarea-frame.ts b/packages/tui/src/typed-value/textarea-frame.ts index a697b8a3..b670a545 100644 --- a/packages/tui/src/typed-value/textarea-frame.ts +++ b/packages/tui/src/typed-value/textarea-frame.ts @@ -1,26 +1,37 @@ +import { renderScrollbarRows } from '#tui/concerns/scrollbar'; import { renderBox } from '#tui/theme/box'; import { dim } from '#tui/theme/styles'; -import type { TypedValueOptions } from '#tui/typed-value/types'; +import { visibleLineWindow } from '#tui/typed-value/lines'; +import type { TypedValueOptions, TypedValueState } from '#tui/typed-value/types'; -export const renderTextareaFrame = (message: string, displayValue: string, options: TypedValueOptions): string => { - return renderBox({ body: textareaBody(displayValue, options), title: message }); +export const renderTextareaFrame = (message: string, state: TypedValueState, options: TypedValueOptions): string => { + return renderBox({ body: textareaBody(state, options), title: message }); }; -const textareaBody = (displayValue: string, options: TypedValueOptions): string => { +const textareaBody = (state: TypedValueState, options: TypedValueOptions): string => { const rows = options.rows === undefined || options.rows <= 0 ? undefined : Math.floor(options.rows); - const lines = displayValue.split('\n'); - if (isPlaceholder(displayValue, options)) { - lines[0] = dim(displayValue); + if (state.value.length === 0) { + return placeholderBody(options, rows); } + const window = visibleLineWindow(state.value, state.cursor, rows); + if (rows === undefined) { - return lines.join('\n'); + return window.lines.join('\n'); } - return [...lines.slice(0, rows), ...Array.from({ length: Math.max(0, rows - lines.length) }, () => '')].join('\n'); + const padded = [...window.lines.slice(0, rows), ...Array.from({ length: Math.max(0, rows - window.lines.length) }, () => '')]; + + return renderScrollbarRows(padded, window.start, rows, window.total).join('\n'); }; -const isPlaceholder = (displayValue: string, options: TypedValueOptions): boolean => { - return displayValue.length > 0 && displayValue === options.placeholder; +const placeholderBody = (options: TypedValueOptions, rows: number | undefined): string => { + const lines = [dim(options.placeholder ?? '')]; + + if (rows === undefined) { + return lines.join('\n'); + } + + return [...lines, ...Array.from({ length: Math.max(0, rows - lines.length) }, () => '')].join('\n'); }; diff --git a/packages/tui/tests/textarea.test.ts b/packages/tui/tests/textarea.test.ts index e1d9bfdb..e80a92b9 100644 --- a/packages/tui/tests/textarea.test.ts +++ b/packages/tui/tests/textarea.test.ts @@ -57,6 +57,8 @@ describe('textarea prompt', () => { expect(latestFrame).toContain('│ B'); expect(latestFrame).toContain('│ C'); expect(latestFrame).not.toContain('A\nB\nC'); + expect(latestFrame).toContain('\u001B[2m│\u001B[22m'); + expect(latestFrame).toContain('\u001B[36m┃\u001B[39m'); }); it('pads textarea frames to the configured rows', async () => { diff --git a/packages/tui/tests/typed-value.test.ts b/packages/tui/tests/typed-value.test.ts index e6727299..aaedbd50 100644 --- a/packages/tui/tests/typed-value.test.ts +++ b/packages/tui/tests/typed-value.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import { applyTypedKey, Key } from '#tui/index'; +import { visibleLineWindow } from '#tui/typed-value/lines'; const apply = (keys: string[]): string => { let state = { cursor: 0, value: '' }; @@ -154,4 +155,12 @@ describe('typed value editing', () => { value: 'abc\nde\nhi', }); }); + + it('reports visible textarea line windows', () => { + expect(visibleLineWindow('A\nB\nC', 5, 2)).toEqual({ + lines: ['B', 'C'], + start: 1, + total: 3, + }); + }); }); From 5c37634aacd171924ee2a90813c67e7aabe35483 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 22:08:49 +0800 Subject: [PATCH 186/675] Render submitted choice frames --- UPSTREAM.md | 4 +++- packages/tui/src/prompts/search/read-single.ts | 11 +++++++++-- packages/tui/src/prompts/search/render.ts | 5 +++++ packages/tui/src/prompts/select/read-selected.ts | 10 ++++++++-- packages/tui/src/prompts/select/render.ts | 6 ++++++ packages/tui/tests/choices.test.ts | 3 +++ packages/tui/tests/search.test.ts | 3 +++ 7 files changed, 37 insertions(+), 5 deletions(-) diff --git a/UPSTREAM.md b/UPSTREAM.md index 03d2abb7..ec65a0eb 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -23,6 +23,7 @@ TypeScript and OpenTUI internals. | Password input | `packages/tui/src/prompts/password.ts`, `packages/tui/src/typed-value.ts` | | Number input | `packages/tui/src/prompts/number.ts`, `packages/tui/src/typed-value.ts` | | Confirm, select, multiselect | `packages/tui/src/prompts/select/*` | +| Select submitted final frame | `packages/tui/src/prompts/select/render.ts`, `packages/tui/src/prompts/select/read-selected.ts`, `packages/tui/tests/choices.test.ts` | | Multi-select checklist markers | `packages/tui/src/concerns/choices.ts`, `packages/tui/src/prompts/select/render.ts`, `packages/tui/tests/choices.test.ts` | | Multi-select active and selected row styling | `packages/tui/src/concerns/choices.ts`, `packages/tui/tests/choices.test.ts` | | Suggest, autocomplete | `packages/tui/src/prompts/suggest/*` | @@ -30,6 +31,7 @@ TypeScript and OpenTUI internals. | Search, multisearch | `packages/tui/src/prompts/search/*` | | Search and multisearch empty-result rendering | `packages/tui/src/prompts/search/render.ts`, `packages/tui/tests/search.test.ts` | | Search highlighted-result row styling | `packages/tui/src/prompts/search/render.ts`, `packages/tui/src/theme/styles.ts`, `packages/tui/tests/search.test.ts` | +| Search submitted final frame | `packages/tui/src/prompts/search/render.ts`, `packages/tui/src/prompts/search/read-single.ts`, `packages/tui/tests/search.test.ts` | | Pause | `packages/tui/src/prompts/pause.ts` | | Choice normalization, matching, navigation, and list rendering concerns | `packages/tui/src/concerns/choices.ts` | | Choice and search scrollbar rendering | `packages/tui/src/concerns/scrollbar.ts`, `packages/tui/src/concerns/choices.ts`, `packages/tui/src/prompts/search/render.ts` | @@ -66,7 +68,7 @@ later phases: - Multi-search full rendering and selected-result summary parity - Multi-select boxed layout and submit/cancel frame parity - Prompt lifecycle -- Search boxed layout and submit/cancel frame parity +- Search boxed layout and cancel frame parity - Textarea wrapping and cursor parity - Default theme - Feature parity tests diff --git a/packages/tui/src/prompts/search/read-single.ts b/packages/tui/src/prompts/search/read-single.ts index 751cb679..cc8eba1f 100644 --- a/packages/tui/src/prompts/search/read-single.ts +++ b/packages/tui/src/prompts/search/read-single.ts @@ -3,7 +3,7 @@ import { Key } from '#tui/key'; import { applyTypedKey } from '#tui/typed-value'; import { resolveSearchChoices } from '#tui/prompts/search/choices'; import { clearsSearchHighlight, moveSearchHighlight, searchNavigationAction } from '#tui/prompts/search/keys'; -import { renderSearchChoices } from '#tui/prompts/search/render'; +import { renderSearchChoices, renderSubmittedSearchChoice } from '#tui/prompts/search/render'; import { cancelledSearchValue, lineSearchValue, selectedSearchValue } from '#tui/prompts/search/read-single/result'; import type { SearchPromptOptions } from '#tui/types'; @@ -51,7 +51,14 @@ export const readSearchChoice = async (options: SearchPromptOptions, attem if (key === Key.enter) { if (highlighted !== null) { - return selectedSearchValue(choices, highlighted); + const choice = choices[highlighted]; + const value = selectedSearchValue(choices, highlighted); + + if (choice && value !== undefined) { + renderSubmittedSearchChoice(options.message, choice.label); + } + + return value; } choices = await resolveSearchChoices(options.options, state.value); diff --git a/packages/tui/src/prompts/search/render.ts b/packages/tui/src/prompts/search/render.ts index 70339f38..059d687d 100644 --- a/packages/tui/src/prompts/search/render.ts +++ b/packages/tui/src/prompts/search/render.ts @@ -3,6 +3,7 @@ import { choiceWindow } from '#tui/concerns/choices'; import { resolveInfo } from '#tui/concerns/info'; import { renderScrollbarRows } from '#tui/concerns/scrollbar'; import { searchMessage } from '#tui/prompts/search/choices'; +import { renderBox } from '#tui/theme/box'; import { cyan, dim } from '#tui/theme/styles'; import type { Choice, MultiSearchPromptOptions, SearchPromptOptions } from '#tui/types'; @@ -36,6 +37,10 @@ export const renderSearchChoices = ( } }; +export const renderSubmittedSearchChoice = (message: string, label: string): void => { + promptEnvironment().output.write(`${renderBox({ body: label, title: dim(message) })}\n`); +}; + const renderSearchRows = (message: string, choices: Array>, highlighted: number | null, marked: Set, scroll: number | undefined, multiple: boolean): void => { const environment = promptEnvironment(); const window = choiceWindow(choices.length, highlighted ?? 0, scroll); diff --git a/packages/tui/src/prompts/select/read-selected.ts b/packages/tui/src/prompts/select/read-selected.ts index d593d75c..39efba07 100644 --- a/packages/tui/src/prompts/select/read-selected.ts +++ b/packages/tui/src/prompts/select/read-selected.ts @@ -5,7 +5,7 @@ import { renderChoices } from '#tui/theme'; import { findChoice, firstEnabledIndex } from '#tui/concerns/choices'; import { moveSelectHighlight, selectNavigationAction } from '#tui/prompts/select/keys'; import { parseChoiceIndex } from '#tui/prompts/select/navigation'; -import { renderSelectedChoice } from '#tui/prompts/select/render'; +import { renderSelectedChoice, renderSubmittedChoice } from '#tui/prompts/select/render'; import type { Choice, SelectPromptOptions } from '#tui/types'; const defaultChoiceIndex = (choices: Array>, defaultValue: T | undefined): number => { @@ -61,7 +61,11 @@ export const readSelectedChoice = async (message: string, choices: Array(message: string, choices: Array(message: string, choices: Array>, selected: number, scroll: number | undefined, info: SelectPromptOptions['info']): void => { @@ -13,6 +15,10 @@ export const renderSelectedChoice = (message: string, choices: Array { + promptEnvironment().output.write(`${renderBox({ body: label, title: dim(message) })}\n`); +}; + export const renderMultipleChoices = (message: string, choices: Array>, selected: number, marked: Set, scroll?: number, info?: MultiSelectPromptOptions['info']): void => { renderInteractiveChecklist(message, choices, selected, marked, scroll); diff --git a/packages/tui/tests/choices.test.ts b/packages/tui/tests/choices.test.ts index b0f2255c..7d81a537 100644 --- a/packages/tui/tests/choices.test.ts +++ b/packages/tui/tests/choices.test.ts @@ -122,6 +122,8 @@ describe('choice prompts', () => { expect(result).toBe('second'); expect(output.text()).toContain('Pick one'); + expect(output.text()).toContain('┌ \u001B[2mPick one\u001B[22m '); + expect(output.text()).toContain('│ second'); }); it('returns keys from keyed select options', async () => { @@ -171,6 +173,7 @@ describe('choice prompts', () => { expect(result).toBe('third'); expect(output.text()).toContain('› third'); + expect(output.text()).toContain('│ third'); }); it('renders a scrolling select window around the highlighted choice', async () => { diff --git a/packages/tui/tests/search.test.ts b/packages/tui/tests/search.test.ts index 80474e2b..3dad0c57 100644 --- a/packages/tui/tests/search.test.ts +++ b/packages/tui/tests/search.test.ts @@ -28,6 +28,8 @@ describe('search prompt', () => { expect(result).toBe('blue'); expect(output.text()).toContain('Favorite color? ue'); + expect(output.text()).toContain('┌ \u001B[2mFavorite color?\u001B[22m '); + expect(output.text()).toContain('│ Blue'); }); it('validates selected values and allows another selection', async () => { @@ -137,6 +139,7 @@ describe('search prompt', () => { ); expect(result).toBe('three'); + expect(output.text()).toContain('│ three'); }); it('supports control-key search navigation', async () => { From 95450b91b4d24264a4afc25d43a017a05c658b35 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 22:11:21 +0800 Subject: [PATCH 187/675] Render submitted multichoice frames --- UPSTREAM.md | 6 ++++-- packages/tui/src/prompts/search/read-multi.ts | 4 +++- packages/tui/src/prompts/search/render.ts | 6 ++++++ packages/tui/src/prompts/select/read-multiple.ts | 13 ++++++++++++- packages/tui/src/prompts/select/render.ts | 6 ++++++ packages/tui/tests/choices.test.ts | 4 ++++ packages/tui/tests/search.test.ts | 4 ++++ 7 files changed, 39 insertions(+), 4 deletions(-) diff --git a/UPSTREAM.md b/UPSTREAM.md index ec65a0eb..e79ec5ce 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -26,12 +26,14 @@ TypeScript and OpenTUI internals. | Select submitted final frame | `packages/tui/src/prompts/select/render.ts`, `packages/tui/src/prompts/select/read-selected.ts`, `packages/tui/tests/choices.test.ts` | | Multi-select checklist markers | `packages/tui/src/concerns/choices.ts`, `packages/tui/src/prompts/select/render.ts`, `packages/tui/tests/choices.test.ts` | | Multi-select active and selected row styling | `packages/tui/src/concerns/choices.ts`, `packages/tui/tests/choices.test.ts` | +| Multi-select submitted final frame | `packages/tui/src/prompts/select/render.ts`, `packages/tui/src/prompts/select/read-multiple.ts`, `packages/tui/tests/choices.test.ts` | | Suggest, autocomplete | `packages/tui/src/prompts/suggest/*` | | 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` | | Search, multisearch | `packages/tui/src/prompts/search/*` | | Search and multisearch empty-result rendering | `packages/tui/src/prompts/search/render.ts`, `packages/tui/tests/search.test.ts` | | Search highlighted-result row styling | `packages/tui/src/prompts/search/render.ts`, `packages/tui/src/theme/styles.ts`, `packages/tui/tests/search.test.ts` | | Search submitted final frame | `packages/tui/src/prompts/search/render.ts`, `packages/tui/src/prompts/search/read-single.ts`, `packages/tui/tests/search.test.ts` | +| Multi-search submitted final frame | `packages/tui/src/prompts/search/render.ts`, `packages/tui/src/prompts/search/read-multi.ts`, `packages/tui/tests/search.test.ts` | | Pause | `packages/tui/src/prompts/pause.ts` | | Choice normalization, matching, navigation, and list rendering concerns | `packages/tui/src/concerns/choices.ts` | | Choice and search scrollbar rendering | `packages/tui/src/concerns/scrollbar.ts`, `packages/tui/src/concerns/choices.ts`, `packages/tui/src/prompts/search/render.ts` | @@ -65,8 +67,8 @@ The following reference areas still need full line-by-line behavioral mapping in later phases: - Form builder remaining method-surface and validation parity -- Multi-search full rendering and selected-result summary parity -- Multi-select boxed layout and submit/cancel frame parity +- Multi-search boxed layout and cancel frame parity +- Multi-select boxed layout and cancel frame parity - Prompt lifecycle - Search boxed layout and cancel frame parity - Textarea wrapping and cursor parity diff --git a/packages/tui/src/prompts/search/read-multi.ts b/packages/tui/src/prompts/search/read-multi.ts index 60f386cf..c27f47d3 100644 --- a/packages/tui/src/prompts/search/read-multi.ts +++ b/packages/tui/src/prompts/search/read-multi.ts @@ -3,7 +3,7 @@ import { Key } from '#tui/key'; import { applyTypedKey } from '#tui/typed-value'; import { resolveSearchChoices } from '#tui/prompts/search/choices'; import { moveSearchHighlight, searchNavigationAction } from '#tui/prompts/search/keys'; -import { renderSearchChoices } from '#tui/prompts/search/render'; +import { renderSearchChoices, renderSubmittedSearchChoices } from '#tui/prompts/search/render'; import { lineMultiSearchValues, selectedSearchValues, toggleHighlightedSearchChoice } from '#tui/prompts/search/read-multi/result'; import { createInitialSearchSelection, displayedSearchChoices, markedSearchChoiceIndexes, toggleSearchChoices } from '#tui/prompts/search/selection'; import type { MultiSearchPromptOptions } from '#tui/types'; @@ -37,6 +37,8 @@ export const readMultiSearchChoices = async (options: MultiSearchPromptOption const key = await environment.input.readKey(); if (key === null || key === Key.enter) { + renderSubmittedSearchChoices(options.message, [...selected.values()]); + return selectedSearchValues(selected); } diff --git a/packages/tui/src/prompts/search/render.ts b/packages/tui/src/prompts/search/render.ts index 059d687d..ff08ef1b 100644 --- a/packages/tui/src/prompts/search/render.ts +++ b/packages/tui/src/prompts/search/render.ts @@ -41,6 +41,12 @@ export const renderSubmittedSearchChoice = (message: string, label: string): voi promptEnvironment().output.write(`${renderBox({ body: label, title: dim(message) })}\n`); }; +export const renderSubmittedSearchChoices = (message: string, labels: string[]): void => { + const body = labels.length === 0 ? dim('None') : labels.join('\n'); + + promptEnvironment().output.write(`${renderBox({ body, title: dim(message) })}\n`); +}; + const renderSearchRows = (message: string, choices: Array>, highlighted: number | null, marked: Set, scroll: number | undefined, multiple: boolean): void => { const environment = promptEnvironment(); const window = choiceWindow(choices.length, highlighted ?? 0, scroll); diff --git a/packages/tui/src/prompts/select/read-multiple.ts b/packages/tui/src/prompts/select/read-multiple.ts index 41da0c24..1bea1625 100644 --- a/packages/tui/src/prompts/select/read-multiple.ts +++ b/packages/tui/src/prompts/select/read-multiple.ts @@ -6,9 +6,16 @@ import { firstEnabledIndex } from '#tui/concerns/choices'; import { moveSelectHighlight, selectNavigationAction } from '#tui/prompts/select/keys'; import { choicesFromCommaSeparated, markedChoiceIndexes, markedChoiceValues, toggleAllEnabledChoices, toggleMarkedChoice } from '#tui/prompts/select/multiple'; import { parseChoiceIndex } from '#tui/prompts/select/navigation'; -import { renderMultipleChoices } from '#tui/prompts/select/render'; +import { renderMultipleChoices, renderSubmittedChoices } from '#tui/prompts/select/render'; import type { Choice, MultiSelectPromptOptions } from '#tui/types'; +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 readMultipleChoices = async ( message: string, choices: Array>, @@ -37,6 +44,8 @@ export const readMultipleChoices = async ( const key = await environment.input.readKey(); if (key === null) { + renderSubmittedChoices(message, markedChoiceLabels(choices, marked)); + return markedChoiceValues(choices, marked); } @@ -88,6 +97,8 @@ export const readMultipleChoices = async ( } if (key === Key.enter) { + renderSubmittedChoices(message, markedChoiceLabels(choices, marked)); + return markedChoiceValues(choices, marked); } } diff --git a/packages/tui/src/prompts/select/render.ts b/packages/tui/src/prompts/select/render.ts index 23bd2d02..906a3f4c 100644 --- a/packages/tui/src/prompts/select/render.ts +++ b/packages/tui/src/prompts/select/render.ts @@ -19,6 +19,12 @@ 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 => { + const body = labels.length === 0 ? dim('None') : labels.join('\n'); + + promptEnvironment().output.write(`${renderBox({ body, title: dim(message) })}\n`); +}; + export const renderMultipleChoices = (message: string, choices: Array>, selected: number, marked: Set, scroll?: number, info?: MultiSelectPromptOptions['info']): void => { renderInteractiveChecklist(message, choices, selected, marked, scroll); diff --git a/packages/tui/tests/choices.test.ts b/packages/tui/tests/choices.test.ts index 7d81a537..b2822402 100644 --- a/packages/tui/tests/choices.test.ts +++ b/packages/tui/tests/choices.test.ts @@ -411,6 +411,9 @@ describe('choice prompts', () => { expect(result).toEqual(['first', 'second']); expect(output.text()).toContain('Selected: first'); expect(output.text()).toContain('Selected: first, second'); + expect(output.text()).toContain('┌ \u001B[2mPick many\u001B[22m '); + expect(output.text()).toContain('│ first'); + expect(output.text()).toContain('│ second'); }); it('renders multiselect options as a checklist', async () => { @@ -462,6 +465,7 @@ describe('choice prompts', () => { expect(result).toEqual(['second']); expect(output.text()).toContain('Selected: second'); + expect(output.text()).toContain('│ second'); }); it('returns an empty array for non-interactive multiselect prompts without defaults', async () => { diff --git a/packages/tui/tests/search.test.ts b/packages/tui/tests/search.test.ts index 3dad0c57..e98a3e79 100644 --- a/packages/tui/tests/search.test.ts +++ b/packages/tui/tests/search.test.ts @@ -432,6 +432,9 @@ describe('multisearch prompt', () => { expect(output.text()).toContain('\u001B[36m› ◼\u001B[39m Violet'); expect(output.text()).toContain('Selected: Violet'); expect(output.text()).toContain('Selected: Violet, Green'); + expect(output.text()).toContain('┌ \u001B[2mFavorite colors?\u001B[22m '); + expect(output.text()).toContain('│ Violet'); + expect(output.text()).toContain('│ Green'); }); it('starts multisearch prompts with default selected values', async () => { @@ -455,6 +458,7 @@ describe('multisearch prompt', () => { expect(result).toEqual(['green']); expect(output.text()).toContain('1 selected'); expect(output.text()).toContain('Selected: Green'); + expect(output.text()).toContain('│ Green'); }); it('returns selected defaults when multisearch input is exhausted', async () => { From afc6c49ecee4f041d06d4ebf253ca7db2f262654 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 22:16:47 +0800 Subject: [PATCH 188/675] Render cancelled choice frames --- UPSTREAM.md | 11 +++-- packages/tui/src/concerns/scrollbar.ts | 4 +- packages/tui/src/prompts/search/read-multi.ts | 6 +-- .../tui/src/prompts/search/read-single.ts | 6 ++- .../src/prompts/search/read-single/result.ts | 3 -- packages/tui/src/prompts/search/render.ts | 9 +++- .../tui/src/prompts/select/read-multiple.ts | 4 +- .../tui/src/prompts/select/read-selected.ts | 4 +- packages/tui/src/prompts/select/render.ts | 49 ++++++++++++++++++- packages/tui/src/theme/box.ts | 9 +++- packages/tui/src/theme/styles.ts | 4 ++ packages/tui/tests/choices.test.ts | 9 +++- packages/tui/tests/search.test.ts | 5 ++ 13 files changed, 100 insertions(+), 23 deletions(-) diff --git a/UPSTREAM.md b/UPSTREAM.md index e79ec5ce..19bb15e6 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -24,16 +24,20 @@ TypeScript and OpenTUI internals. | Number input | `packages/tui/src/prompts/number.ts`, `packages/tui/src/typed-value.ts` | | Confirm, select, multiselect | `packages/tui/src/prompts/select/*` | | Select submitted final frame | `packages/tui/src/prompts/select/render.ts`, `packages/tui/src/prompts/select/read-selected.ts`, `packages/tui/tests/choices.test.ts` | +| Select cancel final frame | `packages/tui/src/prompts/select/render.ts`, `packages/tui/src/prompts/select/read-selected.ts`, `packages/tui/tests/choices.test.ts` | | Multi-select checklist markers | `packages/tui/src/concerns/choices.ts`, `packages/tui/src/prompts/select/render.ts`, `packages/tui/tests/choices.test.ts` | | Multi-select active and selected row styling | `packages/tui/src/concerns/choices.ts`, `packages/tui/tests/choices.test.ts` | | Multi-select submitted final frame | `packages/tui/src/prompts/select/render.ts`, `packages/tui/src/prompts/select/read-multiple.ts`, `packages/tui/tests/choices.test.ts` | +| Multi-select cancel final frame | `packages/tui/src/prompts/select/render.ts`, `packages/tui/src/prompts/select/read-multiple.ts`, `packages/tui/tests/choices.test.ts` | | Suggest, autocomplete | `packages/tui/src/prompts/suggest/*` | | 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` | | Search, multisearch | `packages/tui/src/prompts/search/*` | | Search and multisearch empty-result rendering | `packages/tui/src/prompts/search/render.ts`, `packages/tui/tests/search.test.ts` | | Search highlighted-result row styling | `packages/tui/src/prompts/search/render.ts`, `packages/tui/src/theme/styles.ts`, `packages/tui/tests/search.test.ts` | | Search submitted final frame | `packages/tui/src/prompts/search/render.ts`, `packages/tui/src/prompts/search/read-single.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/tests/search.test.ts` | | Multi-search submitted final frame | `packages/tui/src/prompts/search/render.ts`, `packages/tui/src/prompts/search/read-multi.ts`, `packages/tui/tests/search.test.ts` | +| Multi-search cancel final frame | `packages/tui/src/prompts/search/render.ts`, `packages/tui/src/prompts/search/read-multi.ts`, `packages/tui/tests/search.test.ts` | | Pause | `packages/tui/src/prompts/pause.ts` | | Choice normalization, matching, navigation, and list rendering concerns | `packages/tui/src/concerns/choices.ts` | | Choice and search scrollbar rendering | `packages/tui/src/concerns/scrollbar.ts`, `packages/tui/src/concerns/choices.ts`, `packages/tui/src/prompts/search/render.ts` | @@ -57,6 +61,7 @@ TypeScript and OpenTUI internals. | 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` | | 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/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` | | ANSI, wrapping, truncation, Unicode width | `packages/tui/src/strings.ts` | | OpenTUI adapter | `packages/tui/src/opentui.ts` | | Type contracts | `packages/tui/src/types.ts`, `packages/tui/src/contracts/*` | @@ -67,10 +72,10 @@ The following reference areas still need full line-by-line behavioral mapping in later phases: - Form builder remaining method-surface and validation parity -- Multi-search boxed layout and cancel frame parity -- Multi-select boxed layout and cancel frame parity +- Multi-search active boxed layout parity +- Multi-select active boxed layout parity - Prompt lifecycle -- Search boxed layout and cancel frame parity +- Search active boxed layout parity - Textarea wrapping and cursor parity - Default theme - Feature parity tests diff --git a/packages/tui/src/concerns/scrollbar.ts b/packages/tui/src/concerns/scrollbar.ts index 7e8c496e..4afac9b1 100644 --- a/packages/tui/src/concerns/scrollbar.ts +++ b/packages/tui/src/concerns/scrollbar.ts @@ -19,7 +19,7 @@ export const scrollbarPosition = (firstVisible: number, height: number, total: n return Math.round((firstVisible / maxPosition) * (height - 3)) + 1; }; -export const renderScrollbarRows = (rows: string[], firstVisible: number, height: number, total: number): string[] => { +export const renderScrollbarRows = (rows: string[], firstVisible: number, height: number, total: number, style = cyan): string[] => { if (height >= total || rows.length === 0) { return rows; } @@ -28,7 +28,7 @@ export const renderScrollbarRows = (rows: string[], firstVisible: number, height const width = Math.max(...rows.map((row) => visibleWidth(row))); return rows.map((row, index) => { - const bar = index === position ? cyan('┃') : dim('│'); + const bar = index === position ? style('┃') : dim('│'); return `${padVisible(row, width)} ${bar}`; }); diff --git a/packages/tui/src/prompts/search/read-multi.ts b/packages/tui/src/prompts/search/read-multi.ts index c27f47d3..64bf059d 100644 --- a/packages/tui/src/prompts/search/read-multi.ts +++ b/packages/tui/src/prompts/search/read-multi.ts @@ -3,7 +3,7 @@ import { Key } from '#tui/key'; import { applyTypedKey } from '#tui/typed-value'; import { resolveSearchChoices } from '#tui/prompts/search/choices'; import { moveSearchHighlight, searchNavigationAction } from '#tui/prompts/search/keys'; -import { renderSearchChoices, renderSubmittedSearchChoices } from '#tui/prompts/search/render'; +import { renderCancelledSearch, renderSearchChoices, renderSubmittedSearchChoices } from '#tui/prompts/search/render'; import { lineMultiSearchValues, selectedSearchValues, toggleHighlightedSearchChoice } from '#tui/prompts/search/read-multi/result'; import { createInitialSearchSelection, displayedSearchChoices, markedSearchChoiceIndexes, toggleSearchChoices } from '#tui/prompts/search/selection'; import type { MultiSearchPromptOptions } from '#tui/types'; @@ -43,7 +43,7 @@ export const readMultiSearchChoices = async (options: MultiSearchPromptOption } if (key === Key.ctrlC) { - environment.error.write('Cancelled.\n'); + renderCancelledSearch(options.message, state.value, options.placeholder); return selectedSearchValues(selected); } @@ -81,7 +81,7 @@ export const readMultiSearchChoices = async (options: MultiSearchPromptOption const next = applyTypedKey(state, key); if (next.cancelled) { - environment.error.write('Cancelled.\n'); + renderCancelledSearch(options.message, state.value, options.placeholder); return options.default ?? []; } diff --git a/packages/tui/src/prompts/search/read-single.ts b/packages/tui/src/prompts/search/read-single.ts index cc8eba1f..44e17c4d 100644 --- a/packages/tui/src/prompts/search/read-single.ts +++ b/packages/tui/src/prompts/search/read-single.ts @@ -3,7 +3,7 @@ import { Key } from '#tui/key'; import { applyTypedKey } from '#tui/typed-value'; import { resolveSearchChoices } from '#tui/prompts/search/choices'; import { clearsSearchHighlight, moveSearchHighlight, searchNavigationAction } from '#tui/prompts/search/keys'; -import { renderSearchChoices, renderSubmittedSearchChoice } from '#tui/prompts/search/render'; +import { renderCancelledSearch, renderSearchChoices, renderSubmittedSearchChoice } from '#tui/prompts/search/render'; import { cancelledSearchValue, lineSearchValue, selectedSearchValue } from '#tui/prompts/search/read-single/result'; import type { SearchPromptOptions } from '#tui/types'; @@ -30,6 +30,8 @@ export const readSearchChoice = async (options: SearchPromptOptions, attem } if (key === Key.ctrlC) { + renderCancelledSearch(options.message, state.value, options.placeholder); + return cancelledSearchValue(choices, highlighted, options.default); } @@ -71,7 +73,7 @@ export const readSearchChoice = async (options: SearchPromptOptions, attem const next = applyTypedKey(state, key); if (next.cancelled) { - environment.error.write('Cancelled.\n'); + renderCancelledSearch(options.message, state.value, options.placeholder); return options.default; } diff --git a/packages/tui/src/prompts/search/read-single/result.ts b/packages/tui/src/prompts/search/read-single/result.ts index 17b586cf..e89d4407 100644 --- a/packages/tui/src/prompts/search/read-single/result.ts +++ b/packages/tui/src/prompts/search/read-single/result.ts @@ -1,4 +1,3 @@ -import { promptEnvironment } from '#tui/environment'; import { ask } from '#tui/prompt'; import { resolveLineSearchChoice } from '#tui/prompts/search/line-mode'; import type { Choice, SearchPromptOptions } from '#tui/types'; @@ -20,7 +19,5 @@ export const selectedSearchValue = (choices: Array>, highlighted: n }; export const cancelledSearchValue = (choices: Array>, highlighted: number | null, fallback?: T): T | undefined => { - promptEnvironment().error.write('Cancelled.\n'); - return selectedSearchValue(choices, highlighted) ?? fallback; }; diff --git a/packages/tui/src/prompts/search/render.ts b/packages/tui/src/prompts/search/render.ts index ff08ef1b..ea323ccc 100644 --- a/packages/tui/src/prompts/search/render.ts +++ b/packages/tui/src/prompts/search/render.ts @@ -4,7 +4,7 @@ import { resolveInfo } from '#tui/concerns/info'; import { renderScrollbarRows } from '#tui/concerns/scrollbar'; import { searchMessage } from '#tui/prompts/search/choices'; import { renderBox } from '#tui/theme/box'; -import { cyan, dim } from '#tui/theme/styles'; +import { cyan, dim, red, strikethrough } from '#tui/theme/styles'; import type { Choice, MultiSearchPromptOptions, SearchPromptOptions } from '#tui/types'; export const renderSearchChoices = ( @@ -47,6 +47,13 @@ export const renderSubmittedSearchChoices = (message: string, labels: string[]): promptEnvironment().output.write(`${renderBox({ body, 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 renderSearchRows = (message: string, choices: Array>, highlighted: number | null, marked: Set, scroll: number | undefined, multiple: boolean): void => { const environment = promptEnvironment(); const window = choiceWindow(choices.length, highlighted ?? 0, scroll); diff --git a/packages/tui/src/prompts/select/read-multiple.ts b/packages/tui/src/prompts/select/read-multiple.ts index 1bea1625..032eb3c4 100644 --- a/packages/tui/src/prompts/select/read-multiple.ts +++ b/packages/tui/src/prompts/select/read-multiple.ts @@ -6,7 +6,7 @@ import { firstEnabledIndex } from '#tui/concerns/choices'; import { moveSelectHighlight, selectNavigationAction } from '#tui/prompts/select/keys'; import { choicesFromCommaSeparated, markedChoiceIndexes, markedChoiceValues, toggleAllEnabledChoices, toggleMarkedChoice } from '#tui/prompts/select/multiple'; import { parseChoiceIndex } from '#tui/prompts/select/navigation'; -import { renderMultipleChoices, renderSubmittedChoices } from '#tui/prompts/select/render'; +import { renderCancelledChoices, renderMultipleChoices, renderSubmittedChoices } from '#tui/prompts/select/render'; import type { Choice, MultiSelectPromptOptions } from '#tui/types'; const markedChoiceLabels = (choices: Array>, marked: Set): string[] => { @@ -50,7 +50,7 @@ export const readMultipleChoices = async ( } if (key === Key.ctrlC) { - environment.error.write('Cancelled.\n'); + renderCancelledChoices(message, choices, selected, marked, scroll); return markedChoiceValues(choices, marked); } diff --git a/packages/tui/src/prompts/select/read-selected.ts b/packages/tui/src/prompts/select/read-selected.ts index 39efba07..27be642d 100644 --- a/packages/tui/src/prompts/select/read-selected.ts +++ b/packages/tui/src/prompts/select/read-selected.ts @@ -5,7 +5,7 @@ import { renderChoices } from '#tui/theme'; import { findChoice, firstEnabledIndex } from '#tui/concerns/choices'; import { moveSelectHighlight, selectNavigationAction } from '#tui/prompts/select/keys'; import { parseChoiceIndex } from '#tui/prompts/select/navigation'; -import { renderSelectedChoice, renderSubmittedChoice } from '#tui/prompts/select/render'; +import { renderCancelledChoice, renderSelectedChoice, renderSubmittedChoice } from '#tui/prompts/select/render'; import type { Choice, SelectPromptOptions } from '#tui/types'; const defaultChoiceIndex = (choices: Array>, defaultValue: T | undefined): number => { @@ -47,7 +47,7 @@ export const readSelectedChoice = async (message: string, choices: Array(message: string, choices: Array>, selected: number, scroll: number | undefined, info: SelectPromptOptions['info']): void => { @@ -25,6 +26,16 @@ export const renderSubmittedChoices = (message: string, labels: string[]): void promptEnvironment().output.write(`${renderBox({ body, 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']): void => { renderInteractiveChecklist(message, choices, selected, marked, scroll); @@ -45,3 +56,37 @@ export const renderMultipleChoices = (message: string, choices: Array(choice: Choice): string => { + const disabled = choice.disabled ? ` (${typeof choice.disabled === 'string' ? choice.disabled : 'disabled'})` : ''; + const hint = choice.hint ? ` ${choice.hint}` : ''; + + return strikethrough(`${choice.label}${hint}${disabled}`); +}; + +const renderCancelledChoiceRows = (choices: Array>, selected: number, scroll?: number): string => { + const window = choiceWindow(choices.length, selected, scroll); + + const rows = choices.slice(window.start, window.end).map((choice, offset) => { + const index = window.start + offset; + const marker = index === selected ? '› ●' : ' ○'; + + return dim(`${marker} ${cancelledChoiceLabel(choice)} `); + }); + + return renderScrollbarRows(rows, window.start, window.end - window.start, choices.length, dim).join('\n'); +}; + +const renderCancelledChecklistRows = (choices: Array>, selected: number, marked: Set, scroll?: number): string => { + const window = choiceWindow(choices.length, selected, scroll); + + const rows = choices.slice(window.start, window.end).map((choice, offset) => { + const index = window.start + offset; + const pointer = index === selected ? '›' : ' '; + const marker = marked.has(index) ? '◼' : '◻'; + + return dim(`${pointer} ${marker} ${cancelledChoiceLabel(choice)} `); + }); + + return renderScrollbarRows(rows, window.start, window.end - window.start, choices.length, dim).join('\n'); +}; diff --git a/packages/tui/src/theme/box.ts b/packages/tui/src/theme/box.ts index 60cf5da2..dac1815b 100644 --- a/packages/tui/src/theme/box.ts +++ b/packages/tui/src/theme/box.ts @@ -4,6 +4,7 @@ const DEFAULT_BOX_WIDTH = 60; type BoxOptions = { body: string; + borderStyle?: (value: string) => string; info?: string; title?: string; width?: number; @@ -11,7 +12,7 @@ type BoxOptions = { const padVisible = (value: string, width: number): string => `${value}${' '.repeat(Math.max(0, width - visibleWidth(value)))}`; -export const renderBox = ({ body, info = '', title = '', width = DEFAULT_BOX_WIDTH }: BoxOptions): string => { +export const renderBox = ({ body, borderStyle = (value) => value, info = '', title = '', width = DEFAULT_BOX_WIDTH }: BoxOptions): string => { const bodyLines = body.split('\n'); const contentWidth = Math.max(width, visibleWidth(title), ...bodyLines.map(visibleWidth)); const titleWidth = visibleWidth(title); @@ -20,5 +21,9 @@ export const renderBox = ({ body, info = '', title = '', width = DEFAULT_BOX_WID const renderedInfo = info ? truncate(info, contentWidth - 1) : ''; const bottomBorder = '─'.repeat(renderedInfo ? contentWidth - visibleWidth(renderedInfo) : contentWidth + 2); - return [` ┌${titleLabel}${topBorder}┐`, ...bodyLines.map((line) => ` │ ${padVisible(line, contentWidth)} │`), ` └${bottomBorder}${renderedInfo ? ` ${renderedInfo} ` : ''}┘`].join('\n'); + return [ + `${borderStyle(' ┌')}${titleLabel}${borderStyle(`${topBorder}┐`)}`, + ...bodyLines.map((line) => `${borderStyle(' │')} ${padVisible(line, contentWidth)} ${borderStyle('│')}`), + borderStyle(` └${bottomBorder}${renderedInfo ? ` ${renderedInfo} ` : ''}┘`), + ].join('\n'); }; diff --git a/packages/tui/src/theme/styles.ts b/packages/tui/src/theme/styles.ts index 7291ff9f..3998388c 100644 --- a/packages/tui/src/theme/styles.ts +++ b/packages/tui/src/theme/styles.ts @@ -12,6 +12,10 @@ export const cyan = (value: string): string => ansi(36, 39, value); export const inverse = (value: string): string => ansi(7, 27, value); +export const red = (value: string): string => ansi(31, 39, value); + +export const strikethrough = (value: string): string => ansi(9, 29, value); + export const foregroundRgb = (value: string, red: number, green: number, blue: number): string => { if (value.length === 0) { return ''; diff --git a/packages/tui/tests/choices.test.ts b/packages/tui/tests/choices.test.ts index b2822402..37095b2f 100644 --- a/packages/tui/tests/choices.test.ts +++ b/packages/tui/tests/choices.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { confirm, createMemoryOutput, createScriptedInput, Key, multiselect, PromptValidationError, select, withPromptEnvironment } from '#tui/index'; +import { confirm, createMemoryOutput, createScriptedInput, Key, multiselect, parseAnsiText, PromptValidationError, select, withPromptEnvironment } from '#tui/index'; describe('choice prompts', () => { it('confirms with direct y and n keys', async () => { @@ -393,6 +393,9 @@ describe('choice prompts', () => { expect(result).toBe('second'); expect(output.text()).toContain('Cancelled.'); + expect(output.text()).toContain('\u001B[31m ┌\u001B[39m Pick one '); + expect(output.text()).toContain('\u001B[2m› ● \u001B[9msecond\u001B[29m'); + expect(parseAnsiText(output.text())).toContain('│ › ● second'); }); it('toggles multiselect choices with the space bar', async () => { @@ -717,5 +720,9 @@ describe('choice prompts', () => { expect(result).toEqual(['first']); expect(output.text()).toContain('Cancelled.'); + expect(output.text()).toContain('\u001B[31m ┌\u001B[39m Pick many '); + expect(output.text()).toContain('\u001B[2m ◼ \u001B[9mfirst\u001B[29m'); + expect(output.text()).toContain('\u001B[2m› ◻ \u001B[9msecond\u001B[29m'); + expect(parseAnsiText(output.text())).toContain('│ › ◻ second'); }); }); diff --git a/packages/tui/tests/search.test.ts b/packages/tui/tests/search.test.ts index e98a3e79..52f9d074 100644 --- a/packages/tui/tests/search.test.ts +++ b/packages/tui/tests/search.test.ts @@ -272,6 +272,8 @@ describe('search prompt', () => { expect(result).toBe('red'); expect(output.text()).toContain('Cancelled.'); + expect(output.text()).toContain('\u001B[31m ┌\u001B[39m \u001B[2mFavorite color?\u001B[22m '); + expect(parseAnsiText(output.text())).toContain('⚠ Cancelled.'); }); it('uses the default when cancelling a disabled highlighted search result', async () => { @@ -630,6 +632,9 @@ describe('multisearch prompt', () => { expect(result).toEqual(['red']); expect(output.text()).toContain('Cancelled.'); + expect(output.text()).toContain('\u001B[31m ┌\u001B[39m \u001B[2mFavorite colors?\u001B[22m '); + expect(output.text()).toContain('\u001B[9m\u001B[2mB\u001B[22m\u001B[29m'); + expect(parseAnsiText(output.text())).toContain('│ B'); }); it('toggles all current multisearch results with ctrl-a', async () => { From 9db0c6e8509b86491b26297884b4bd880c560d92 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 22:20:13 +0800 Subject: [PATCH 189/675] Render active choice frames --- UPSTREAM.md | 5 +- packages/tui/src/prompts/select/render.ts | 77 ++++++++++++++++------- packages/tui/tests/choices.test.ts | 39 ++++++------ 3 files changed, 76 insertions(+), 45 deletions(-) diff --git a/UPSTREAM.md b/UPSTREAM.md index 19bb15e6..1eaa6dcb 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -23,10 +23,10 @@ TypeScript and OpenTUI internals. | Password input | `packages/tui/src/prompts/password.ts`, `packages/tui/src/typed-value.ts` | | Number input | `packages/tui/src/prompts/number.ts`, `packages/tui/src/typed-value.ts` | | Confirm, select, multiselect | `packages/tui/src/prompts/select/*` | +| Select active boxed layout and row styling | `packages/tui/src/prompts/select/render.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/tests/choices.test.ts` | | Select cancel final frame | `packages/tui/src/prompts/select/render.ts`, `packages/tui/src/prompts/select/read-selected.ts`, `packages/tui/tests/choices.test.ts` | -| Multi-select checklist markers | `packages/tui/src/concerns/choices.ts`, `packages/tui/src/prompts/select/render.ts`, `packages/tui/tests/choices.test.ts` | -| Multi-select active and selected row styling | `packages/tui/src/concerns/choices.ts`, `packages/tui/tests/choices.test.ts` | +| Multi-select active boxed layout and row styling | `packages/tui/src/prompts/select/render.ts`, `packages/tui/tests/choices.test.ts` | | Multi-select submitted final frame | `packages/tui/src/prompts/select/render.ts`, `packages/tui/src/prompts/select/read-multiple.ts`, `packages/tui/tests/choices.test.ts` | | Multi-select cancel final frame | `packages/tui/src/prompts/select/render.ts`, `packages/tui/src/prompts/select/read-multiple.ts`, `packages/tui/tests/choices.test.ts` | | Suggest, autocomplete | `packages/tui/src/prompts/suggest/*` | @@ -73,7 +73,6 @@ later phases: - Form builder remaining method-surface and validation parity - Multi-search active boxed layout parity -- Multi-select active boxed layout parity - Prompt lifecycle - Search active boxed layout parity - Textarea wrapping and cursor parity diff --git a/packages/tui/src/prompts/select/render.ts b/packages/tui/src/prompts/select/render.ts index 0a9a9faf..9271166d 100644 --- a/packages/tui/src/prompts/select/render.ts +++ b/packages/tui/src/prompts/select/render.ts @@ -1,19 +1,15 @@ import { promptEnvironment } from '#tui/environment'; -import { choiceWindow, renderInteractiveChecklist, renderInteractiveChoices } from '#tui/concerns/choices'; +import { choiceWindow } from '#tui/concerns/choices'; import { resolveInfo } from '#tui/concerns/info'; import { renderScrollbarRows } from '#tui/concerns/scrollbar'; import { renderBox } from '#tui/theme/box'; -import { dim, red, strikethrough } from '#tui/theme/styles'; +import { cyan, dim, red, strikethrough } 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']): void => { - renderInteractiveChoices(message, choices, selected, new Set(), scroll); - const text = resolveInfo(info, choices[selected]?.value ?? null); - if (text.length > 0) { - promptEnvironment().output.write(`${text}\n`); - } + promptEnvironment().output.write(`${renderBox({ body: renderActiveChoiceRows(choices, selected, scroll), borderStyle: cyan, info: text, title: cyan(message) })}\n`); }; export const renderSubmittedChoice = (message: string, label: string): void => { @@ -37,31 +33,66 @@ export const renderCancelledChoices = (message: string, choices: Array(message: string, choices: Array>, selected: number, marked: Set, scroll?: number, info?: MultiSelectPromptOptions['info']): void => { - renderInteractiveChecklist(message, choices, selected, marked, scroll); - const text = resolveInfo(info, choices[selected]?.value ?? null); const summary = scroll !== undefined && choices.length > scroll ? `${marked.size} selected` : ''; const details = [text, summary].filter((part) => part.length > 0).join(' · '); - if (details.length > 0) { - promptEnvironment().output.write(`${details}\n`); - } - - const labels = [...marked] - .sort((left, right) => left - right) - .map((index) => choices[index]?.label) - .filter((label): label is string => label !== undefined); - - if (labels.length > 0) { - promptEnvironment().output.write(`Selected: ${labels.join(', ')}\n`); - } + promptEnvironment().output.write(`${renderBox({ body: renderActiveChecklistRows(choices, selected, marked, scroll), borderStyle: cyan, info: details, title: cyan(message) })}\n`); }; -const cancelledChoiceLabel = (choice: Choice): string => { +const choiceLabel = (choice: Choice): string => { const disabled = choice.disabled ? ` (${typeof choice.disabled === 'string' ? choice.disabled : 'disabled'})` : ''; const hint = choice.hint ? ` ${choice.hint}` : ''; - return strikethrough(`${choice.label}${hint}${disabled}`); + return `${choice.label}${hint}${disabled}`; +}; + +const renderActiveChoiceRows = (choices: Array>, selected: number, scroll?: number): string => { + const window = choiceWindow(choices.length, selected, scroll); + + const rows = choices.slice(window.start, window.end).map((choice, offset) => { + const index = window.start + offset; + const label = choiceLabel(choice); + + if (index === selected) { + return `${cyan('›')} ${cyan('●')} ${label} `; + } + + return ` ${dim('○')} ${dim(label)} `; + }); + + return renderScrollbarRows(rows, window.start, window.end - window.start, choices.length).join('\n'); +}; + +const renderActiveChecklistRows = (choices: Array>, selected: number, marked: Set, scroll?: number): string => { + const window = choiceWindow(choices.length, selected, scroll); + + const rows = choices.slice(window.start, window.end).map((choice, offset) => { + const index = window.start + offset; + const active = index === selected; + const checked = marked.has(index); + const label = choiceLabel(choice); + + if (active && checked) { + return `${cyan('› ◼')} ${label} `; + } + + if (active) { + return `${cyan('›')} ◻ ${label} `; + } + + if (checked) { + return ` ${cyan('◼')} ${dim(label)} `; + } + + return ` ${dim('◻')} ${dim(label)} `; + }); + + return renderScrollbarRows(rows, window.start, window.end - window.start, choices.length).join('\n'); +}; + +const cancelledChoiceLabel = (choice: Choice): string => { + return strikethrough(choiceLabel(choice)); }; const renderCancelledChoiceRows = (choices: Array>, selected: number, scroll?: number): string => { diff --git a/packages/tui/tests/choices.test.ts b/packages/tui/tests/choices.test.ts index 37095b2f..9ca402aa 100644 --- a/packages/tui/tests/choices.test.ts +++ b/packages/tui/tests/choices.test.ts @@ -172,7 +172,8 @@ describe('choice prompts', () => { ); expect(result).toBe('third'); - expect(output.text()).toContain('› third'); + expect(output.text()).toContain('\u001B[36m ┌\u001B[39m \u001B[36mPick one\u001B[39m '); + expect(output.text()).toContain('\u001B[36m›\u001B[39m \u001B[36m●\u001B[39m third'); expect(output.text()).toContain('│ third'); }); @@ -189,15 +190,14 @@ describe('choice prompts', () => { () => select({ message: 'Pick one', options: ['first', 'second', 'third', 'fourth'], scroll: 3 }), ); - const latestFrame = output.text().split('Pick one\n').at(-1) ?? ''; + const rendered = parseAnsiText(output.text()); expect(result).toBe('third'); - expect(latestFrame).not.toContain('first'); - expect(latestFrame).toContain('second'); - expect(latestFrame).toContain('third'); - expect(latestFrame).toContain('fourth'); - expect(latestFrame).toContain('┃'); - expect(latestFrame).toContain('│'); + expect(rendered).toContain('│ ○ second'); + expect(rendered).toContain('│ › ● third'); + expect(rendered).toContain('│ ○ fourth'); + expect(rendered).toContain('┃'); + expect(rendered).toContain('│'); }); it('supports alternate select navigation keys', async () => { @@ -288,13 +288,11 @@ describe('choice prompts', () => { () => select({ message: 'Pick one', options: ['first', 'second', 'third', 'fourth'], scroll: 2.9 }), ); - const latestFrame = output.text().split('Pick one\n').at(-1) ?? ''; + const rendered = parseAnsiText(output.text()); expect(result).toBe('third'); - expect(latestFrame).not.toContain('first'); - expect(latestFrame).not.toContain('second'); - expect(latestFrame).toContain('third'); - expect(latestFrame).toContain('fourth'); + expect(rendered).toContain('│ › ● third'); + expect(rendered).toContain('│ ○ fourth'); }); it('renders select info for the highlighted option', async () => { @@ -412,8 +410,8 @@ describe('choice prompts', () => { ); expect(result).toEqual(['first', 'second']); - expect(output.text()).toContain('Selected: first'); - expect(output.text()).toContain('Selected: first, second'); + expect(output.text()).toContain('\u001B[36m ┌\u001B[39m \u001B[36mPick many\u001B[39m '); + expect(output.text()).toContain('\u001B[36m› ◼\u001B[39m first'); expect(output.text()).toContain('┌ \u001B[2mPick many\u001B[22m '); expect(output.text()).toContain('│ first'); expect(output.text()).toContain('│ second'); @@ -467,7 +465,7 @@ describe('choice prompts', () => { ); expect(result).toEqual(['second']); - expect(output.text()).toContain('Selected: second'); + expect(output.text()).toContain('\u001B[36m ┌\u001B[39m \u001B[36mPick many\u001B[39m '); expect(output.text()).toContain('│ second'); }); @@ -554,7 +552,9 @@ describe('choice prompts', () => { ); expect(result).toEqual(['first', 'second', 'third']); - expect(output.text()).toContain('Selected: first, second, third'); + expect(output.text()).toContain('│ first'); + expect(output.text()).toContain('│ second'); + expect(output.text()).toContain('│ third'); }); it('toggles only enabled multiselect choices with ctrl-a', async () => { @@ -579,8 +579,9 @@ describe('choice prompts', () => { ); expect(result).toEqual(['first', 'third']); - expect(output.text()).toContain('Selected: first, third'); - expect(output.text()).not.toContain('Selected: first, second, third'); + expect(output.text()).toContain('│ first'); + expect(output.text()).toContain('│ third'); + expect(output.text()).not.toContain('│ second'); }); it('renders multiselect info for the highlighted option', async () => { From ed4d3c0c5d00b8ad7ad53e7dfa0129d6f6af14b7 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 22:23:40 +0800 Subject: [PATCH 190/675] Render active search frames --- UPSTREAM.md | 4 +- packages/tui/src/prompts/search/read-multi.ts | 2 +- .../tui/src/prompts/search/read-single.ts | 10 ++-- packages/tui/src/prompts/search/render.ts | 38 ++++++------- packages/tui/tests/search.test.ts | 53 ++++++++++--------- 5 files changed, 54 insertions(+), 53 deletions(-) diff --git a/UPSTREAM.md b/UPSTREAM.md index 1eaa6dcb..c13a1794 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -32,10 +32,12 @@ TypeScript and OpenTUI internals. | Suggest, autocomplete | `packages/tui/src/prompts/suggest/*` | | 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` | | Search, multisearch | `packages/tui/src/prompts/search/*` | +| Search active boxed layout and row styling | `packages/tui/src/prompts/search/render.ts`, `packages/tui/tests/search.test.ts` | | Search and multisearch empty-result rendering | `packages/tui/src/prompts/search/render.ts`, `packages/tui/tests/search.test.ts` | | Search highlighted-result row styling | `packages/tui/src/prompts/search/render.ts`, `packages/tui/src/theme/styles.ts`, `packages/tui/tests/search.test.ts` | | Search submitted final frame | `packages/tui/src/prompts/search/render.ts`, `packages/tui/src/prompts/search/read-single.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/tests/search.test.ts` | +| Multi-search active boxed layout and selected-count footer | `packages/tui/src/prompts/search/render.ts`, `packages/tui/tests/search.test.ts` | | Multi-search submitted final frame | `packages/tui/src/prompts/search/render.ts`, `packages/tui/src/prompts/search/read-multi.ts`, `packages/tui/tests/search.test.ts` | | Multi-search cancel final frame | `packages/tui/src/prompts/search/render.ts`, `packages/tui/src/prompts/search/read-multi.ts`, `packages/tui/tests/search.test.ts` | | Pause | `packages/tui/src/prompts/pause.ts` | @@ -72,9 +74,7 @@ The following reference areas still need full line-by-line behavioral mapping in later phases: - Form builder remaining method-surface and validation parity -- Multi-search active boxed layout parity - Prompt lifecycle -- Search active boxed layout parity - Textarea wrapping and cursor parity - Default theme - Feature parity tests diff --git a/packages/tui/src/prompts/search/read-multi.ts b/packages/tui/src/prompts/search/read-multi.ts index 64bf059d..02a963b3 100644 --- a/packages/tui/src/prompts/search/read-multi.ts +++ b/packages/tui/src/prompts/search/read-multi.ts @@ -28,7 +28,7 @@ export const readMultiSearchChoices = async (options: MultiSearchPromptOption const currentChoices = displayedChoices(); const marked = markedSearchChoiceIndexes(currentChoices, selected); - renderSearchChoices(options.message, state.value, currentChoices, highlighted, marked, [...selected.values()], options.scroll, options.info, true); + renderSearchChoices(options.message, state.value, currentChoices, highlighted, marked, [...selected.values()], options.scroll, options.info, true, options.placeholder); }; render(); diff --git a/packages/tui/src/prompts/search/read-single.ts b/packages/tui/src/prompts/search/read-single.ts index 44e17c4d..866065e8 100644 --- a/packages/tui/src/prompts/search/read-single.ts +++ b/packages/tui/src/prompts/search/read-single.ts @@ -20,7 +20,7 @@ export const readSearchChoice = async (options: SearchPromptOptions, attem let highlighted: number | null = null; - renderSearchChoices(options.message, state.value, choices, highlighted, new Set(), [], options.scroll, options.info); + renderSearchChoices(options.message, state.value, choices, highlighted, new Set(), [], options.scroll, options.info, false, options.placeholder); while (true) { const key = await environment.input.readKey(); @@ -41,13 +41,13 @@ export const readSearchChoice = async (options: SearchPromptOptions, attem choices = await resolveSearchChoices(options.options, state.value); highlighted = moveSearchHighlight(choices, highlighted, action, { attempt, retryFirst: true, scroll: options.scroll }); - renderSearchChoices(options.message, state.value, choices, highlighted, new Set(), [], options.scroll, options.info); + renderSearchChoices(options.message, state.value, choices, highlighted, new Set(), [], options.scroll, options.info, false, options.placeholder); continue; } if (clearsSearchHighlight(key) && highlighted !== null) { highlighted = null; - renderSearchChoices(options.message, state.value, choices, highlighted, new Set(), [], options.scroll, options.info); + renderSearchChoices(options.message, state.value, choices, highlighted, new Set(), [], options.scroll, options.info, false, options.placeholder); continue; } @@ -66,7 +66,7 @@ export const readSearchChoice = async (options: SearchPromptOptions, attem choices = await resolveSearchChoices(options.options, state.value); highlighted = null; - renderSearchChoices(options.message, state.value, choices, highlighted, new Set(), [], options.scroll, options.info); + renderSearchChoices(options.message, state.value, choices, highlighted, new Set(), [], options.scroll, options.info, false, options.placeholder); continue; } @@ -83,6 +83,6 @@ export const readSearchChoice = async (options: SearchPromptOptions, attem choices = await resolveSearchChoices(options.options, state.value); - renderSearchChoices(options.message, state.value, choices, highlighted, new Set(), [], options.scroll, options.info); + renderSearchChoices(options.message, state.value, choices, highlighted, new Set(), [], options.scroll, options.info, false, options.placeholder); } }; diff --git a/packages/tui/src/prompts/search/render.ts b/packages/tui/src/prompts/search/render.ts index ea323ccc..0532d090 100644 --- a/packages/tui/src/prompts/search/render.ts +++ b/packages/tui/src/prompts/search/render.ts @@ -2,7 +2,6 @@ import { promptEnvironment } from '#tui/environment'; import { choiceWindow } from '#tui/concerns/choices'; import { resolveInfo } from '#tui/concerns/info'; import { renderScrollbarRows } from '#tui/concerns/scrollbar'; -import { searchMessage } from '#tui/prompts/search/choices'; import { renderBox } from '#tui/theme/box'; import { cyan, dim, red, strikethrough } from '#tui/theme/styles'; import type { Choice, MultiSearchPromptOptions, SearchPromptOptions } from '#tui/types'; @@ -17,24 +16,15 @@ export const renderSearchChoices = ( scroll?: number, info?: SearchPromptOptions['info'] | MultiSearchPromptOptions['info'], showSelectedSummary = false, + placeholder = '', ): void => { - renderSearchRows(searchMessage(message, query), choices, highlighted, marked, scroll, showSelectedSummary); - - if (query.length > 0 && choices.length === 0) { - promptEnvironment().output.write(`${dim(' No results.')}\n`); - } - const text = resolveInfo(info, highlighted === null ? null : (choices[highlighted]?.value ?? null)); const summary = showSelectedSummary ? selectedSummary(selectedLabels.length, selectedLabels.length - marked.size) : ''; const details = [text, summary].filter((part) => part.length > 0).join(' · '); - if (details.length > 0) { - promptEnvironment().output.write(`${details}\n`); - } - - if (selectedLabels.length > 0) { - promptEnvironment().output.write(`Selected: ${selectedLabels.join(', ')}\n`); - } + promptEnvironment().output.write( + `${renderBox({ body: renderSearchBody(query, placeholder, choices, highlighted, marked, scroll, showSelectedSummary), borderStyle: cyan, info: details, title: cyan(message) })}\n`, + ); }; export const renderSubmittedSearchChoice = (message: string, label: string): void => { @@ -54,11 +44,19 @@ export const renderCancelledSearch = (message: string, query: string, placeholde promptEnvironment().error.write(`${red(' ⚠ Cancelled.')}\n`); }; -const renderSearchRows = (message: string, choices: Array>, highlighted: number | null, marked: Set, scroll: number | undefined, multiple: boolean): void => { - const environment = promptEnvironment(); - const window = choiceWindow(choices.length, highlighted ?? 0, scroll); +const renderSearchBody = (query: string, placeholder: string, choices: Array>, highlighted: number | null, marked: Set, scroll: number | undefined, multiple: boolean): string => { + const value = query.length > 0 ? query : dim(placeholder); + const rows = renderSearchRows(choices, highlighted, marked, scroll, multiple); - environment.output.write(`${message}\n`); + if (query.length > 0 && choices.length === 0) { + return [value, dim(' No results.')].join('\n'); + } + + return rows.length > 0 ? [value, rows].join('\n') : value; +}; + +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; @@ -69,9 +67,7 @@ const renderSearchRows = (message: string, choices: Array>, highlig return multiple ? multiSearchRow(label, active, selected) : searchRow(label, active); }); - for (const row of renderScrollbarRows(rows, window.start, window.end - window.start, choices.length)) { - environment.output.write(`${row}\n`); - } + return renderScrollbarRows(rows, window.start, window.end - window.start, choices.length).join('\n'); }; const choiceLabel = (choice: Choice): string => { diff --git a/packages/tui/tests/search.test.ts b/packages/tui/tests/search.test.ts index 52f9d074..4cb62e8f 100644 --- a/packages/tui/tests/search.test.ts +++ b/packages/tui/tests/search.test.ts @@ -27,7 +27,8 @@ describe('search prompt', () => { ); expect(result).toBe('blue'); - expect(output.text()).toContain('Favorite color? ue'); + expect(output.text()).toContain('\u001B[36m ┌\u001B[39m \u001B[36mFavorite color?\u001B[39m '); + expect(parseAnsiText(output.text())).toContain('│ ue'); expect(output.text()).toContain('┌ \u001B[2mFavorite color?\u001B[22m '); expect(output.text()).toContain('│ Blue'); }); @@ -67,7 +68,7 @@ describe('search prompt', () => { () => search({ message: 'Favorite color?', options: colors }), ); - expect(output.text()).toContain('Favorite color? r'); + expect(parseAnsiText(output.text())).toContain('│ r'); expect(output.text()).toContain('\u001B[36m›\u001B[39m Red'); expect(output.text()).toContain('\u001B[2mGreen\u001B[22m'); }); @@ -85,10 +86,8 @@ describe('search prompt', () => { () => search({ message: 'Favorite color?', options: colors, default: 'red' }), ); - const firstFrame = output.text().split('Favorite color?\n')[1] ?? ''; - - expect(firstFrame).not.toContain('›'); - expect(firstFrame).toContain('\u001B[2mRed\u001B[22m'); + expect(output.text()).not.toContain('›'); + expect(output.text()).toContain('\u001B[2mRed\u001B[22m'); }); it('respects scroll windows when rendering results', async () => { @@ -109,15 +108,12 @@ describe('search prompt', () => { }), ); - const latestFrame = output.text().split('Pick number\n').at(-1) ?? ''; - expect(result).toBe('three'); - expect(latestFrame).not.toContain('one'); - expect(latestFrame).toContain('two'); - expect(latestFrame).toContain('three'); - expect(latestFrame).toContain('four'); - expect(latestFrame).toContain('┃'); - expect(latestFrame).toContain('│'); + expect(parseAnsiText(output.text())).toContain('│ two'); + expect(parseAnsiText(output.text())).toContain('│ › three'); + expect(parseAnsiText(output.text())).toContain('│ four'); + expect(output.text()).toContain('┃'); + expect(output.text()).toContain('│'); }); it('supports page search navigation keys', async () => { @@ -432,8 +428,8 @@ describe('multisearch prompt', () => { expect(result).toEqual(['violet', 'green']); expect(output.text()).toContain('\u001B[36m› ◼\u001B[39m Violet'); - expect(output.text()).toContain('Selected: Violet'); - expect(output.text()).toContain('Selected: Violet, Green'); + expect(output.text()).toContain('1 selected'); + expect(output.text()).toContain('2 selected'); expect(output.text()).toContain('┌ \u001B[2mFavorite colors?\u001B[22m '); expect(output.text()).toContain('│ Violet'); expect(output.text()).toContain('│ Green'); @@ -459,7 +455,6 @@ describe('multisearch prompt', () => { expect(result).toEqual(['green']); expect(output.text()).toContain('1 selected'); - expect(output.text()).toContain('Selected: Green'); expect(output.text()).toContain('│ Green'); }); @@ -482,7 +477,7 @@ describe('multisearch prompt', () => { ); expect(result).toEqual(['green']); - expect(output.text()).toContain('Selected: Green'); + expect(output.text()).toContain('│ Green'); }); it('returns an empty array for non-interactive multisearch prompts without defaults', async () => { @@ -614,7 +609,7 @@ describe('multisearch prompt', () => { expect(result).toEqual(['green']); expect(output.text()).toContain('1 selected (1 hidden)'); - expect(output.text()).toContain('Selected: Green'); + expect(output.text()).toContain('│ Green'); }); it('cancels multisearch prompts with the current selected values', async () => { @@ -651,7 +646,10 @@ describe('multisearch prompt', () => { ); expect(result).toEqual(['red', 'green', 'blue']); - expect(output.text()).toContain('Selected: Red, Green, Blue'); + expect(output.text()).toContain('3 selected'); + expect(output.text()).toContain('│ Red'); + expect(output.text()).toContain('│ Green'); + expect(output.text()).toContain('│ Blue'); }); it('does not navigate multisearch results with ctrl-n or ctrl-p', async () => { @@ -731,7 +729,9 @@ describe('multisearch prompt', () => { ); expect(result).toEqual(['blue', 'red']); - expect(output.text()).toContain('Selected: Blue, Red'); + expect(output.text()).toContain('2 selected'); + expect(output.text()).toContain('│ Blue'); + expect(output.text()).toContain('│ Red'); }); it('ignores end-of-line control navigation while multisearch results are highlighted', async () => { @@ -748,7 +748,8 @@ describe('multisearch prompt', () => { ); expect(result).toEqual(['red']); - expect(output.text()).toContain('Selected: Red'); + expect(output.text()).toContain('1 selected'); + expect(output.text()).toContain('│ Red'); }); it('supports page multisearch navigation keys', async () => { @@ -765,7 +766,9 @@ describe('multisearch prompt', () => { ); expect(result).toEqual(['blue', 'red']); - expect(output.text()).toContain('Selected: Blue, Red'); + expect(output.text()).toContain('2 selected'); + expect(output.text()).toContain('│ Blue'); + expect(output.text()).toContain('│ Red'); expect(output.text()).toContain('┃'); expect(output.text()).toContain('│'); }); @@ -792,7 +795,9 @@ describe('multisearch prompt', () => { ); expect(result).toEqual(['green', 'blue']); - expect(output.text()).toContain('Selected: Green, Blue'); + expect(output.text()).toContain('2 selected'); + expect(output.text()).toContain('│ Green'); + expect(output.text()).toContain('│ Blue'); }); it('transforms multisearch values before validation and return', async () => { From cfce4a215638ceddce5d2db0a1f857f048564752 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 22:27:35 +0800 Subject: [PATCH 191/675] Wrap textarea frame rows --- UPSTREAM.md | 3 +- packages/tui/src/typed-value/lines.ts | 38 ++++++++++++++++--- .../tui/src/typed-value/textarea-frame.ts | 4 +- packages/tui/tests/textarea.test.ts | 22 +++++++++++ packages/tui/tests/typed-value.test.ts | 8 ++++ 5 files changed, 68 insertions(+), 7 deletions(-) diff --git a/UPSTREAM.md b/UPSTREAM.md index c13a1794..189a0319 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -20,6 +20,7 @@ TypeScript and OpenTUI internals. | 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/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/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` | | Number input | `packages/tui/src/prompts/number.ts`, `packages/tui/src/typed-value.ts` | | Confirm, select, multiselect | `packages/tui/src/prompts/select/*` | @@ -75,7 +76,7 @@ later phases: - Form builder remaining method-surface and validation parity - Prompt lifecycle -- Textarea wrapping and cursor parity +- Textarea wrapped-line cursor parity - Default theme - Feature parity tests diff --git a/packages/tui/src/typed-value/lines.ts b/packages/tui/src/typed-value/lines.ts index 1da657f4..e7728027 100644 --- a/packages/tui/src/typed-value/lines.ts +++ b/packages/tui/src/typed-value/lines.ts @@ -1,4 +1,5 @@ import { fromCharacters } from '#tui/typed-value/characters'; +import { visibleWidth } from '#tui/strings'; type LineRange = { end: number; @@ -11,19 +12,46 @@ export type VisibleLineWindow = { total: number; }; -const lineRanges = (value: string[]): LineRange[] => { +const lineRanges = (value: string[], width?: number): LineRange[] => { const ranges: LineRange[] = []; let start = 0; for (const [index, character] of value.entries()) { if (character === '\n') { - ranges.push({ end: index, start }); + ranges.push(...wrappedRanges(value, start, index, width)); start = index + 1; } } - ranges.push({ end: value.length, start }); + ranges.push(...wrappedRanges(value, start, value.length, width)); + + return ranges; +}; + +const wrappedRanges = (value: string[], start: number, end: number, width?: number): LineRange[] => { + if (width === undefined || width <= 0 || start === end) { + return [{ end, start }]; + } + + const ranges: LineRange[] = []; + + let rangeStart = start; + let rangeWidth = 0; + + for (let index = start; index < end; index += 1) { + const characterWidth = visibleWidth(value[index] ?? ''); + + if (rangeWidth > 0 && rangeWidth + characterWidth > width) { + ranges.push({ end: index, start: rangeStart }); + rangeStart = index; + rangeWidth = 0; + } + + rangeWidth += characterWidth; + } + + ranges.push({ end, start: rangeStart }); return ranges; }; @@ -38,9 +66,9 @@ export const visibleLines = (value: string, cursor: number, rows: number | undef return visibleLineWindow(value, cursor, rows).lines.join('\n'); }; -export const visibleLineWindow = (value: string, cursor: number, rows: number | undefined): VisibleLineWindow => { +export const visibleLineWindow = (value: string, cursor: number, rows: number | undefined, width?: number): VisibleLineWindow => { const valueCharacters = [...value]; - const ranges = lineRanges(valueCharacters); + const ranges = lineRanges(valueCharacters, width); if (rows === undefined || rows <= 0) { return { diff --git a/packages/tui/src/typed-value/textarea-frame.ts b/packages/tui/src/typed-value/textarea-frame.ts index b670a545..eb5c6df2 100644 --- a/packages/tui/src/typed-value/textarea-frame.ts +++ b/packages/tui/src/typed-value/textarea-frame.ts @@ -4,6 +4,8 @@ import { dim } from '#tui/theme/styles'; import { visibleLineWindow } from '#tui/typed-value/lines'; import type { TypedValueOptions, TypedValueState } from '#tui/typed-value/types'; +const TEXTAREA_CONTENT_WIDTH = 60; + export const renderTextareaFrame = (message: string, state: TypedValueState, options: TypedValueOptions): string => { return renderBox({ body: textareaBody(state, options), title: message }); }; @@ -15,7 +17,7 @@ const textareaBody = (state: TypedValueState, options: TypedValueOptions): strin return placeholderBody(options, rows); } - const window = visibleLineWindow(state.value, state.cursor, rows); + const window = visibleLineWindow(state.value, state.cursor, rows, TEXTAREA_CONTENT_WIDTH); if (rows === undefined) { return window.lines.join('\n'); diff --git a/packages/tui/tests/textarea.test.ts b/packages/tui/tests/textarea.test.ts index e80a92b9..5ddbc0cc 100644 --- a/packages/tui/tests/textarea.test.ts +++ b/packages/tui/tests/textarea.test.ts @@ -81,6 +81,28 @@ describe('textarea prompt', () => { expect(output.text()).toContain('\u001B[2mUse full sentences.\u001B[22m'); }); + it('wraps long textarea lines inside the frame', async () => { + const output = createMemoryOutput(); + const longLine = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + const firstWrappedLine = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ01234567'; + + await withPromptEnvironment( + { + input: createScriptedInput([longLine, Key.ctrlD]), + output, + error: output, + interactive: true, + }, + () => textarea('Description', '', '', false, undefined, '', 2), + ); + + const latestFrame = output.text().split('┌ Description ').at(-1) ?? ''; + + expect(latestFrame).toContain(`│ ${firstWrappedLine}`); + expect(latestFrame).toContain('│ 89'); + expect(latestFrame).not.toContain(`│ ${longLine}`); + }); + it('uses control navigation keys to move between textarea lines', async () => { const output = createMemoryOutput(); diff --git a/packages/tui/tests/typed-value.test.ts b/packages/tui/tests/typed-value.test.ts index aaedbd50..dba23adb 100644 --- a/packages/tui/tests/typed-value.test.ts +++ b/packages/tui/tests/typed-value.test.ts @@ -163,4 +163,12 @@ describe('typed value editing', () => { total: 3, }); }); + + it('reports wrapped textarea line windows', () => { + expect(visibleLineWindow('abcdefghijkl', 12, 2, 5)).toEqual({ + lines: ['fghij', 'kl'], + start: 1, + total: 3, + }); + }); }); From e42a4647d83b12cdbd9c8a3230b1f7a0f62f6e24 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 22:30:36 +0800 Subject: [PATCH 192/675] Navigate wrapped textarea rows --- UPSTREAM.md | 2 +- packages/tui/src/typed-value.ts | 3 ++- packages/tui/src/typed-value/edit.ts | 4 ++-- .../tui/src/typed-value/edit/navigation.ts | 6 +++--- packages/tui/src/typed-value/lines.ts | 4 ++-- .../tui/src/typed-value/textarea-frame.ts | 3 +-- packages/tui/src/typed-value/textarea.ts | 1 + packages/tui/tests/textarea.test.ts | 16 ++++++++++++++ packages/tui/tests/typed-value.test.ts | 21 +++++++++++++++++-- 9 files changed, 47 insertions(+), 13 deletions(-) create mode 100644 packages/tui/src/typed-value/textarea.ts diff --git a/UPSTREAM.md b/UPSTREAM.md index 189a0319..8a4febdf 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -21,6 +21,7 @@ TypeScript and OpenTUI internals. | 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/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/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/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` | | Number input | `packages/tui/src/prompts/number.ts`, `packages/tui/src/typed-value.ts` | | Confirm, select, multiselect | `packages/tui/src/prompts/select/*` | @@ -76,7 +77,6 @@ later phases: - Form builder remaining method-surface and validation parity - Prompt lifecycle -- Textarea wrapped-line cursor parity - Default theme - Feature parity tests diff --git a/packages/tui/src/typed-value.ts b/packages/tui/src/typed-value.ts index 0fa2e8bb..fba118f4 100644 --- a/packages/tui/src/typed-value.ts +++ b/packages/tui/src/typed-value.ts @@ -3,6 +3,7 @@ import { PromptValidationError } from '#tui/prompt'; import { renderQuestion } from '#tui/theme'; 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 { applyTypedKey }; @@ -32,7 +33,7 @@ export const readTypedValue = async (message: string, options: TypedValueOptions return state.value; } - const next = applyTypedKey(state, key, options.allowNewLine); + const next = applyTypedKey(state, key, options.allowNewLine, options.allowNewLine ? TEXTAREA_CONTENT_WIDTH : undefined); if (next.cancelled) { environment.error.write('Cancelled.\n'); diff --git a/packages/tui/src/typed-value/edit.ts b/packages/tui/src/typed-value/edit.ts index 7d341ad5..4c488842 100644 --- a/packages/tui/src/typed-value/edit.ts +++ b/packages/tui/src/typed-value/edit.ts @@ -6,7 +6,7 @@ import { moveTypedValueCursor } from '#tui/typed-value/edit/navigation'; import { typedKeyResult } from '#tui/typed-value/edit/result'; import type { AppliedTypedKey, TypedValueState } from '#tui/typed-value/types'; -export const applyTypedKey = (state: TypedValueState, key: string, allowNewLine = false): AppliedTypedKey => { +export const applyTypedKey = (state: TypedValueState, key: string, allowNewLine = false, wrapWidth?: number): AppliedTypedKey => { const value = characters(state.value); let cursor = Math.max(0, Math.min(value.length, state.cursor)); @@ -30,7 +30,7 @@ export const applyTypedKey = (state: TypedValueState, key: string, allowNewLine return typedKeyResult(value, cursor); } - const moved = moveTypedValueCursor(value, cursor, key, allowNewLine); + const moved = moveTypedValueCursor(value, cursor, key, allowNewLine, wrapWidth); if (moved !== undefined) { return typedKeyResult(value, moved); diff --git a/packages/tui/src/typed-value/edit/navigation.ts b/packages/tui/src/typed-value/edit/navigation.ts index 82a2911c..b929d2d6 100644 --- a/packages/tui/src/typed-value/edit/navigation.ts +++ b/packages/tui/src/typed-value/edit/navigation.ts @@ -1,7 +1,7 @@ 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): number | undefined => { +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); } @@ -11,11 +11,11 @@ export const moveTypedValueCursor = (value: string[], cursor: number, key: strin } if (allowNewLine && (key === Key.up || key === Key.upArrow || key === Key.ctrlP)) { - return moveLine(value, cursor, -1); + return moveLine(value, cursor, -1, wrapWidth); } if (allowNewLine && (key === Key.down || key === Key.downArrow || key === Key.ctrlN)) { - return moveLine(value, cursor, 1); + return moveLine(value, cursor, 1, wrapWidth); } if (oneOf([Key.home, Key.ctrlA], key)) { diff --git a/packages/tui/src/typed-value/lines.ts b/packages/tui/src/typed-value/lines.ts index e7728027..0c9534e7 100644 --- a/packages/tui/src/typed-value/lines.ts +++ b/packages/tui/src/typed-value/lines.ts @@ -89,8 +89,8 @@ export const visibleLineWindow = (value: string, cursor: number, rows: number | }; }; -export const moveLine = (value: string[], cursor: number, direction: 1 | -1): number => { - const ranges = lineRanges(value); +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]; diff --git a/packages/tui/src/typed-value/textarea-frame.ts b/packages/tui/src/typed-value/textarea-frame.ts index eb5c6df2..a7b4c02e 100644 --- a/packages/tui/src/typed-value/textarea-frame.ts +++ b/packages/tui/src/typed-value/textarea-frame.ts @@ -2,10 +2,9 @@ import { renderScrollbarRows } from '#tui/concerns/scrollbar'; import { renderBox } from '#tui/theme/box'; import { dim } from '#tui/theme/styles'; import { visibleLineWindow } from '#tui/typed-value/lines'; +import { TEXTAREA_CONTENT_WIDTH } from '#tui/typed-value/textarea'; import type { TypedValueOptions, TypedValueState } from '#tui/typed-value/types'; -const TEXTAREA_CONTENT_WIDTH = 60; - export const renderTextareaFrame = (message: string, state: TypedValueState, options: TypedValueOptions): string => { return renderBox({ body: textareaBody(state, options), title: message }); }; diff --git a/packages/tui/src/typed-value/textarea.ts b/packages/tui/src/typed-value/textarea.ts new file mode 100644 index 00000000..7097da98 --- /dev/null +++ b/packages/tui/src/typed-value/textarea.ts @@ -0,0 +1 @@ +export const TEXTAREA_CONTENT_WIDTH = 60; diff --git a/packages/tui/tests/textarea.test.ts b/packages/tui/tests/textarea.test.ts index 5ddbc0cc..a8483589 100644 --- a/packages/tui/tests/textarea.test.ts +++ b/packages/tui/tests/textarea.test.ts @@ -103,6 +103,22 @@ describe('textarea prompt', () => { expect(latestFrame).not.toContain(`│ ${longLine}`); }); + it('moves the textarea cursor across wrapped rows before editing', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', Key.up, '!', Key.ctrlD]), + output, + error: output, + interactive: true, + }, + () => textarea('Description', '', '', false, undefined, '', 2), + ); + + expect(result).toBe('ab!cdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'); + }); + it('uses control navigation keys to move between textarea lines', async () => { const output = createMemoryOutput(); diff --git a/packages/tui/tests/typed-value.test.ts b/packages/tui/tests/typed-value.test.ts index dba23adb..98604562 100644 --- a/packages/tui/tests/typed-value.test.ts +++ b/packages/tui/tests/typed-value.test.ts @@ -28,11 +28,11 @@ const applyMultiline = (keys: string[]): { submitted: boolean; value: string } = return { submitted, value: state.value }; }; -const applyMultilineState = (value: string, cursor: number, keys: string[]): { cursor: number; value: string } => { +const applyMultilineState = (value: string, cursor: number, keys: string[], wrapWidth?: number): { cursor: number; value: string } => { let state = { cursor, value }; for (const key of keys) { - const next = applyTypedKey(state, key, true); + const next = applyTypedKey(state, key, true, wrapWidth); state = { cursor: next.cursor, value: next.value }; } @@ -132,6 +132,23 @@ describe('typed value editing', () => { }); }); + it('moves the textarea cursor between wrapped rows', () => { + expect(applyMultilineState('abcdefghijkl', 7, [Key.up], 5)).toEqual({ + cursor: 2, + value: 'abcdefghijkl', + }); + + expect(applyMultilineState('abcdefghijkl', 2, [Key.down], 5)).toEqual({ + cursor: 7, + value: 'abcdefghijkl', + }); + + expect(applyMultilineState('abcdefghijkl', 11, [Key.up], 5)).toEqual({ + cursor: 6, + value: 'abcdefghijkl', + }); + }); + it('moves to textarea line boundaries', () => { expect(applyMultilineState('abc\nde\nfghi', 5, [Key.home[0]])).toEqual({ cursor: 4, From 689657eecc203c11c72916b7fd07fc2bc79ca89b Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 22:37:04 +0800 Subject: [PATCH 193/675] Align display helper return values --- UPSTREAM.md | 1 + packages/tui/src/form/builder/output.ts | 53 ++++++++++++++++++++----- packages/tui/src/output/notes.ts | 16 ++++---- packages/tui/src/output/table.ts | 8 +--- packages/tui/tests/form.test.ts | 8 ++-- packages/tui/tests/output.test.ts | 28 ++++++------- 6 files changed, 72 insertions(+), 42 deletions(-) diff --git a/UPSTREAM.md b/UPSTREAM.md index 8a4febdf..14b43a01 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -53,6 +53,7 @@ TypeScript and OpenTUI internals. | 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/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/tests/output.test.ts`, `packages/tui/tests/form.test.ts` | | Progress boxed frame rendering and formatted fractions | `packages/tui/src/status/progress/render.ts`, `packages/tui/src/theme/box.ts` | | Spinner static and deterministic animated frame rendering | `packages/tui/src/status/spinner.ts`, `packages/tui/src/status/spinner/render.ts` | | Task static and deterministic animated frame rendering, bounded logs, and stable summary symbols | `packages/tui/src/status/task.ts`, `packages/tui/src/status/task/*` | diff --git a/packages/tui/src/form/builder/output.ts b/packages/tui/src/form/builder/output.ts index 8e301814..27b96988 100644 --- a/packages/tui/src/form/builder/output.ts +++ b/packages/tui/src/form/builder/output.ts @@ -1,6 +1,7 @@ import { alert, datatable, error, info, intro, note, outro, table, warning } from '#tui/output'; +import { sideEffectStep } from '#tui/form/builder/step'; import type { FormBuilder } from '#tui/form/builder/index'; -import type { DataTablePromptOptions, TableOptions } from '#tui/types'; +import type { DataTablePromptOptions, MaybePromise, TableOptions } from '#tui/types'; export type OutputBuilderMethods = { alert(this: FormBuilder, message: string, name?: string): FormBuilder; @@ -14,32 +15,66 @@ export type OutputBuilderMethods = { warning(this: FormBuilder, message: string, name?: string): FormBuilder; }; +const displayStep = (callback: () => void): (() => MaybePromise) => sideEffectStep(callback); + export const outputBuilderMethods: OutputBuilderMethods & ThisType = { alert(message, name) { - return this.add(() => alert(message), name, true); + return this.add( + displayStep(() => alert(message)), + name, + true, + ); }, datatable(options: DataTablePromptOptions, name?: string) { return this.add(() => datatable(options), name); }, error(message, name) { - return this.add(() => error(message), name, true); + return this.add( + displayStep(() => error(message)), + name, + true, + ); }, info(message, name) { - return this.add(() => info(message), name, true); + return this.add( + displayStep(() => info(message)), + name, + true, + ); }, intro(message, name) { - return this.add(() => intro(message), name, true); + return this.add( + displayStep(() => intro(message)), + name, + true, + ); }, note(message, type = null, name) { - return this.add(() => note(message, type), name, true); + return this.add( + displayStep(() => note(message, type)), + name, + true, + ); }, outro(message, name) { - return this.add(() => outro(message), name, true); + return this.add( + displayStep(() => outro(message)), + name, + true, + ); }, table(headersOrOptions = [], rows = null, name) { - return this.add(() => table(headersOrOptions, rows), name, true); + return this.add( + displayStep(() => table(headersOrOptions, rows)), + name, + true, + ); }, warning(message, name) { - return this.add(() => warning(message), name, true); + return this.add( + displayStep(() => warning(message)), + name, + true, + ); }, }; diff --git a/packages/tui/src/output/notes.ts b/packages/tui/src/output/notes.ts index 30387510..82bda5e0 100644 --- a/packages/tui/src/output/notes.ts +++ b/packages/tui/src/output/notes.ts @@ -26,22 +26,20 @@ const notePrefix = (type?: NoteType | null): string => { } }; -export const note = (message: string, type?: NoteType | null): boolean => { +export const note = (message: string, type?: NoteType | null): void => { const prefix = notePrefix(type); promptEnvironment().output.write(`${prefix} ${message}\n`); - - return true; }; -export const error = (message: string): boolean => note(message, 'error'); +export const error = (message: string): void => note(message, 'error'); -export const warning = (message: string): boolean => note(message, 'warning'); +export const warning = (message: string): void => note(message, 'warning'); -export const alert = (message: string): boolean => note(message, 'alert'); +export const alert = (message: string): void => note(message, 'alert'); -export const info = (message: string): boolean => note(message, 'info'); +export const info = (message: string): void => note(message, 'info'); -export const intro = (message: string): boolean => note(message, 'intro'); +export const intro = (message: string): void => note(message, 'intro'); -export const outro = (message: string): boolean => note(message, 'outro'); +export const outro = (message: string): void => note(message, 'outro'); diff --git a/packages/tui/src/output/table.ts b/packages/tui/src/output/table.ts index 08ea5897..7b56cbaa 100644 --- a/packages/tui/src/output/table.ts +++ b/packages/tui/src/output/table.ts @@ -7,7 +7,7 @@ const stringify = (value: string | number | boolean | null | undefined): string return value === null || value === undefined ? '' : String(value); }; -export const table = (headersOrOptions: TableOptions | string[] = [], rows: TableOptions['rows'] | null = null): boolean => { +export const table = (headersOrOptions: TableOptions | string[] = [], rows: TableOptions['rows'] | null = null): void => { const options = parseTableOptions(headersOrOptions, rows); const normalizedRows = options.rows.map((row) => { @@ -19,11 +19,9 @@ export const table = (headersOrOptions: TableOptions | string[] = [], rows: Tabl }); promptEnvironment().output.write(`${renderTable(options.headers, normalizedRows)}\n`); - - return true; }; -export const grid = (items: Array = [], maxWidth?: number): boolean => { +export const grid = (items: Array = [], maxWidth?: number): void => { const width = Math.max(1, Math.trunc(maxWidth ?? 4)); const rows: string[][] = []; @@ -33,8 +31,6 @@ export const grid = (items: Array = [], maxWidth?: nu promptEnvironment().output.write(rows.map((row) => row.join(' ')).join('\n')); promptEnvironment().output.write(items.length > 0 ? '\n' : ''); - - return true; }; export const dataTable = table; diff --git a/packages/tui/tests/form.test.ts b/packages/tui/tests/form.test.ts index c73492d5..c7bc30a1 100644 --- a/packages/tui/tests/form.test.ts +++ b/packages/tui/tests/form.test.ts @@ -129,7 +129,7 @@ describe('form builder', () => { ); expect(responses[0]).toBe('C'); - expect(responses.note).toBe(true); + expect(responses.note).toBeNull(); expect(responses[2]).toBe('D'); }); @@ -316,7 +316,7 @@ describe('form builder', () => { expect(output.text()).toContain('2 / 2'); }); - it('stores output helper success responses', async () => { + it('stores null for display-only output helper responses', async () => { const output = createMemoryOutput(); const responses = await withPromptEnvironment( @@ -331,7 +331,7 @@ describe('form builder', () => { .submit(), ); - expect(responses.note).toBe(true); - expect(responses.table).toBe(true); + expect(responses.note).toBeNull(); + expect(responses.table).toBeNull(); }); }); diff --git a/packages/tui/tests/output.test.ts b/packages/tui/tests/output.test.ts index fa98e05b..104b2929 100644 --- a/packages/tui/tests/output.test.ts +++ b/packages/tui/tests/output.test.ts @@ -2,29 +2,29 @@ import { describe, expect, it } from 'vitest'; import { alert, createMemoryOutput, error, grid, info, intro, note, outro, table, warning, withPromptEnvironment } from '#tui/index'; describe('output helpers', () => { - it('returns success from note-style helpers', async () => { + it('renders note-style helpers without returning a value', async () => { const output = createMemoryOutput(); await withPromptEnvironment({ output, error: output }, async () => { - expect(note('Hello')).toBe(true); - expect(error('Nope')).toBe(true); - expect(warning('Careful')).toBe(true); - expect(alert('Heads up')).toBe(true); - expect(info('Facts')).toBe(true); - expect(intro('Start')).toBe(true); - expect(outro('Done')).toBe(true); + expect(note('Hello')).toBeUndefined(); + expect(error('Nope')).toBeUndefined(); + expect(warning('Careful')).toBeUndefined(); + expect(alert('Heads up')).toBeUndefined(); + expect(info('Facts')).toBeUndefined(); + expect(intro('Start')).toBeUndefined(); + expect(outro('Done')).toBeUndefined(); }); expect(output.text()).toContain('Hello'); expect(output.text()).toContain('Done'); }); - it('returns success from table and grid helpers', async () => { + it('renders table and grid helpers without returning a value', async () => { const output = createMemoryOutput(); await withPromptEnvironment({ output, error: output }, async () => { - expect(table(['Name'], [['Ollin']])).toBe(true); - expect(grid(['A', 'B'], 2)).toBe(true); + expect(table(['Name'], [['Ollin']])).toBeUndefined(); + expect(grid(['A', 'B'], 2)).toBeUndefined(); }); expect(output.text()).toContain('| Name'); @@ -35,8 +35,8 @@ describe('output helpers', () => { const output = createMemoryOutput(); await withPromptEnvironment({ output, error: output }, async () => { - expect(grid(['A', 'B'], 0)).toBe(true); - expect(grid(['C', 'D'], -2)).toBe(true); + expect(grid(['A', 'B'], 0)).toBeUndefined(); + expect(grid(['C', 'D'], -2)).toBeUndefined(); }); expect(output.text()).toContain('A\nB'); @@ -47,7 +47,7 @@ describe('output helpers', () => { const output = createMemoryOutput(); await withPromptEnvironment({ output, error: output }, async () => { - expect(grid(['A', 'B', 'C', 'D'], 2.5)).toBe(true); + expect(grid(['A', 'B', 'C', 'D'], 2.5)).toBeUndefined(); }); expect(output.text()).toBe('A B\nC D\n'); From f00139396f620e128c81d1484d71365cc571d0d3 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 22:39:55 +0800 Subject: [PATCH 194/675] Align terminal side effect returns --- UPSTREAM.md | 1 + packages/tui/src/output/notify.ts | 4 ++-- packages/tui/src/output/terminal.ts | 8 ++------ packages/tui/tests/notify.test.ts | 8 ++++++-- packages/tui/tests/terminal.test.ts | 4 ++-- 5 files changed, 13 insertions(+), 12 deletions(-) diff --git a/UPSTREAM.md b/UPSTREAM.md index 14b43a01..f1255c44 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -64,6 +64,7 @@ TypeScript and OpenTUI internals. | Progress cursor visibility and rendered-frame redraw cleanup | `packages/tui/src/status/progress.ts`, `packages/tui/src/status/frame.ts` | | Progress signal cleanup | `packages/tui/src/status/progress.ts`, `packages/tui/tests/progress.test.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` | +| 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/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` | diff --git a/packages/tui/src/output/notify.ts b/packages/tui/src/output/notify.ts index 1609fe1f..0e01440b 100644 --- a/packages/tui/src/output/notify.ts +++ b/packages/tui/src/output/notify.ts @@ -26,6 +26,6 @@ export const notifyForPlatform = (targetPlatform: NodeJS.Platform, title: string return false; }; -export const notify = (title: string, body = '', subtitle = '', sound = '', icon = ''): boolean => { - return notifyForPlatform(platform, title, body, subtitle, sound, icon); +export const notify = (title: string, body = '', subtitle = '', sound = '', icon = ''): void => { + notifyForPlatform(platform, title, body, subtitle, sound, icon); }; diff --git a/packages/tui/src/output/terminal.ts b/packages/tui/src/output/terminal.ts index ae0a2460..4c1444d5 100644 --- a/packages/tui/src/output/terminal.ts +++ b/packages/tui/src/output/terminal.ts @@ -1,13 +1,9 @@ import { clearTerminal, setTerminalTitle } from '#tui/terminal'; -export const title = (value: string): boolean => { +export const title = (value: string): void => { setTerminalTitle(value); - - return true; }; -export const clear = (): boolean => { +export const clear = (): void => { clearTerminal(); - - return true; }; diff --git a/packages/tui/tests/notify.test.ts b/packages/tui/tests/notify.test.ts index d3243e67..77580184 100644 --- a/packages/tui/tests/notify.test.ts +++ b/packages/tui/tests/notify.test.ts @@ -1,5 +1,5 @@ -import { describe, expect, it } from 'vitest'; -import { createMemoryOutput, notificationCommand, notificationCommands, notifyForPlatform, withPromptEnvironment } from '#tui/index'; +import { describe, expect, expectTypeOf, it } from 'vitest'; +import { createMemoryOutput, notificationCommand, notificationCommands, notify, notifyForPlatform, withPromptEnvironment } from '#tui/index'; import type { NotificationCommand } from '#tui/index'; describe('notify helper', () => { @@ -54,6 +54,10 @@ describe('notify helper', () => { expect(output.text()).toContain('Deploy: Done'); }); + it('does not expose a public notification return value', () => { + expectTypeOf(notify).returns.toBeVoid(); + }); + it('executes the first available Linux notifier and reports the process result', () => { const executed: NotificationCommand[] = []; diff --git a/packages/tui/tests/terminal.test.ts b/packages/tui/tests/terminal.test.ts index 03a73bf7..072fedfe 100644 --- a/packages/tui/tests/terminal.test.ts +++ b/packages/tui/tests/terminal.test.ts @@ -23,7 +23,7 @@ describe('terminal helpers', () => { await withPromptEnvironment({ output, error: output }, async () => { clearTerminal(); - expect(clear()).toBe(true); + expect(clear()).toBeUndefined(); }); expect(output.text()).toBe('\u001B[H\u001B[J\u001B[H\u001B[J'); @@ -34,7 +34,7 @@ describe('terminal helpers', () => { await withPromptEnvironment({ output, error: output }, async () => { setTerminalTitle('Demo'); - expect(title('Prompt')).toBe(true); + expect(title('Prompt')).toBeUndefined(); }); expect(output.text()).toBe('\u001B]0;Demo\u0007\u001B]0;Prompt\u0007'); From b9ed339e0569ba8ffd4f90dbad5cf9f15edd67e3 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 22:44:25 +0800 Subject: [PATCH 195/675] Add prompt cancellation hook --- UPSTREAM.md | 1 + packages/tui/src/index.ts | 3 +- packages/tui/src/output/data-table/read.ts | 4 +- packages/tui/src/prompt.ts | 2 + packages/tui/src/prompt/cancel.ts | 17 ++++++ packages/tui/src/prompts/number/input.ts | 4 +- packages/tui/src/prompts/password/input.ts | 4 +- packages/tui/src/prompts/search/read-multi.ts | 5 +- .../tui/src/prompts/search/read-single.ts | 5 +- .../tui/src/prompts/select/read-confirm.ts | 4 +- .../tui/src/prompts/select/read-multiple.ts | 4 +- .../tui/src/prompts/select/read-selected.ts | 4 +- .../src/prompts/suggest/read-autocomplete.ts | 4 +- packages/tui/src/prompts/suggest/read.ts | 4 +- packages/tui/src/typed-value.ts | 4 +- packages/tui/tests/prompt.test.ts | 52 ++++++++++++++++++- 16 files changed, 96 insertions(+), 25 deletions(-) create mode 100644 packages/tui/src/prompt/cancel.ts diff --git a/UPSTREAM.md b/UPSTREAM.md index f1255c44..6fad3ce9 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -13,6 +13,7 @@ TypeScript and OpenTUI internals. | --- | --- | | 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 cancellation callback lifecycle | `packages/tui/src/prompt/cancel.ts`, `packages/tui/tests/prompt.test.ts` | | Key constants 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` | diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index 75748751..8da60fdc 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -26,7 +26,8 @@ export { warning, } from '#tui/output'; export type { NotificationCommand, NotificationRuntime } from '#tui/output'; -export { PromptValidationError } from '#tui/prompt'; +export { cancelUsing, PromptValidationError } from '#tui/prompt'; +export type { PromptCancelHandler } from '#tui/prompt'; export { Logger, Progress, Stream, progress, spin, stream, task } from '#tui/status'; export { backgroundColor, diff --git a/packages/tui/src/output/data-table/read.ts b/packages/tui/src/output/data-table/read.ts index 1360d8fd..5e75ea11 100644 --- a/packages/tui/src/output/data-table/read.ts +++ b/packages/tui/src/output/data-table/read.ts @@ -1,6 +1,6 @@ import { promptEnvironment } from '#tui/environment'; import { Key } from '#tui/key'; -import { PromptValidationError } from '#tui/prompt'; +import { cancelPrompt, PromptValidationError } from '#tui/prompt'; import { dataTableNavigationAction, startsDataTableSearch } from '#tui/output/data-table/keys'; import { moveDataTableSelection } from '#tui/output/data-table/navigation'; import { renderDataTableFrame } from '#tui/output/data-table/render'; @@ -60,7 +60,7 @@ export const readDataTableSelection = async (options: DataTablePromptOptions< if (key === Key.ctrlC) { environment.error.write('Cancelled.\n'); - return selectedDataTableValue(rows, selected); + return cancelPrompt(selectedDataTableValue(rows, selected)); } const nextSearch = applyDataTableSearchKey(search, key); diff --git a/packages/tui/src/prompt.ts b/packages/tui/src/prompt.ts index d0cca60a..51983d19 100644 --- a/packages/tui/src/prompt.ts +++ b/packages/tui/src/prompt.ts @@ -1,4 +1,6 @@ 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 { promptUntilValid } from '#tui/prompt/lifecycle'; export { ensureRequired, validationMessage } from '#tui/prompt/validation'; diff --git a/packages/tui/src/prompt/cancel.ts b/packages/tui/src/prompt/cancel.ts new file mode 100644 index 00000000..db889877 --- /dev/null +++ b/packages/tui/src/prompt/cancel.ts @@ -0,0 +1,17 @@ +import type { MaybePromise } from '#tui/types'; + +export type PromptCancelHandler = () => MaybePromise; + +let promptCancelHandler: PromptCancelHandler | null = null; + +export const cancelUsing = (handler?: PromptCancelHandler | null): void => { + promptCancelHandler = handler ?? null; +}; + +export const cancelPrompt = async (fallback: T): Promise => { + if (promptCancelHandler === null) { + return fallback; + } + + return (await promptCancelHandler()) as T; +}; diff --git a/packages/tui/src/prompts/number/input.ts b/packages/tui/src/prompts/number/input.ts index cd729d5a..0b910778 100644 --- a/packages/tui/src/prompts/number/input.ts +++ b/packages/tui/src/prompts/number/input.ts @@ -1,6 +1,6 @@ import { promptEnvironment } from '#tui/environment'; import { Key } from '#tui/key'; -import { PromptValidationError } from '#tui/prompt'; +import { cancelPrompt, PromptValidationError } from '#tui/prompt'; import { renderQuestion } from '#tui/theme'; import { applyTypedKey } from '#tui/typed-value'; import { renderNumberValue } from '#tui/prompts/number/render'; @@ -53,7 +53,7 @@ export const readNumberValue = async (message: string, options: NumberInputOptio if (next.cancelled) { environment.error.write('Cancelled.\n'); - return state.value; + return cancelPrompt(state.value); } state = { diff --git a/packages/tui/src/prompts/password/input.ts b/packages/tui/src/prompts/password/input.ts index edff94c3..09c89a24 100644 --- a/packages/tui/src/prompts/password/input.ts +++ b/packages/tui/src/prompts/password/input.ts @@ -1,5 +1,5 @@ import { promptEnvironment } from '#tui/environment'; -import { PromptValidationError } from '#tui/prompt'; +import { cancelPrompt, PromptValidationError } from '#tui/prompt'; import { renderQuestion } from '#tui/theme'; import { applyTypedKey } from '#tui/typed-value'; @@ -50,7 +50,7 @@ export const readPasswordValue = async (message: string, options: PasswordInputO if (next.cancelled) { environment.error.write('Cancelled.\n'); - return state.value; + return cancelPrompt(state.value); } state = { diff --git a/packages/tui/src/prompts/search/read-multi.ts b/packages/tui/src/prompts/search/read-multi.ts index 02a963b3..b2fab610 100644 --- a/packages/tui/src/prompts/search/read-multi.ts +++ b/packages/tui/src/prompts/search/read-multi.ts @@ -1,5 +1,6 @@ import { promptEnvironment } from '#tui/environment'; import { Key } from '#tui/key'; +import { cancelPrompt } from '#tui/prompt'; import { applyTypedKey } from '#tui/typed-value'; import { resolveSearchChoices } from '#tui/prompts/search/choices'; import { moveSearchHighlight, searchNavigationAction } from '#tui/prompts/search/keys'; @@ -45,7 +46,7 @@ export const readMultiSearchChoices = async (options: MultiSearchPromptOption if (key === Key.ctrlC) { renderCancelledSearch(options.message, state.value, options.placeholder); - return selectedSearchValues(selected); + return cancelPrompt(selectedSearchValues(selected)); } const action = searchNavigationAction(key); @@ -83,7 +84,7 @@ export const readMultiSearchChoices = async (options: MultiSearchPromptOption if (next.cancelled) { renderCancelledSearch(options.message, state.value, options.placeholder); - return options.default ?? []; + return cancelPrompt(options.default ?? []); } state = { cursor: next.cursor, value: next.value }; diff --git a/packages/tui/src/prompts/search/read-single.ts b/packages/tui/src/prompts/search/read-single.ts index 866065e8..714616b0 100644 --- a/packages/tui/src/prompts/search/read-single.ts +++ b/packages/tui/src/prompts/search/read-single.ts @@ -1,5 +1,6 @@ import { promptEnvironment } from '#tui/environment'; import { Key } from '#tui/key'; +import { cancelPrompt } from '#tui/prompt'; import { applyTypedKey } from '#tui/typed-value'; import { resolveSearchChoices } from '#tui/prompts/search/choices'; import { clearsSearchHighlight, moveSearchHighlight, searchNavigationAction } from '#tui/prompts/search/keys'; @@ -32,7 +33,7 @@ export const readSearchChoice = async (options: SearchPromptOptions, attem if (key === Key.ctrlC) { renderCancelledSearch(options.message, state.value, options.placeholder); - return cancelledSearchValue(choices, highlighted, options.default); + return cancelPrompt(cancelledSearchValue(choices, highlighted, options.default)); } const action = searchNavigationAction(key, { controlNavigation: true, lineControls: true }); @@ -75,7 +76,7 @@ export const readSearchChoice = async (options: SearchPromptOptions, attem if (next.cancelled) { renderCancelledSearch(options.message, state.value, options.placeholder); - return options.default; + return cancelPrompt(options.default); } state = { cursor: next.cursor, value: next.value }; diff --git a/packages/tui/src/prompts/select/read-confirm.ts b/packages/tui/src/prompts/select/read-confirm.ts index 8b4536c9..ce899f71 100644 --- a/packages/tui/src/prompts/select/read-confirm.ts +++ b/packages/tui/src/prompts/select/read-confirm.ts @@ -1,6 +1,6 @@ import { promptEnvironment } from '#tui/environment'; import { Key } from '#tui/key'; -import { ask } from '#tui/prompt'; +import { ask, cancelPrompt } from '#tui/prompt'; import { renderQuestion } from '#tui/theme'; import type { ConfirmPromptOptions } from '#tui/types'; @@ -66,7 +66,7 @@ export const readConfirm = async (options: ConfirmPromptOptions): Promise( if (key === Key.ctrlC) { renderCancelledChoices(message, choices, selected, marked, scroll); - return markedChoiceValues(choices, marked); + return cancelPrompt(markedChoiceValues(choices, marked)); } if (key.includes(',')) { diff --git a/packages/tui/src/prompts/select/read-selected.ts b/packages/tui/src/prompts/select/read-selected.ts index 27be642d..d2d086b7 100644 --- a/packages/tui/src/prompts/select/read-selected.ts +++ b/packages/tui/src/prompts/select/read-selected.ts @@ -1,6 +1,6 @@ import { promptEnvironment } from '#tui/environment'; import { Key } from '#tui/key'; -import { ask, PromptValidationError } from '#tui/prompt'; +import { ask, cancelPrompt, PromptValidationError } from '#tui/prompt'; import { renderChoices } from '#tui/theme'; import { findChoice, firstEnabledIndex } from '#tui/concerns/choices'; import { moveSelectHighlight, selectNavigationAction } from '#tui/prompts/select/keys'; @@ -55,7 +55,7 @@ export const readSelectedChoice = async (message: string, choices: Array { + afterEach(() => { + cancelUsing(null); + }); + it('uses the default required message for empty values', () => { expect(ensureRequired('', true)).toBe('Required.'); expect(ensureRequired([], true)).toBe('Required.'); @@ -89,4 +96,45 @@ describe('prompt validation', () => { }, ); }); + + it('uses a custom cancellation return value after rendering the cancelled state', async () => { + const output = createMemoryOutput(); + + cancelUsing(() => 'Manual'); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.ctrlC]), + output, + error: output, + interactive: true, + }, + () => text('Name'), + ); + + expect(result).toBe('Manual'); + expect(output.text()).toContain('Cancelled.'); + }); + + it('propagates custom cancellation errors after rendering the cancelled state', async () => { + const output = createMemoryOutput(); + + cancelUsing(() => { + throw new Error('Stopped.'); + }); + + await expect( + withPromptEnvironment( + { + input: createScriptedInput([Key.ctrlC]), + output, + error: output, + interactive: true, + }, + () => text('Name'), + ), + ).rejects.toThrow('Stopped.'); + + expect(output.text()).toContain('Cancelled.'); + }); }); From ca3c3e721045ddcdd2d00cf97eb4de4b1b6afe76 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 22:47:13 +0800 Subject: [PATCH 196/675] Render boxed grid output --- UPSTREAM.md | 1 + packages/tui/src/output/grid.ts | 68 +++++++++++++++++++++++++++++++ packages/tui/src/output/table.ts | 11 +++-- packages/tui/tests/output.test.ts | 42 +++++++++++++++---- 4 files changed, 109 insertions(+), 13 deletions(-) create mode 100644 packages/tui/src/output/grid.ts diff --git a/UPSTREAM.md b/UPSTREAM.md index 6fad3ce9..dbe047d1 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -51,6 +51,7 @@ TypeScript and OpenTUI internals. | 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/data-table.ts` | +| Boxed balanced grid rendering | `packages/tui/src/output/grid.ts`, `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/tests/form.test.ts` | diff --git a/packages/tui/src/output/grid.ts b/packages/tui/src/output/grid.ts new file mode 100644 index 00000000..4d22a02b --- /dev/null +++ b/packages/tui/src/output/grid.ts @@ -0,0 +1,68 @@ +import { visibleWidth } from '#tui/strings'; +import { terminalSize } from '#tui/terminal'; + +const padVisible = (value: string, width: number): string => `${value}${' '.repeat(Math.max(0, width - visibleWidth(value)))}`; + +const balancedColumnCount = (itemCount: number, maxColumns: number): number => { + if (itemCount <= maxColumns) { + return itemCount; + } + + for (let columns = maxColumns; columns >= 1; columns -= 1) { + const remainder = itemCount % columns; + + if (remainder === 0 || remainder >= Math.ceil(columns / 2)) { + return columns; + } + } + + return maxColumns; +}; + +const chunkItems = (items: string[], columnCount: number): string[][] => { + const rows: string[][] = []; + + for (let index = 0; index < items.length; index += columnCount) { + const row = items.slice(index, index + columnCount); + + while (row.length < columnCount) { + row.push(''); + } + + rows.push(row); + } + + return rows; +}; + +const horizontal = (left: string, middle: string, right: string, widths: number[]): string => { + return `${left}${widths.map((width) => '─'.repeat(width + 2)).join(middle)}${right}`; +}; + +export const renderGrid = (items: Array = [], maxWidth?: number): string => { + if (items.length === 0) { + return ''; + } + + const values = items.map(String); + const width = Math.max(1, Math.trunc(maxWidth ?? terminalSize().columns)); + const availableWidth = width - 2; + const cellWidth = Math.max(...values.map(visibleWidth)) + 4; + const maxColumns = Math.max(1, Math.floor((availableWidth - 1) / (cellWidth + 1))); + const columnCount = Math.max(1, balancedColumnCount(values.length, maxColumns)); + const rows = chunkItems(values, columnCount); + const widths = Array.from({ length: columnCount }, (_, column) => Math.max(...rows.map((row) => visibleWidth(row[column] ?? '')))); + const renderedRows: string[] = [horizontal('┌', '┬', '┐', widths)]; + + rows.forEach((row, index) => { + if (index > 0) { + renderedRows.push(horizontal('├', '┼', '┤', widths)); + } + + renderedRows.push(`│ ${widths.map((columnWidth, column) => padVisible(row[column] ?? '', columnWidth)).join(' │ ')} │`); + }); + + renderedRows.push(horizontal('└', '┴', '┘', widths)); + + return renderedRows.map((line) => ` ${line}`).join('\n'); +}; diff --git a/packages/tui/src/output/table.ts b/packages/tui/src/output/table.ts index 7b56cbaa..2ac0fa7e 100644 --- a/packages/tui/src/output/table.ts +++ b/packages/tui/src/output/table.ts @@ -1,5 +1,6 @@ import { promptEnvironment } from '#tui/environment'; import { parseTableOptions } from '#tui/output/validators/table'; +import { renderGrid } from '#tui/output/grid'; import { renderTable } from '#tui/theme'; import type { TableOptions } from '#tui/types'; @@ -22,15 +23,13 @@ export const table = (headersOrOptions: TableOptions | string[] = [], rows: Tabl }; export const grid = (items: Array = [], maxWidth?: number): void => { - const width = Math.max(1, Math.trunc(maxWidth ?? 4)); - const rows: string[][] = []; + const rendered = renderGrid(items, maxWidth); - for (let index = 0; index < items.length; index += width) { - rows.push(items.slice(index, index + width).map(String)); + if (rendered === '') { + return; } - promptEnvironment().output.write(rows.map((row) => row.join(' ')).join('\n')); - promptEnvironment().output.write(items.length > 0 ? '\n' : ''); + promptEnvironment().output.write(`${rendered}\n`); }; export const dataTable = table; diff --git a/packages/tui/tests/output.test.ts b/packages/tui/tests/output.test.ts index 104b2929..d1d6e338 100644 --- a/packages/tui/tests/output.test.ts +++ b/packages/tui/tests/output.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import { alert, createMemoryOutput, error, grid, info, intro, note, outro, table, warning, withPromptEnvironment } from '#tui/index'; +import { renderGrid } from '#tui/output/grid'; describe('output helpers', () => { it('renders note-style helpers without returning a value', async () => { @@ -19,16 +20,17 @@ describe('output helpers', () => { expect(output.text()).toContain('Done'); }); - it('renders table and grid helpers without returning a value', async () => { + it('renders table and boxed grid helpers without returning a value', async () => { const output = createMemoryOutput(); await withPromptEnvironment({ output, error: output }, async () => { expect(table(['Name'], [['Ollin']])).toBeUndefined(); - expect(grid(['A', 'B'], 2)).toBeUndefined(); + expect(grid(['A', 'B'], 80)).toBeUndefined(); }); expect(output.text()).toContain('| Name'); - expect(output.text()).toContain('A B'); + expect(output.text()).toContain('┌───┬───┐'); + expect(output.text()).toContain('│ A │ B │'); }); it('keeps grid rendering finite for invalid widths', async () => { @@ -39,17 +41,43 @@ describe('output helpers', () => { expect(grid(['C', 'D'], -2)).toBeUndefined(); }); - expect(output.text()).toContain('A\nB'); - expect(output.text()).toContain('C\nD'); + expect(output.text()).toContain('│ A │'); + expect(output.text()).toContain('│ B │'); + expect(output.text()).toContain('│ C │'); + expect(output.text()).toContain('│ D │'); }); - it('normalizes fractional grid widths without overlapping rows', async () => { + it('normalizes fractional grid widths without overlapping boxed rows', async () => { const output = createMemoryOutput(); await withPromptEnvironment({ output, error: output }, async () => { expect(grid(['A', 'B', 'C', 'D'], 2.5)).toBeUndefined(); }); - expect(output.text()).toBe('A B\nC D\n'); + expect(output.text()).toBe(' ┌───┐\n │ A │\n ├───┤\n │ B │\n ├───┤\n │ C │\n ├───┤\n │ D │\n └───┘\n'); + }); + + it('renders balanced grid rows with table separators', () => { + expect(renderGrid(['component-alpha', 'api-client', 'theme-box', 'status-task', 'prompt-input', 'stream-log'], 50)).toBe( + [ + ' ┌─────────────────┬─────────────┐', + ' │ component-alpha │ api-client │', + ' ├─────────────────┼─────────────┤', + ' │ theme-box │ status-task │', + ' ├─────────────────┼─────────────┤', + ' │ prompt-input │ stream-log │', + ' └─────────────────┴─────────────┘', + ].join('\n'), + ); + }); + + it('does not render empty grids', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + grid([]); + }); + + expect(output.text()).toBe(''); }); }); From 9999d3be7b31be2e6860350ff42a1a59229c23e2 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 22:50:30 +0800 Subject: [PATCH 197/675] Render empty data table searches --- UPSTREAM.md | 1 + packages/tui/src/output/data-table/render.ts | 12 ++++++--- packages/tui/tests/table.test.ts | 26 ++++++++++++++++++++ 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/UPSTREAM.md b/UPSTREAM.md index dbe047d1..cebc67f4 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -51,6 +51,7 @@ TypeScript and OpenTUI internals. | 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/data-table.ts` | +| Data table no-results search rendering | `packages/tui/src/output/data-table/render.ts`, `packages/tui/tests/table.test.ts` | | Boxed balanced grid rendering | `packages/tui/src/output/grid.ts`, `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` | diff --git a/packages/tui/src/output/data-table/render.ts b/packages/tui/src/output/data-table/render.ts index 3914deda..c805426c 100644 --- a/packages/tui/src/output/data-table/render.ts +++ b/packages/tui/src/output/data-table/render.ts @@ -18,6 +18,15 @@ export const renderDataTableFrame = (options: RenderDataTableFrameOptions) const environment = promptEnvironment(); const selected = clampDataTableSelection(options.selected, options.rows); const window = dataTableRowWindow(options.rows.length, selected, options.scroll); + const querySuffix = options.mode === 'search' || options.query.length > 0 ? ` ${options.query}` : ''; + + environment.output.write(`${options.message}${querySuffix}\n`); + + if (options.rows.length === 0) { + environment.output.write(`${renderTable([], [['No results found.']])}\n`); + + return selected; + } const renderedRows = options.rows.slice(window.start, window.end).map(({ row }, offset) => { const index = window.start + offset; @@ -25,9 +34,6 @@ export const renderDataTableFrame = (options: RenderDataTableFrameOptions) return [index === selected ? '›' : ' ', ...dataTableRowCells(options.headers, row)]; }); - const querySuffix = options.mode === 'search' || options.query.length > 0 ? ` ${options.query}` : ''; - - environment.output.write(`${options.message}${querySuffix}\n`); environment.output.write(`${renderTable(['', ...options.headers], renderedRows)}\n`); return selected; diff --git a/packages/tui/tests/table.test.ts b/packages/tui/tests/table.test.ts index 0a63aa88..b5f99263 100644 --- a/packages/tui/tests/table.test.ts +++ b/packages/tui/tests/table.test.ts @@ -247,6 +247,32 @@ describe('data table prompt', () => { expect(latestFrame).toContain('Beta'); }); + it('renders a no-results row for unmatched data table searches', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['/', 'Z', Key.escape, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + datatable({ + message: 'Pick project', + headers: ['Name'], + rows: [ + { Name: 'Alpha', value: 'alpha' }, + { Name: 'Beta', value: 'beta' }, + ], + }), + ); + + expect(result).toBe('alpha'); + expect(output.text()).toContain('Pick project Z'); + expect(output.text()).toContain('No results found.'); + }); + it('cancels data table prompts with the current selected row', async () => { const output = createMemoryOutput(); From aca2dbbd16ba3b4a463d0d6e1b699adcf3ffe50e Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 22:52:28 +0800 Subject: [PATCH 198/675] Render data table viewing info --- UPSTREAM.md | 1 + packages/tui/src/output/data-table/render.ts | 6 +++ packages/tui/tests/table.test.ts | 45 ++++++++++++++++++++ 3 files changed, 52 insertions(+) diff --git a/UPSTREAM.md b/UPSTREAM.md index cebc67f4..909e9959 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -52,6 +52,7 @@ TypeScript and OpenTUI internals. | 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/data-table.ts` | | Data table no-results search rendering | `packages/tui/src/output/data-table/render.ts`, `packages/tui/tests/table.test.ts` | +| Data table viewing-info rendering | `packages/tui/src/output/data-table/render.ts`, `packages/tui/tests/table.test.ts` | | Boxed balanced grid rendering | `packages/tui/src/output/grid.ts`, `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` | diff --git a/packages/tui/src/output/data-table/render.ts b/packages/tui/src/output/data-table/render.ts index c805426c..4bb47a81 100644 --- a/packages/tui/src/output/data-table/render.ts +++ b/packages/tui/src/output/data-table/render.ts @@ -36,5 +36,11 @@ export const renderDataTableFrame = (options: RenderDataTableFrameOptions) environment.output.write(`${renderTable(['', ...options.headers], renderedRows)}\n`); + if (window.end - window.start < options.rows.length) { + const suffix = options.query.length > 0 ? ' results' : ''; + + environment.output.write(` Viewing ${window.start + 1}-${window.end} of ${options.rows.length}${suffix}\n`); + } + return selected; }; diff --git a/packages/tui/tests/table.test.ts b/packages/tui/tests/table.test.ts index b5f99263..95dc4802 100644 --- a/packages/tui/tests/table.test.ts +++ b/packages/tui/tests/table.test.ts @@ -440,6 +440,51 @@ describe('data table prompt', () => { expect(latestFrame).toContain('Second'); expect(latestFrame).toContain('Third'); expect(latestFrame).toContain('Fourth'); + expect(latestFrame).toContain('Viewing 2-4 of 4'); + }); + + it('does not render data table viewing info when all rows are visible', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment( + { + input: createScriptedInput([Key.enter]), + output, + error: output, + interactive: true, + }, + () => + datatable({ + message: 'Pick row', + headers: ['Name'], + rows: [['First'], ['Second']], + scroll: 5, + }), + ); + + expect(output.text()).not.toContain('Viewing'); + }); + + it('renders data table viewing info for filtered result windows', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment( + { + input: createScriptedInput(['/', 'a', Key.pageDown, Key.enter, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + datatable({ + message: 'Pick row', + headers: ['Name'], + rows: [['Alpha'], ['Atlas'], ['Beta'], ['Gamma'], ['Delta']], + scroll: 2, + }), + ); + + expect(output.text()).toContain('Viewing 3-4 of 5 results'); }); it('supports page, home, and end data table navigation keys', async () => { From 5f6c56b26a69a13f32908d08444943fad7f84289 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 22:55:40 +0800 Subject: [PATCH 199/675] Render data table final frames --- UPSTREAM.md | 1 + packages/tui/src/output/data-table/read.ts | 6 ++-- packages/tui/src/output/data-table/render.ts | 26 ++++++++++++++++ packages/tui/tests/table.test.ts | 32 ++++++++++++++++++-- 4 files changed, 60 insertions(+), 5 deletions(-) diff --git a/UPSTREAM.md b/UPSTREAM.md index 909e9959..1c60a07c 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -53,6 +53,7 @@ TypeScript and OpenTUI internals. | Tables, data tables, and grids | `packages/tui/src/output/table.ts`, `packages/tui/src/output/data-table.ts` | | Data table no-results search rendering | `packages/tui/src/output/data-table/render.ts`, `packages/tui/tests/table.test.ts` | | Data table viewing-info rendering | `packages/tui/src/output/data-table/render.ts`, `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/read.ts`, `packages/tui/tests/table.test.ts` | | Boxed balanced grid rendering | `packages/tui/src/output/grid.ts`, `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` | diff --git a/packages/tui/src/output/data-table/read.ts b/packages/tui/src/output/data-table/read.ts index 5e75ea11..1db08737 100644 --- a/packages/tui/src/output/data-table/read.ts +++ b/packages/tui/src/output/data-table/read.ts @@ -3,7 +3,7 @@ import { Key } from '#tui/key'; import { cancelPrompt, PromptValidationError } from '#tui/prompt'; import { dataTableNavigationAction, startsDataTableSearch } from '#tui/output/data-table/keys'; import { moveDataTableSelection } from '#tui/output/data-table/navigation'; -import { renderDataTableFrame } from '#tui/output/data-table/render'; +import { renderCancelledDataTableFrame, renderDataTableFrame, renderSubmittedDataTableFrame } from '#tui/output/data-table/render'; import { dataTableRowValue, visibleDataTableRows } from '#tui/output/data-table/rows'; import { applyDataTableSearchKey, initialDataTableSearchState, startDataTableSearch } from '#tui/output/data-table/search'; import type { DataTableSearchState } from '#tui/output/data-table/search'; @@ -58,7 +58,7 @@ export const readDataTableSelection = async (options: DataTablePromptOptions< const rows = visibleRows(); if (key === Key.ctrlC) { - environment.error.write('Cancelled.\n'); + renderCancelledDataTableFrame(options.message, headers, rows, selected); return cancelPrompt(selectedDataTableValue(rows, selected)); } @@ -88,6 +88,8 @@ export const readDataTableSelection = async (options: DataTablePromptOptions< } if (key === Key.enter) { + renderSubmittedDataTableFrame(options.message, headers, rows, selected); + return selectedDataTableValue(rows, selected); } } diff --git a/packages/tui/src/output/data-table/render.ts b/packages/tui/src/output/data-table/render.ts index 4bb47a81..bbf89adc 100644 --- a/packages/tui/src/output/data-table/render.ts +++ b/packages/tui/src/output/data-table/render.ts @@ -2,6 +2,7 @@ import { promptEnvironment } from '#tui/environment'; import { renderTable } from '#tui/theme'; import { dataTableRowCells } from '#tui/output/data-table/rows'; import { clampDataTableSelection, dataTableRowWindow } from '#tui/output/data-table/selection'; +import { dim, red, strikethrough } from '#tui/theme/styles'; import type { VisibleDataTableRow } from '#tui/output/data-table/types'; type RenderDataTableFrameOptions = { @@ -44,3 +45,28 @@ export const renderDataTableFrame = (options: RenderDataTableFrameOptions) return selected; }; + +export const renderSubmittedDataTableFrame = (message: string, headers: string[], rows: Array>, selected: number): void => { + const row = rows[selected]; + + if (!row) { + return; + } + + const display = dataTableRowCells(headers, row.row).join(', '); + + promptEnvironment().output.write(`${message}\n${display}\n`); +}; + +export const renderCancelledDataTableFrame = (message: string, headers: string[], rows: Array>, selected: number): void => { + const outputRows = rows.map(({ row }, index) => { + return [index === selected ? '›' : ' ', ...dataTableRowCells(headers, row).map((cell) => dim(strikethrough(cell)))]; + }); + + const environment = promptEnvironment(); + + environment.output.write(`${message}\n`); + environment.output.write(`${dim('/ Search')}\n`); + environment.output.write(`${renderTable(['', ...headers], outputRows)}\n`); + environment.error.write(`${red('Cancelled.')}\n`); +}; diff --git a/packages/tui/tests/table.test.ts b/packages/tui/tests/table.test.ts index 95dc4802..50f11c8a 100644 --- a/packages/tui/tests/table.test.ts +++ b/packages/tui/tests/table.test.ts @@ -240,7 +240,7 @@ describe('data table prompt', () => { expect(result).toBe('beta'); - const latestFrame = output.text().split('Pick project\n').at(-1) ?? ''; + const latestFrame = output.text().split('Pick project\n').at(-2) ?? ''; expect(latestFrame).not.toContain('Pick project B'); expect(latestFrame).toContain('Alpha'); @@ -295,6 +295,7 @@ describe('data table prompt', () => { expect(result).toBe('beta'); expect(output.text()).toContain('Cancelled.'); + expect(output.text()).toContain('/ Search'); }); it('cancels data table search with the current filtered row', async () => { @@ -321,6 +322,31 @@ describe('data table prompt', () => { expect(output.text()).toContain('Cancelled.'); }); + it('renders a submitted data table row summary', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + datatable({ + message: 'Pick project', + headers: ['Name', 'Role'], + rows: [ + ['Alice', 'Designer'], + ['Bob', 'Developer'], + ], + }), + ); + + expect(result).toBe(1); + expect(output.text()).toContain('Bob, Developer'); + }); + it('edits Unicode data table search queries without corrupting input', async () => { const output = createMemoryOutput(); @@ -433,7 +459,7 @@ describe('data table prompt', () => { }), ); - const latestFrame = output.text().split('Pick row\n').at(-1) ?? ''; + const latestFrame = output.text().split('Pick row\n').at(-2) ?? ''; expect(result).toBe(2); expect(latestFrame).not.toContain('First'); @@ -645,7 +671,7 @@ describe('data table prompt', () => { }), ); - const latestFrame = output.text().split('Pick row\n').at(-1) ?? ''; + const latestFrame = output.text().split('Pick row\n').at(-2) ?? ''; expect(result).toBe(2); expect(latestFrame).not.toContain('First'); From 93e7304ac6a5cfd485c01e97555b21d886fd509d Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 23:01:40 +0800 Subject: [PATCH 200/675] Render multiline data table cells --- UPSTREAM.md | 1 + .../tui/src/output/data-table/multiline.ts | 18 ++++++++++++ packages/tui/src/output/data-table/render.ts | 17 +++++++++-- packages/tui/tests/table.test.ts | 29 +++++++++++++++++++ 4 files changed, 62 insertions(+), 3 deletions(-) create mode 100644 packages/tui/src/output/data-table/multiline.ts diff --git a/UPSTREAM.md b/UPSTREAM.md index 1c60a07c..1007bb1c 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -54,6 +54,7 @@ TypeScript and OpenTUI internals. | Data table no-results search rendering | `packages/tui/src/output/data-table/render.ts`, `packages/tui/tests/table.test.ts` | | Data table viewing-info rendering | `packages/tui/src/output/data-table/render.ts`, `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/read.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/tests/table.test.ts` | | Boxed balanced grid rendering | `packages/tui/src/output/grid.ts`, `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` | diff --git a/packages/tui/src/output/data-table/multiline.ts b/packages/tui/src/output/data-table/multiline.ts new file mode 100644 index 00000000..059c3d64 --- /dev/null +++ b/packages/tui/src/output/data-table/multiline.ts @@ -0,0 +1,18 @@ +export const splitMultilineDataTableCell = (cell: string): string[] => cell.split('\n'); + +export const expandMultilineDataTableRows = (rows: string[][]): string[][] => { + return rows.flatMap((row) => { + const columns = row.map(splitMultilineDataTableCell); + const height = Math.max(1, ...columns.map((lines) => lines.length)); + + return Array.from({ length: height }, (_, lineIndex) => { + return columns.map((lines, columnIndex) => { + if (columnIndex === 0 && lineIndex > 0) { + return ''; + } + + return lines[lineIndex] ?? ''; + }); + }); + }); +}; diff --git a/packages/tui/src/output/data-table/render.ts b/packages/tui/src/output/data-table/render.ts index bbf89adc..4cf0755f 100644 --- a/packages/tui/src/output/data-table/render.ts +++ b/packages/tui/src/output/data-table/render.ts @@ -1,5 +1,6 @@ import { promptEnvironment } from '#tui/environment'; import { renderTable } from '#tui/theme'; +import { expandMultilineDataTableRows } from '#tui/output/data-table/multiline'; import { dataTableRowCells } from '#tui/output/data-table/rows'; import { clampDataTableSelection, dataTableRowWindow } from '#tui/output/data-table/selection'; import { dim, red, strikethrough } from '#tui/theme/styles'; @@ -35,7 +36,7 @@ export const renderDataTableFrame = (options: RenderDataTableFrameOptions) return [index === selected ? '›' : ' ', ...dataTableRowCells(options.headers, row)]; }); - environment.output.write(`${renderTable(['', ...options.headers], renderedRows)}\n`); + environment.output.write(`${renderTable(['', ...options.headers], expandMultilineDataTableRows(renderedRows))}\n`); if (window.end - window.start < options.rows.length) { const suffix = options.query.length > 0 ? ' results' : ''; @@ -59,8 +60,18 @@ export const renderSubmittedDataTableFrame = (message: string, headers: strin }; export const renderCancelledDataTableFrame = (message: string, headers: string[], rows: Array>, selected: number): void => { - const outputRows = rows.map(({ row }, index) => { - return [index === selected ? '›' : ' ', ...dataTableRowCells(headers, row).map((cell) => dim(strikethrough(cell)))]; + const outputRows = 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)) : ''; + }); + }); }); const environment = promptEnvironment(); diff --git a/packages/tui/tests/table.test.ts b/packages/tui/tests/table.test.ts index 50f11c8a..442fa438 100644 --- a/packages/tui/tests/table.test.ts +++ b/packages/tui/tests/table.test.ts @@ -347,6 +347,35 @@ describe('data table prompt', () => { expect(output.text()).toContain('Bob, Developer'); }); + it('renders multiline data table cells as visual table rows', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.enter]), + output, + error: output, + interactive: true, + }, + () => + datatable({ + message: 'Pick row', + headers: ['Name', 'Role'], + rows: [ + ['Alice', 'Lead\nDeveloper'], + ['Bob', 'Designer'], + ], + }), + ); + + const activeFrame = output.text().split('Pick row\n').at(-2) ?? ''; + + expect(result).toBe(0); + expect(activeFrame).toContain('| › | Alice | Lead |'); + expect(activeFrame).toContain('| | | Developer |'); + expect(activeFrame).toContain('| | Bob | Designer |'); + }); + it('edits Unicode data table search queries without corrupting input', async () => { const output = createMemoryOutput(); From c57100ebc388b86bdbff5924b43ba0895edd8646 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 23:04:35 +0800 Subject: [PATCH 201/675] Render data table scrollbars --- UPSTREAM.md | 1 + packages/tui/src/output/data-table/render.ts | 3 ++- packages/tui/src/output/data-table/scrollbar.ts | 11 +++++++++++ packages/tui/tests/table.test.ts | 2 ++ 4 files changed, 16 insertions(+), 1 deletion(-) create mode 100644 packages/tui/src/output/data-table/scrollbar.ts diff --git a/UPSTREAM.md b/UPSTREAM.md index 1007bb1c..88fffe0c 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -55,6 +55,7 @@ TypeScript and OpenTUI internals. | Data table viewing-info rendering | `packages/tui/src/output/data-table/render.ts`, `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/read.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/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/tests/table.test.ts` | | Boxed balanced grid rendering | `packages/tui/src/output/grid.ts`, `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` | diff --git a/packages/tui/src/output/data-table/render.ts b/packages/tui/src/output/data-table/render.ts index 4cf0755f..b45461cc 100644 --- a/packages/tui/src/output/data-table/render.ts +++ b/packages/tui/src/output/data-table/render.ts @@ -2,6 +2,7 @@ import { promptEnvironment } from '#tui/environment'; import { renderTable } from '#tui/theme'; import { expandMultilineDataTableRows } from '#tui/output/data-table/multiline'; import { dataTableRowCells } from '#tui/output/data-table/rows'; +import { renderScrollableDataTable } from '#tui/output/data-table/scrollbar'; import { clampDataTableSelection, dataTableRowWindow } from '#tui/output/data-table/selection'; import { dim, red, strikethrough } from '#tui/theme/styles'; import type { VisibleDataTableRow } from '#tui/output/data-table/types'; @@ -36,7 +37,7 @@ export const renderDataTableFrame = (options: RenderDataTableFrameOptions) return [index === selected ? '›' : ' ', ...dataTableRowCells(options.headers, row)]; }); - environment.output.write(`${renderTable(['', ...options.headers], expandMultilineDataTableRows(renderedRows))}\n`); + environment.output.write(`${renderScrollableDataTable(['', ...options.headers], expandMultilineDataTableRows(renderedRows), window.start, window.end - window.start, options.rows.length)}\n`); if (window.end - window.start < options.rows.length) { const suffix = options.query.length > 0 ? ' results' : ''; diff --git a/packages/tui/src/output/data-table/scrollbar.ts b/packages/tui/src/output/data-table/scrollbar.ts new file mode 100644 index 00000000..b0b51b4b --- /dev/null +++ b/packages/tui/src/output/data-table/scrollbar.ts @@ -0,0 +1,11 @@ +import { renderScrollbarRows } from '#tui/concerns/scrollbar'; +import { renderTable } from '#tui/theme'; + +export const renderScrollableDataTable = (headers: string[], rows: string[][], firstVisible: number, visibleRows: number, totalRows: number): string => { + const lines = renderTable(headers, rows).split('\n'); + const headerLineCount = headers.length > 0 ? 2 : 0; + const head = lines.slice(0, headerLineCount); + const body = lines.slice(headerLineCount); + + return [...head, ...renderScrollbarRows(body, firstVisible, visibleRows, totalRows)].join('\n'); +}; diff --git a/packages/tui/tests/table.test.ts b/packages/tui/tests/table.test.ts index 442fa438..f8918c4c 100644 --- a/packages/tui/tests/table.test.ts +++ b/packages/tui/tests/table.test.ts @@ -496,6 +496,7 @@ describe('data table prompt', () => { expect(latestFrame).toContain('Third'); expect(latestFrame).toContain('Fourth'); expect(latestFrame).toContain('Viewing 2-4 of 4'); + expect(latestFrame).toContain('┃'); }); it('does not render data table viewing info when all rows are visible', async () => { @@ -518,6 +519,7 @@ describe('data table prompt', () => { ); expect(output.text()).not.toContain('Viewing'); + expect(output.text()).not.toContain('┃'); }); it('renders data table viewing info for filtered result windows', async () => { From 10c51886df229dd5d93da1a811925be442955091 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 23:07:06 +0800 Subject: [PATCH 202/675] Render data tables without headers --- UPSTREAM.md | 1 + packages/tui/src/output/data-table/render.ts | 8 ++- packages/tui/tests/table.test.ts | 58 ++++++++++++++++++++ 3 files changed, 65 insertions(+), 2 deletions(-) diff --git a/UPSTREAM.md b/UPSTREAM.md index 88fffe0c..2746f8dd 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -56,6 +56,7 @@ TypeScript and OpenTUI internals. | Data table submitted and cancelled final frames | `packages/tui/src/output/data-table/render.ts`, `packages/tui/src/output/data-table/read.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/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/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/rows.ts`, `packages/tui/tests/table.test.ts` | | Boxed balanced grid rendering | `packages/tui/src/output/grid.ts`, `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` | diff --git a/packages/tui/src/output/data-table/render.ts b/packages/tui/src/output/data-table/render.ts index b45461cc..ca817990 100644 --- a/packages/tui/src/output/data-table/render.ts +++ b/packages/tui/src/output/data-table/render.ts @@ -17,6 +17,8 @@ type RenderDataTableFrameOptions = { selected: number; }; +const renderHeaders = (headers: string[]): string[] => (headers.length > 0 ? ['', ...headers] : []); + export const renderDataTableFrame = (options: RenderDataTableFrameOptions): number => { const environment = promptEnvironment(); const selected = clampDataTableSelection(options.selected, options.rows); @@ -37,7 +39,9 @@ export const renderDataTableFrame = (options: RenderDataTableFrameOptions) return [index === selected ? '›' : ' ', ...dataTableRowCells(options.headers, row)]; }); - environment.output.write(`${renderScrollableDataTable(['', ...options.headers], expandMultilineDataTableRows(renderedRows), window.start, window.end - window.start, options.rows.length)}\n`); + environment.output.write( + `${renderScrollableDataTable(renderHeaders(options.headers), expandMultilineDataTableRows(renderedRows), window.start, window.end - window.start, options.rows.length)}\n`, + ); if (window.end - window.start < options.rows.length) { const suffix = options.query.length > 0 ? ' results' : ''; @@ -79,6 +83,6 @@ export const renderCancelledDataTableFrame = (message: string, headers: strin environment.output.write(`${message}\n`); environment.output.write(`${dim('/ Search')}\n`); - environment.output.write(`${renderTable(['', ...headers], outputRows)}\n`); + environment.output.write(`${renderTable(renderHeaders(headers), outputRows)}\n`); environment.error.write(`${red('Cancelled.')}\n`); }; diff --git a/packages/tui/tests/table.test.ts b/packages/tui/tests/table.test.ts index f8918c4c..1698bcc4 100644 --- a/packages/tui/tests/table.test.ts +++ b/packages/tui/tests/table.test.ts @@ -376,6 +376,64 @@ describe('data table prompt', () => { expect(activeFrame).toContain('| | Bob | Designer |'); }); + it('renders data table rows without headers', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.enter]), + output, + error: output, + interactive: true, + }, + () => + datatable({ + message: 'Pick row', + headers: [], + rows: [ + ['Alice', 'Developer'], + ['Bob', 'Designer'], + ], + }), + ); + + const activeFrame = output.text().split('Pick row\n').at(-2) ?? ''; + + expect(result).toBe(0); + expect(activeFrame).not.toContain('---'); + expect(activeFrame).toContain('| › | Alice | Developer |'); + expect(activeFrame).toContain('| | Bob | Designer |'); + }); + + it('renders blank data table cells as empty columns', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.enter]), + output, + error: output, + interactive: true, + }, + () => + datatable({ + message: 'Pick row', + headers: ['Name', 'Role'], + rows: [ + ['Alice', null], + ['Bob', undefined], + ], + }), + ); + + const activeFrame = output.text().split('Pick row\n').at(-2) ?? ''; + + expect(result).toBe(0); + expect(activeFrame).toContain('| Name | Role |'); + expect(activeFrame).toMatch(/\| › \| Alice \|\s+\|/); + expect(activeFrame).toMatch(/\| \| Bob\s+\|\s+\|/); + }); + it('edits Unicode data table search queries without corrupting input', async () => { const output = createMemoryOutput(); From bd3a2726e5b6e5fec42d9b4d7f540f130f330ae0 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 23:10:05 +0800 Subject: [PATCH 203/675] Maintain data table visual height --- UPSTREAM.md | 1 + packages/tui/src/output/data-table/render.ts | 7 ++- .../src/output/data-table/visual-window.ts | 18 ++++++ packages/tui/tests/table.test.ts | 58 +++++++++++++++++++ 4 files changed, 81 insertions(+), 3 deletions(-) create mode 100644 packages/tui/src/output/data-table/visual-window.ts diff --git a/UPSTREAM.md b/UPSTREAM.md index 2746f8dd..7ea15b17 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -57,6 +57,7 @@ TypeScript and OpenTUI internals. | Data table multiline cell rendering | `packages/tui/src/output/data-table/multiline.ts`, `packages/tui/src/output/data-table/render.ts`, `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/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/rows.ts`, `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/tests/table.test.ts` | | Boxed balanced grid rendering | `packages/tui/src/output/grid.ts`, `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` | diff --git a/packages/tui/src/output/data-table/render.ts b/packages/tui/src/output/data-table/render.ts index ca817990..a56b9389 100644 --- a/packages/tui/src/output/data-table/render.ts +++ b/packages/tui/src/output/data-table/render.ts @@ -4,6 +4,7 @@ import { expandMultilineDataTableRows } from '#tui/output/data-table/multiline'; import { dataTableRowCells } from '#tui/output/data-table/rows'; import { renderScrollableDataTable } from '#tui/output/data-table/scrollbar'; import { clampDataTableSelection, dataTableRowWindow } from '#tui/output/data-table/selection'; +import { fixedVisualDataTableRows } from '#tui/output/data-table/visual-window'; import { dim, red, strikethrough } from '#tui/theme/styles'; import type { VisibleDataTableRow } from '#tui/output/data-table/types'; @@ -39,9 +40,9 @@ export const renderDataTableFrame = (options: RenderDataTableFrameOptions) return [index === selected ? '›' : ' ', ...dataTableRowCells(options.headers, row)]; }); - environment.output.write( - `${renderScrollableDataTable(renderHeaders(options.headers), expandMultilineDataTableRows(renderedRows), window.start, window.end - window.start, options.rows.length)}\n`, - ); + const visualRows = fixedVisualDataTableRows(expandMultilineDataTableRows(renderedRows), options.scroll); + + environment.output.write(`${renderScrollableDataTable(renderHeaders(options.headers), visualRows, window.start, window.end - window.start, options.rows.length)}\n`); if (window.end - window.start < options.rows.length) { const suffix = options.query.length > 0 ? ' results' : ''; diff --git a/packages/tui/src/output/data-table/visual-window.ts b/packages/tui/src/output/data-table/visual-window.ts new file mode 100644 index 00000000..3787a519 --- /dev/null +++ b/packages/tui/src/output/data-table/visual-window.ts @@ -0,0 +1,18 @@ +import { parseOptionalScrollSize } from '#tui/concerns/validators/scroll'; + +export const fixedVisualDataTableRows = (rows: string[][], scroll?: number): string[][] => { + const height = parseOptionalScrollSize(scroll); + + if (height === undefined || rows.length >= height) { + return rows; + } + + const columnCount = Math.max(0, ...rows.map((row) => row.length)); + const paddedRows = [...rows]; + + while (paddedRows.length < height) { + paddedRows.push(Array.from({ length: columnCount }, () => '')); + } + + return paddedRows; +}; diff --git a/packages/tui/tests/table.test.ts b/packages/tui/tests/table.test.ts index 1698bcc4..97e55ce4 100644 --- a/packages/tui/tests/table.test.ts +++ b/packages/tui/tests/table.test.ts @@ -376,6 +376,64 @@ describe('data table prompt', () => { expect(activeFrame).toContain('| | Bob | Designer |'); }); + it('keeps highlighted multiline data table rows fully visible', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + datatable({ + message: 'Pick row', + headers: ['Name', 'Role'], + rows: [ + ['Alice', 'Designer'], + ['Bob', 'CEO\nCTO\nDeveloper'], + ['Charlie', 'Designer'], + ], + scroll: 5, + }), + ); + + const activeFrame = output.text().split('Pick row\n').at(-2) ?? ''; + + expect(result).toBe(1); + expect(activeFrame).toContain('CEO'); + expect(activeFrame).toContain('CTO'); + expect(activeFrame).toContain('Developer'); + }); + + it('maintains fixed data table visual height', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.enter]), + output, + error: output, + interactive: true, + }, + () => + datatable({ + message: 'Pick row', + headers: ['Name'], + rows: [['Alice'], ['Bob']], + scroll: 5, + }), + ); + + const activeFrame = output.text().split('Pick row\n').at(-2) ?? ''; + const tableLines = activeFrame.split('\n').filter((line) => line.startsWith('|')); + + expect(result).toBe(0); + expect(tableLines).toHaveLength(7); + expect(tableLines.slice(2)).toHaveLength(5); + }); + it('renders data table rows without headers', async () => { const output = createMemoryOutput(); From 068fa7178cb80f4853252601ba487cd92d1e1eb3 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 23:16:51 +0800 Subject: [PATCH 204/675] Constrain data table column widths --- UPSTREAM.md | 1 + packages/tui/src/output/data-table/read.ts | 1 + packages/tui/src/output/data-table/render.ts | 13 ++- packages/tui/src/output/data-table/widths.ts | 85 ++++++++++++++++++++ packages/tui/tests/table.test.ts | 62 +++++++++++++- 5 files changed, 158 insertions(+), 4 deletions(-) create mode 100644 packages/tui/src/output/data-table/widths.ts diff --git a/UPSTREAM.md b/UPSTREAM.md index 7ea15b17..ec3f2de0 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -58,6 +58,7 @@ TypeScript and OpenTUI internals. | Data table scrollbar rendering | `packages/tui/src/output/data-table/scrollbar.ts`, `packages/tui/src/output/data-table/render.ts`, `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/rows.ts`, `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/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/render.ts`, `packages/tui/tests/table.test.ts` | | Boxed balanced grid rendering | `packages/tui/src/output/grid.ts`, `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` | diff --git a/packages/tui/src/output/data-table/read.ts b/packages/tui/src/output/data-table/read.ts index 1db08737..0e4307fa 100644 --- a/packages/tui/src/output/data-table/read.ts +++ b/packages/tui/src/output/data-table/read.ts @@ -32,6 +32,7 @@ export const readDataTableSelection = async (options: DataTablePromptOptions< const render = (): void => { selected = renderDataTableFrame({ + allRows: options.rows, headers, message: options.message, mode: search.mode, diff --git a/packages/tui/src/output/data-table/render.ts b/packages/tui/src/output/data-table/render.ts index a56b9389..570047bd 100644 --- a/packages/tui/src/output/data-table/render.ts +++ b/packages/tui/src/output/data-table/render.ts @@ -5,10 +5,13 @@ import { dataTableRowCells } from '#tui/output/data-table/rows'; import { renderScrollableDataTable } from '#tui/output/data-table/scrollbar'; import { clampDataTableSelection, 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 { dim, red, strikethrough } from '#tui/theme/styles'; +import type { DataTableRow } from '#tui/types'; import type { VisibleDataTableRow } from '#tui/output/data-table/types'; type RenderDataTableFrameOptions = { + allRows: Array>; headers: string[]; message: string; mode: 'browse' | 'search'; @@ -34,15 +37,19 @@ export const renderDataTableFrame = (options: RenderDataTableFrameOptions) return selected; } - const renderedRows = options.rows.slice(window.start, window.end).map(({ row }, offset) => { + 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 renderedRows = fitted.rows.map((row, offset) => { const index = window.start + offset; - return [index === selected ? '›' : ' ', ...dataTableRowCells(options.headers, row)]; + return [index === selected ? '›' : ' ', ...row]; }); const visualRows = fixedVisualDataTableRows(expandMultilineDataTableRows(renderedRows), options.scroll); - environment.output.write(`${renderScrollableDataTable(renderHeaders(options.headers), visualRows, window.start, window.end - window.start, options.rows.length)}\n`); + environment.output.write(`${renderScrollableDataTable(renderHeaders(fitted.headers), visualRows, window.start, window.end - window.start, options.rows.length)}\n`); if (window.end - window.start < options.rows.length) { const suffix = options.query.length > 0 ? ' results' : ''; diff --git a/packages/tui/src/output/data-table/widths.ts b/packages/tui/src/output/data-table/widths.ts new file mode 100644 index 00000000..c71fa99b --- /dev/null +++ b/packages/tui/src/output/data-table/widths.ts @@ -0,0 +1,85 @@ +import { terminalSize } from '#tui/terminal'; +import { truncate, visibleWidth } from '#tui/strings'; + +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, maxWidth); + + return { + 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 => + cell + .split('\n') + .map((line) => truncate(line, width)) + .join('\n'); + +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, naturalColumnWidth(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; + } + + const shrunk = natural.map((width, index) => Math.max(headerWidths[index] ?? 0, Math.floor((available * width) / totalNatural))); + + let remaining = available - shrunk.reduce((sum, width) => sum + width, 0); + + for (const index of natural + .map((width, index) => ({ index, width })) + .sort((left, right) => right.width - left.width) + .map(({ index }) => index)) { + if (remaining <= 0) { + break; + } + + shrunk[index] += 1; + remaining -= 1; + } + + return shrunk; +}; + +const naturalColumnWidth = (rows: string[][], column: number): number => { + const widths = rows + .map((row) => row[column] ?? '') + .flatMap((cell) => cell.split('\n').map(visibleWidth)) + .filter((width) => width > 0) + .sort((left, right) => left - right); + + if (widths.length === 0) { + return 0; + } + + const percentile = widths[Math.max(0, Math.ceil(widths.length * 0.9) - 1)] ?? 0; + const maximum = widths.at(-1) ?? 0; + + return maximum <= percentile * 2 ? maximum : percentile; +}; + +const availableDataTableCellWidth = (columnCount: number, maxWidth: number): number => { + const markerWidth = 1; + const renderedColumnCount = columnCount + 1; + const tableOverhead = renderedColumnCount * 3 + 1; + const scrollbarArea = 2; + const frameInset = 6; + + return maxWidth - markerWidth - tableOverhead - scrollbarArea - frameInset; +}; diff --git a/packages/tui/tests/table.test.ts b/packages/tui/tests/table.test.ts index 97e55ce4..2ddb249f 100644 --- a/packages/tui/tests/table.test.ts +++ b/packages/tui/tests/table.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { createMemoryOutput, createScriptedInput, datatable, form, Key, table, withPromptEnvironment } from '#tui/index'; +import { createMemoryOutput, createScriptedInput, datatable, form, Key, table, visibleWidth, withPromptEnvironment } from '#tui/index'; describe('table output', () => { it('renders static table rows', async () => { @@ -434,6 +434,66 @@ describe('data table prompt', () => { expect(tableLines.slice(2)).toHaveLength(5); }); + it('uses comfortable data table width for compact rows', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.enter]), + output, + error: output, + interactive: true, + }, + () => + datatable({ + message: 'Pick row', + headers: ['A', 'B'], + rows: [['Hi', 'Lo']], + scroll: 5, + }), + ); + + const activeFrame = output.text().split('Pick row\n').at(-2) ?? ''; + const widestLine = Math.max(...activeFrame.split('\n').map(visibleWidth)); + + expect(result).toBe(0); + expect(widestLine).toBeLessThan(70); + }); + + it('truncates outlier data table columns before they stretch the table', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.end[0], Key.enter]), + output, + error: output, + interactive: true, + }, + () => + datatable({ + message: 'Pick row', + headers: ['Name', 'Value'], + rows: [ + ['Alice', 'Short'], + ['Bob', 'Short'], + ['Charlie', 'Short'], + ['Diana', 'Short'], + ['Ethan', 'Short'], + ['An extremely long value that should be treated as an outlier and truncated', 'Short'], + ], + scroll: 5, + }), + ); + + const activeFrame = output.text().split('Pick row\n').at(-2) ?? ''; + const widestLine = Math.max(...activeFrame.split('\n').map(visibleWidth)); + + expect(result).toBe(5); + expect(widestLine).toBeLessThan(76); + expect(activeFrame).toContain('...'); + }); + it('renders data table rows without headers', async () => { const output = createMemoryOutput(); From 3f552c0f943b378811c6d87801128f022dc20fc3 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 23:19:58 +0800 Subject: [PATCH 205/675] Reuse confirm defaults when reverting forms --- UPSTREAM.md | 1 + packages/tui/src/form/builder/previous.ts | 6 +++++- .../tui/src/form/builder/prompts/choices.ts | 4 ++-- .../tui/src/form/builder/validators/previous.ts | 7 +++++++ packages/tui/tests/form.test.ts | 17 +++++++++++++++++ 5 files changed, 32 insertions(+), 3 deletions(-) diff --git a/UPSTREAM.md b/UPSTREAM.md index ec3f2de0..99d1de1a 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -63,6 +63,7 @@ TypeScript and OpenTUI internals. | 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/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` | | 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/tests/output.test.ts`, `packages/tui/tests/form.test.ts` | | Progress boxed frame rendering and formatted fractions | `packages/tui/src/status/progress/render.ts`, `packages/tui/src/theme/box.ts` | | Spinner static and deterministic animated frame rendering | `packages/tui/src/status/spinner.ts`, `packages/tui/src/status/spinner/render.ts` | diff --git a/packages/tui/src/form/builder/previous.ts b/packages/tui/src/form/builder/previous.ts index 62e38705..ca45bd1d 100644 --- a/packages/tui/src/form/builder/previous.ts +++ b/packages/tui/src/form/builder/previous.ts @@ -1,4 +1,4 @@ -import { parsePreviousArray, parsePreviousNumber, parsePreviousString } from '#tui/form/builder/validators/previous'; +import { parsePreviousArray, parsePreviousBoolean, parsePreviousNumber, parsePreviousString } from '#tui/form/builder/validators/previous'; export const previousString = (previous: unknown, defaultValue: string): string => { return parsePreviousString(previous, defaultValue); @@ -11,3 +11,7 @@ export const previousNumber = (previous: unknown, defaultValue: number | string) export const previousArray = (previous: unknown, defaultValue: T[]): T[] => { return parsePreviousArray(previous, defaultValue); }; + +export const previousBoolean = (previous: unknown, defaultValue: boolean): boolean => { + return parsePreviousBoolean(previous, defaultValue); +}; diff --git a/packages/tui/src/form/builder/prompts/choices.ts b/packages/tui/src/form/builder/prompts/choices.ts index 490f3a6e..2ad75121 100644 --- a/packages/tui/src/form/builder/prompts/choices.ts +++ b/packages/tui/src/form/builder/prompts/choices.ts @@ -1,5 +1,5 @@ import { autocomplete, confirm, multiselect, multisearch, pause, search, select, suggest } from '#tui/prompts/choices'; -import { previousArray, previousString } from '#tui/form/builder/previous'; +import { previousArray, previousBoolean, previousString } from '#tui/form/builder/previous'; import { isSearchPromptLabel } from '#tui/form/builder/prompts/validators/search'; import type { FormBuilder } from '#tui/form/builder/index'; import type { ChoicePromptBuilderMethods } from '#tui/form/builder/prompts/types'; @@ -10,7 +10,7 @@ export const choicePromptBuilderMethods: ChoicePromptBuilderMethods & ThisType autocomplete({ message: label, label, options, default: previousString(previous, defaultValue), required, validate, hint, transform }), name); }, confirm(label, defaultValue = true, yes = 'Yes', no = 'No', required = false, validate = undefined, hint = '', name, transform = undefined) { - return this.add(() => confirm(label, defaultValue, yes, no, required, validate, hint, transform), name); + return this.add((_, previous) => confirm(label, previousBoolean(previous, defaultValue), yes, no, required, validate, hint, transform), name); }, multisearch( optionsOrLabel: MultiSearchPromptOptions | string, diff --git a/packages/tui/src/form/builder/validators/previous.ts b/packages/tui/src/form/builder/validators/previous.ts index f062c81b..abef437b 100644 --- a/packages/tui/src/form/builder/validators/previous.ts +++ b/packages/tui/src/form/builder/validators/previous.ts @@ -2,6 +2,7 @@ import { z } from 'zod'; const previousNumberSchema = z.union([z.number(), z.string()]); const previousArraySchema = z.array(z.unknown()); +const previousBooleanSchema = z.boolean(); export const parsePreviousString = (previous: unknown, defaultValue: string): string => { return previous === undefined || previous === null ? defaultValue : String(previous); @@ -18,3 +19,9 @@ export const parsePreviousArray = (previous: unknown, defaultValue: T[]): T[] return parsed.success ? (parsed.data as T[]) : defaultValue; }; + +export const parsePreviousBoolean = (previous: unknown, defaultValue: boolean): boolean => { + const parsed = previousBooleanSchema.safeParse(previous); + + return parsed.success ? parsed.data : defaultValue; +}; diff --git a/packages/tui/tests/form.test.ts b/packages/tui/tests/form.test.ts index c7bc30a1..4e228df1 100644 --- a/packages/tui/tests/form.test.ts +++ b/packages/tui/tests/form.test.ts @@ -115,6 +115,23 @@ describe('form builder', () => { expect(responses[1]).toBe('D'); }); + it('reuses boolean previous responses when reverting confirm steps', async () => { + const output = createMemoryOutput(); + + const responses = await withPromptEnvironment( + { + input: createScriptedInput(['n', Key.enter, Key.ctrlU, Key.enter, 'D', Key.enter]), + output, + error: output, + interactive: true, + }, + () => form().confirm('Active?').text('Done').submit(), + ); + + expect(responses[0]).toBe(false); + expect(responses[1]).toBe('D'); + }); + it('reverts past ignored side-effect steps', async () => { const output = createMemoryOutput(); From 1b7546faa52b35e49da50ebdd2cfe4097f55b189 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 23:22:26 +0800 Subject: [PATCH 206/675] Align form suggest arguments --- UPSTREAM.md | 1 + .../tui/src/form/builder/prompts/choices.ts | 8 ++--- .../form/builder/prompts/contracts/choices.ts | 2 ++ packages/tui/tests/form.test.ts | 35 ++++++++++++++++++- 4 files changed, 41 insertions(+), 5 deletions(-) diff --git a/UPSTREAM.md b/UPSTREAM.md index 99d1de1a..74ac0e4f 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -64,6 +64,7 @@ TypeScript and OpenTUI internals. | 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/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 suggest and autocomplete placeholder/default arguments | `packages/tui/src/form/builder/prompts/choices.ts`, `packages/tui/src/form/builder/prompts/contracts/choices.ts`, `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/tests/output.test.ts`, `packages/tui/tests/form.test.ts` | | Progress boxed frame rendering and formatted fractions | `packages/tui/src/status/progress/render.ts`, `packages/tui/src/theme/box.ts` | | Spinner static and deterministic animated frame rendering | `packages/tui/src/status/spinner.ts`, `packages/tui/src/status/spinner/render.ts` | diff --git a/packages/tui/src/form/builder/prompts/choices.ts b/packages/tui/src/form/builder/prompts/choices.ts index 2ad75121..6024d10f 100644 --- a/packages/tui/src/form/builder/prompts/choices.ts +++ b/packages/tui/src/form/builder/prompts/choices.ts @@ -6,8 +6,8 @@ import type { ChoicePromptBuilderMethods } from '#tui/form/builder/prompts/types import type { ChoiceOptions, MultiSearchPromptOptions, SearchPromptOptions } from '#tui/types'; export const choicePromptBuilderMethods: ChoicePromptBuilderMethods & ThisType = { - autocomplete(label, options, defaultValue = '', required = false, validate = undefined, hint = '', name, transform = undefined) { - return this.add((_, previous) => autocomplete({ message: label, label, options, default: previousString(previous, defaultValue), required, validate, hint, transform }), name); + autocomplete(label, options, placeholder = '', defaultValue = '', required = false, validate = undefined, hint = '', name, transform = undefined) { + return this.add((_, previous) => autocomplete({ message: label, label, options, placeholder, default: previousString(previous, defaultValue), required, validate, hint, transform }), name); }, confirm(label, defaultValue = true, yes = 'Yes', no = 'No', required = false, validate = undefined, hint = '', name, transform = undefined) { return this.add((_, previous) => confirm(label, previousBoolean(previous, defaultValue), yes, no, required, validate, hint, transform), name); @@ -89,7 +89,7 @@ export const choicePromptBuilderMethods: ChoicePromptBuilderMethods & ThisType(label: string, options: ChoiceOptions, defaultValue?: T, scroll = 5, validate = undefined, hint = '', required: boolean | string = true, name?: string, transform = undefined) { return this.add((_, previous) => select({ message: label, options, default: previous === undefined ? defaultValue : (previous as T), scroll, validate, hint, required, transform }), name); }, - suggest(label, options, defaultValue = '', scroll = 5, required = false, validate = undefined, hint = '', name, transform = undefined) { - return this.add((_, previous) => suggest({ message: label, label, options, default: previousString(previous, defaultValue), scroll, required, validate, hint, transform }), name); + suggest(label, options, placeholder = '', defaultValue = '', scroll = 5, required = false, validate = undefined, hint = '', name, transform = undefined) { + return this.add((_, previous) => suggest({ message: label, label, options, placeholder, default: previousString(previous, defaultValue), scroll, required, validate, hint, transform }), name); }, }; diff --git a/packages/tui/src/form/builder/prompts/contracts/choices.ts b/packages/tui/src/form/builder/prompts/contracts/choices.ts index 38d269d5..9745f8ae 100644 --- a/packages/tui/src/form/builder/prompts/contracts/choices.ts +++ b/packages/tui/src/form/builder/prompts/contracts/choices.ts @@ -7,6 +7,7 @@ export type ChoicePromptBuilderMethods = { this: FormBuilder, label: string, options: SuggestOptions['options'], + placeholder?: string, defaultValue?: string, required?: boolean | string, validate?: TextPromptOptions['validate'], @@ -81,6 +82,7 @@ export type ChoicePromptBuilderMethods = { this: FormBuilder, label: string, options: SuggestOptions['options'], + placeholder?: string, defaultValue?: string, scroll?: number, required?: boolean | string, diff --git a/packages/tui/tests/form.test.ts b/packages/tui/tests/form.test.ts index 4e228df1..283f103b 100644 --- a/packages/tui/tests/form.test.ts +++ b/packages/tui/tests/form.test.ts @@ -179,7 +179,7 @@ describe('form builder', () => { }, () => form() - .suggest('Suggested color', ['Red', 'Green', 'Blue'], '', 5, false, undefined, '', 'suggested') + .suggest('Suggested color', ['Red', 'Green', 'Blue'], '', '', 5, false, undefined, '', 'suggested') .search( { message: 'Searched color', @@ -210,6 +210,39 @@ describe('form builder', () => { expect(responses.many).toEqual(['red']); }); + it('passes suggest placeholders and defaults from form steps', async () => { + const output = createMemoryOutput(); + + const responses = await withPromptEnvironment( + { + input: createScriptedInput([Key.enter]), + output, + error: output, + interactive: true, + }, + () => form().suggest('Suggested color', ['Red', 'Green', 'Blue'], 'Type a color', 'Blue', 5, false, undefined, '', 'suggested').submit(), + ); + + expect(responses.suggested).toBe('Blue'); + }); + + it('passes autocomplete placeholders from form steps', async () => { + const output = createMemoryOutput(); + + const responses = await withPromptEnvironment( + { + input: createScriptedInput([Key.enter]), + output, + error: output, + interactive: true, + }, + () => form().autocomplete('Auto color', ['Red', 'Green', 'Blue'], 'Type a color', '', false, undefined, '', 'auto').submit(), + ); + + expect(responses.auto).toBe(''); + expect(output.text()).toContain('Type a color'); + }); + it('runs label-first search and multisearch form steps', async () => { const output = createMemoryOutput(); From 4a6955062d99efed339e9091134bddf3ad413802 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 23:25:14 +0800 Subject: [PATCH 207/675] Use empty spinner default message --- UPSTREAM.md | 1 + packages/tui/src/form/builder/status.ts | 2 +- packages/tui/src/status/spinner.ts | 4 ++-- packages/tui/tests/form.test.ts | 5 +++-- packages/tui/tests/spinner.test.ts | 12 ++++++++++++ 5 files changed, 19 insertions(+), 5 deletions(-) diff --git a/UPSTREAM.md b/UPSTREAM.md index 74ac0e4f..a701b18f 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -68,6 +68,7 @@ TypeScript and OpenTUI internals. | 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/tests/output.test.ts`, `packages/tui/tests/form.test.ts` | | Progress boxed frame rendering and formatted fractions | `packages/tui/src/status/progress/render.ts`, `packages/tui/src/theme/box.ts` | | Spinner static and deterministic animated frame rendering | `packages/tui/src/status/spinner.ts`, `packages/tui/src/status/spinner/render.ts` | +| Spinner empty default message | `packages/tui/src/status/spinner.ts`, `packages/tui/src/form/builder/status.ts`, `packages/tui/tests/spinner.test.ts`, `packages/tui/tests/form.test.ts` | | Task static and deterministic animated frame rendering, bounded logs, and stable summary symbols | `packages/tui/src/status/task.ts`, `packages/tui/src/status/task/*` | | Task process output capture | `packages/tui/src/status/task/process-output.ts`, `packages/tui/tests/task.test.ts` | | Stream frame rendering, wrapping, fade styles, and close flushing | `packages/tui/src/status/stream.ts`, `packages/tui/src/status/stream/*` | diff --git a/packages/tui/src/form/builder/status.ts b/packages/tui/src/form/builder/status.ts index 33ffeac0..a976c1fb 100644 --- a/packages/tui/src/form/builder/status.ts +++ b/packages/tui/src/form/builder/status.ts @@ -15,7 +15,7 @@ export const statusBuilderMethods: StatusBuilderMethods & ThisType progress(label: string, steps: Iterable | number, callback?: (step: T | number, bar: Progress) => MaybePromise, hint = '', name?: string) { return this.add(() => progress(label, steps, callback, hint), name, true); }, - spin(callback: () => MaybePromise, message = 'Loading', name?: string) { + spin(callback: () => MaybePromise, message = '', name?: string) { return this.add(() => spin(callback, { message }), name, true); }, stream(source: AsyncIterable | Iterable, name?: string) { diff --git a/packages/tui/src/status/spinner.ts b/packages/tui/src/status/spinner.ts index 24dfa4e7..1933ad3c 100644 --- a/packages/tui/src/status/spinner.ts +++ b/packages/tui/src/status/spinner.ts @@ -8,7 +8,7 @@ export function spin(callback: () => MaybePromise, options?: StatusOptions export function spin(message: string, callback: () => MaybePromise): Promise; -export async function spin(callbackOrMessage: (() => MaybePromise) | string, optionsOrCallback: StatusOptions | (() => MaybePromise) = { message: 'Loading' }): Promise { +export async function spin(callbackOrMessage: (() => MaybePromise) | string, optionsOrCallback: StatusOptions | (() => MaybePromise) = { message: '' }): Promise { const callback = typeof callbackOrMessage === 'string' ? optionsOrCallback : callbackOrMessage; const options = typeof callbackOrMessage === 'string' ? { message: callbackOrMessage } : optionsOrCallback; @@ -16,7 +16,7 @@ export async function spin(callbackOrMessage: (() => MaybePromise) | strin throw new Error('A spinner callback is required.'); } - const message = typeof options === 'function' ? 'Loading' : options.message; + const message = typeof options === 'function' ? '' : options.message; const output = promptEnvironment().output; const frame = renderSpinnerFrame(message); diff --git a/packages/tui/tests/form.test.ts b/packages/tui/tests/form.test.ts index 283f103b..06691106 100644 --- a/packages/tui/tests/form.test.ts +++ b/packages/tui/tests/form.test.ts @@ -360,8 +360,9 @@ describe('form builder', () => { expect(responses.spin).toBe('spun'); expect(responses.progress).toEqual([2, 4]); - expect(output.text()).toContain(' ⠶ Loading'); - expect(output.text()).not.toContain('Done: Loading'); + expect(output.text()).toContain(' ⠶ '); + expect(output.text()).not.toContain('Loading'); + expect(output.text()).not.toContain('Done:'); expect(output.text()).toContain('Files'); expect(output.text()).toContain('2 / 2'); }); diff --git a/packages/tui/tests/spinner.test.ts b/packages/tui/tests/spinner.test.ts index a47a85d8..42547afe 100644 --- a/packages/tui/tests/spinner.test.ts +++ b/packages/tui/tests/spinner.test.ts @@ -18,6 +18,18 @@ describe('spinner helper', () => { expect(output.text()).not.toContain('Done: Working'); }); + it('runs callback-first spinner callbacks without a default message', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment({ output, error: output }, async () => { + return spin(async () => 'done'); + }); + + expect(result).toBe('done'); + expect(output.text()).toContain(' ⠶ '); + expect(output.text()).not.toContain('Loading'); + }); + it('runs label-first spinner callbacks', async () => { const output = createMemoryOutput(); From b4fdcee22e7982cc5d4f0ac306439b5f8dd8bd85 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 23:27:57 +0800 Subject: [PATCH 208/675] Reject optional single select prompts --- UPSTREAM.md | 1 + packages/tui/src/prompts/select/index.ts | 3 +++ packages/tui/src/prompts/select/validators/options.ts | 10 ++++++++++ packages/tui/tests/choices.test.ts | 11 +++++++++++ 4 files changed, 25 insertions(+) create mode 100644 packages/tui/src/prompts/select/validators/options.ts diff --git a/UPSTREAM.md b/UPSTREAM.md index a701b18f..8a9b1198 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -27,6 +27,7 @@ TypeScript and OpenTUI internals. | Number input | `packages/tui/src/prompts/number.ts`, `packages/tui/src/typed-value.ts` | | Confirm, select, multiselect | `packages/tui/src/prompts/select/*` | | Select active boxed layout and row styling | `packages/tui/src/prompts/select/render.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/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/tests/choices.test.ts` | | Select cancel final frame | `packages/tui/src/prompts/select/render.ts`, `packages/tui/src/prompts/select/read-selected.ts`, `packages/tui/tests/choices.test.ts` | | Multi-select active boxed layout and row styling | `packages/tui/src/prompts/select/render.ts`, `packages/tui/tests/choices.test.ts` | diff --git a/packages/tui/src/prompts/select/index.ts b/packages/tui/src/prompts/select/index.ts index 08cb8fa4..6d41c681 100644 --- a/packages/tui/src/prompts/select/index.ts +++ b/packages/tui/src/prompts/select/index.ts @@ -4,9 +4,12 @@ import { promptUntilValid, PromptValidationError } from '#tui/prompt'; import { normalizeChoices } from '#tui/concerns/choices'; import { readMultipleChoices } from '#tui/prompts/select/read-multiple'; import { readSelectedChoice } from '#tui/prompts/select/read-selected'; +import { assertSelectOptions } from '#tui/prompts/select/validators/options'; import type { MultiSelectPromptOptions, SelectPromptOptions } from '#tui/types'; export const select = async (options: SelectPromptOptions): Promise => { + assertSelectOptions(options); + const choices = normalizeChoices(options.options); return promptUntilValid(options, async () => { diff --git a/packages/tui/src/prompts/select/validators/options.ts b/packages/tui/src/prompts/select/validators/options.ts new file mode 100644 index 00000000..c109c45b --- /dev/null +++ b/packages/tui/src/prompts/select/validators/options.ts @@ -0,0 +1,10 @@ +import { z } from 'zod'; +import type { SelectPromptOptions } from '#tui/types'; + +const disallowedRequiredSchema = z.literal(false); + +export const assertSelectOptions = (options: SelectPromptOptions): void => { + if (disallowedRequiredSchema.safeParse(options.required).success) { + throw new TypeError('Argument [required] must be true or a string.'); + } +}; diff --git a/packages/tui/tests/choices.test.ts b/packages/tui/tests/choices.test.ts index 9ca402aa..f1789545 100644 --- a/packages/tui/tests/choices.test.ts +++ b/packages/tui/tests/choices.test.ts @@ -1,5 +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'; describe('choice prompts', () => { it('confirms with direct y and n keys', async () => { @@ -126,6 +127,16 @@ describe('choice prompts', () => { expect(output.text()).toContain('│ second'); }); + it('rejects optional single select prompts', async () => { + const options = { + message: 'Pick one', + options: ['first', 'second'], + required: false, + } as unknown as SelectPromptOptions; + + await expect(select(options)).rejects.toThrow('Argument [required] must be true or a string.'); + }); + it('returns keys from keyed select options', async () => { const output = createMemoryOutput(); From 06e7ba9c47d40d3f5802ba580960921d4b5b8008 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 23:31:52 +0800 Subject: [PATCH 209/675] Validate non-interactive select defaults --- UPSTREAM.md | 1 + packages/tui/src/prompts/select/index.ts | 11 +++++----- packages/tui/tests/choices.test.ts | 26 ++++++++++++++++++++++++ 3 files changed, 33 insertions(+), 5 deletions(-) diff --git a/UPSTREAM.md b/UPSTREAM.md index 8a9b1198..1885d872 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -28,6 +28,7 @@ TypeScript and OpenTUI internals. | Confirm, select, multiselect | `packages/tui/src/prompts/select/*` | | Select active boxed layout and row styling | `packages/tui/src/prompts/select/render.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/tests/choices.test.ts` | +| Select non-interactive default validation | `packages/tui/src/prompts/select/index.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/tests/choices.test.ts` | | Select cancel final frame | `packages/tui/src/prompts/select/render.ts`, `packages/tui/src/prompts/select/read-selected.ts`, `packages/tui/tests/choices.test.ts` | | Multi-select active boxed layout and row styling | `packages/tui/src/prompts/select/render.ts`, `packages/tui/tests/choices.test.ts` | diff --git a/packages/tui/src/prompts/select/index.ts b/packages/tui/src/prompts/select/index.ts index 6d41c681..d5d6ca04 100644 --- a/packages/tui/src/prompts/select/index.ts +++ b/packages/tui/src/prompts/select/index.ts @@ -10,18 +10,19 @@ import type { MultiSelectPromptOptions, SelectPromptOptions } from '#tui/types'; export const select = async (options: SelectPromptOptions): Promise => { assertSelectOptions(options); + const promptOptions = { ...options, required: options.required ?? true }; const choices = normalizeChoices(options.options); - return promptUntilValid(options, async () => { - const selected = await readSelectedChoice(options.message, choices, options.default, options.hint, options.scroll, options.info).catch((error: unknown) => { - if (options.default !== undefined && error instanceof PromptValidationError) { - return options.default; + return promptUntilValid(promptOptions, async () => { + const selected = await readSelectedChoice(promptOptions.message, choices, promptOptions.default, promptOptions.hint, promptOptions.scroll, promptOptions.info).catch((error: unknown) => { + if (promptOptions.default !== undefined && error instanceof PromptValidationError) { + return promptOptions.default; } throw error; }); - return options.transform ? options.transform(selected) : selected; + return promptOptions.transform ? promptOptions.transform(selected) : selected; }); }; diff --git a/packages/tui/tests/choices.test.ts b/packages/tui/tests/choices.test.ts index f1789545..a52ac2b9 100644 --- a/packages/tui/tests/choices.test.ts +++ b/packages/tui/tests/choices.test.ts @@ -188,6 +188,32 @@ describe('choice prompts', () => { expect(output.text()).toContain('│ third'); }); + it('returns the default value for non-interactive select prompts', async () => { + const result = await withPromptEnvironment( + { + output: createMemoryOutput(), + error: createMemoryOutput(), + interactive: false, + }, + () => select({ message: 'Pick one', options: ['first', 'second'], default: 'second' }), + ); + + expect(result).toBe('second'); + }); + + it('rejects non-interactive select prompts without defaults', async () => { + await expect( + withPromptEnvironment( + { + output: createMemoryOutput(), + error: createMemoryOutput(), + interactive: false, + }, + () => select({ message: 'Pick one', options: ['first', 'second'] }), + ), + ).rejects.toThrow('Required.'); + }); + it('renders a scrolling select window around the highlighted choice', async () => { const output = createMemoryOutput(); From 4f741dfc15fdde4e68af506f90e3685f11b350a9 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 23:36:16 +0800 Subject: [PATCH 210/675] Reject prompt reverts after forms --- UPSTREAM.md | 1 + packages/tui/src/form/builder/revert.ts | 4 ++-- packages/tui/src/prompt/revert.ts | 6 +++++ .../tui/src/prompts/select/read-confirm.ts | 6 +++++ packages/tui/tests/form.test.ts | 23 ++++++++++++++++++- 5 files changed, 37 insertions(+), 3 deletions(-) create mode 100644 packages/tui/src/prompt/revert.ts diff --git a/UPSTREAM.md b/UPSTREAM.md index 1885d872..d4941f8d 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -14,6 +14,7 @@ TypeScript and OpenTUI internals. | 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 cancellation callback lifecycle | `packages/tui/src/prompt/cancel.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` | | Key constants 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` | diff --git a/packages/tui/src/form/builder/revert.ts b/packages/tui/src/form/builder/revert.ts index 400a0b89..f5102acd 100644 --- a/packages/tui/src/form/builder/revert.ts +++ b/packages/tui/src/form/builder/revert.ts @@ -1,6 +1,6 @@ import { configurePrompts, promptEnvironment } from '#tui/environment'; import { Key } from '#tui/key'; -import { renderError } from '#tui/theme'; +import { rejectPromptRevert } from '#tui/prompt/revert'; import type { MaybePromise, PromptInput } from '#tui/types'; export class FormRevertedError extends Error { @@ -33,7 +33,7 @@ export const runWithFormRevert = async (canRevert: boolean, callback: () => M throw new FormRevertedError(); } - environment.error.write(renderError('This cannot be reverted.')); + rejectPromptRevert(); } }, }; diff --git a/packages/tui/src/prompt/revert.ts b/packages/tui/src/prompt/revert.ts new file mode 100644 index 00000000..d47cdce1 --- /dev/null +++ b/packages/tui/src/prompt/revert.ts @@ -0,0 +1,6 @@ +import { promptEnvironment } from '#tui/environment'; +import { renderError } from '#tui/theme'; + +export const rejectPromptRevert = (): void => { + promptEnvironment().error.write(renderError('This cannot be reverted.')); +}; diff --git a/packages/tui/src/prompts/select/read-confirm.ts b/packages/tui/src/prompts/select/read-confirm.ts index ce899f71..f10764de 100644 --- a/packages/tui/src/prompts/select/read-confirm.ts +++ b/packages/tui/src/prompts/select/read-confirm.ts @@ -1,6 +1,7 @@ import { promptEnvironment } from '#tui/environment'; import { Key } from '#tui/key'; import { ask, cancelPrompt } from '#tui/prompt'; +import { rejectPromptRevert } from '#tui/prompt/revert'; import { renderQuestion } from '#tui/theme'; import type { ConfirmPromptOptions } from '#tui/types'; @@ -68,5 +69,10 @@ export const readConfirm = async (options: ConfirmPromptOptions): Promise { it('runs chained steps and returns positional responses', async () => { @@ -167,6 +167,27 @@ describe('form builder', () => { expect(output.text()).toContain('This cannot be reverted.'); }); + it('does not allow reverting a normal prompt after form submit', async () => { + const output = createMemoryOutput(); + + const confirmed = await withPromptEnvironment( + { + input: createScriptedInput(['A', Key.enter, Key.ctrlU, Key.enter]), + output, + error: output, + interactive: true, + }, + async () => { + await form().text('Name').submit(); + + return confirm('Ready?'); + }, + ); + + expect(confirmed).toBe(true); + expect(output.text()).toContain('This cannot be reverted.'); + }); + it('runs suggest, search, and multisearch form steps', async () => { const output = createMemoryOutput(); From 135b1d5a88d65aee6cd319066f3763ad11922f51 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 23:39:52 +0800 Subject: [PATCH 211/675] Preserve ANSI wrapping state --- UPSTREAM.md | 2 +- packages/tui/src/index.ts | 3 +- packages/tui/src/strings.ts | 135 ++++++++++++++++++++++++++++- packages/tui/tests/strings.test.ts | 39 ++++++++- 4 files changed, 175 insertions(+), 4 deletions(-) diff --git a/UPSTREAM.md b/UPSTREAM.md index d4941f8d..5e870566 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -84,7 +84,7 @@ TypeScript and OpenTUI internals. | 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/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` | -| ANSI, wrapping, truncation, Unicode width | `packages/tui/src/strings.ts` | +| ANSI segment parsing, ANSI-aware wrapping, truncation, Unicode width | `packages/tui/src/strings.ts`, `packages/tui/tests/strings.test.ts` | | OpenTUI adapter | `packages/tui/src/opentui.ts` | | Type contracts | `packages/tui/src/types.ts`, `packages/tui/src/contracts/*` | diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index 8da60fdc..7423c80c 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -43,7 +43,8 @@ export { terminalSize, } from '#tui/terminal'; export { applyTypedKey, readTypedValue } from '#tui/typed-value'; -export { parseAnsiText, truncate, visibleWidth, wrap } from '#tui/strings'; +export { parseAnsiSegments, parseAnsiText, truncate, visibleWidth, wrap } from '#tui/strings'; +export type { AnsiSegment } from '#tui/strings'; export type { BasePromptOptions, Choice, diff --git a/packages/tui/src/strings.ts b/packages/tui/src/strings.ts index 7c700953..f1d643df 100644 --- a/packages/tui/src/strings.ts +++ b/packages/tui/src/strings.ts @@ -1,6 +1,15 @@ import stringWidth from 'string-width'; import stripAnsi from 'strip-ansi'; +export type AnsiSegment = { + codes: string; + text: string; +}; + +const ESC = '\u001B'; +const ANSI_SEQUENCE = new RegExp(`^${ESC}\\[[0-9;]*m$`, 'u'); +const ANSI_RESET_CODES = new Set(['0', '22', '23', '24', '27', '29', '39', '49']); + export const visibleWidth = (value: string): number => stringWidth(stripAnsi(value)); export const truncate = (value: string, width: number, marker = '...'): string => { @@ -29,7 +38,51 @@ export const truncate = (value: string, width: number, marker = '...'): string = return result; }; -export const wrap = (value: string, width: number): string[] => { +export const parseAnsiSegments = (value: string): AnsiSegment[] => { + const segments: AnsiSegment[] = []; + + let currentCodes = ''; + let currentText = ''; + let index = 0; + + while (index < value.length) { + if (value[index] === '\u001B' && value[index + 1] === '[') { + if (currentText !== '') { + segments.push({ text: currentText, codes: currentCodes }); + currentText = ''; + } + + let sequence = ''; + + while (index < value.length) { + sequence += value[index]; + index += 1; + + if (ANSI_SEQUENCE.test(sequence)) { + const code = sequence.slice(2, -1); + + currentCodes = ANSI_RESET_CODES.has(code) ? '' : sequence; + break; + } + } + + continue; + } + + currentText += value[index]; + index += 1; + } + + if (currentText !== '') { + segments.push({ text: currentText, codes: currentCodes }); + } + + return segments; +}; + +const codedCharacters = (value: string): AnsiSegment[] => parseAnsiSegments(value).flatMap((segment) => [...segment.text].map((text) => ({ text, codes: segment.codes }))); + +const plainWrap = (value: string, width: number): string[] => { if (width <= 0) { return [value]; } @@ -78,4 +131,84 @@ export const wrap = (value: string, width: number): string[] => { return lines; }; +const ansiCloseSequence = (codes: string): string => { + const code = codes.slice(2, -1); + + if (code === '1' || code === '2') { + return `${ESC}[22m`; + } + + if (code === '3') { + return `${ESC}[23m`; + } + + if (code === '4') { + return `${ESC}[24m`; + } + + if (code === '7') { + return `${ESC}[27m`; + } + + if (code === '9') { + return `${ESC}[29m`; + } + + return `${ESC}[0m`; +}; + +export const wrap = (value: string, width: number): string[] => { + const plainLines = plainWrap(stripAnsi(value), width); + 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; +}; + export const parseAnsiText = (value: string): string => stripAnsi(value); diff --git a/packages/tui/tests/strings.test.ts b/packages/tui/tests/strings.test.ts index 751132f2..e0f298fd 100644 --- a/packages/tui/tests/strings.test.ts +++ b/packages/tui/tests/strings.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { parseAnsiText, truncate, visibleWidth, wrap } from '#tui/index'; +import { parseAnsiSegments, parseAnsiText, truncate, visibleWidth, wrap } from '#tui/index'; describe('string utilities', () => { it('measures visible width without ANSI control sequences', () => { @@ -21,4 +21,41 @@ describe('string utilities', () => { it('strips ANSI text', () => { expect(parseAnsiText('\u001B[32mDone\u001B[39m')).toBe('Done'); }); + + it('parses plain text into a single ANSI segment', () => { + expect(parseAnsiSegments('Hello, World!')).toEqual([{ text: 'Hello, World!', codes: '' }]); + }); + + it('parses styled and unstyled ANSI segments', () => { + expect(parseAnsiSegments('Hello \u001B[1mBold\u001B[0m World')).toEqual([ + { text: 'Hello ', codes: '' }, + { text: 'Bold', codes: '\u001B[1m' }, + { text: ' World', codes: '' }, + ]); + }); + + it('parses 24-bit ANSI color segments', () => { + expect(parseAnsiSegments('\u001B[38;2;255;100;50mColored\u001B[0m')).toEqual([{ text: 'Colored', codes: '\u001B[38;2;255;100;50m' }]); + }); + + it('preserves ANSI codes across wrapped lines', () => { + const result = wrap('\u001B[31mHello World\u001B[0m', 5); + + expect(result).toHaveLength(2); + expect(result[0]).toContain('\u001B[31m'); + expect(result[0]).toContain('Hello'); + expect(result[0]?.endsWith('\u001B[0m')).toBe(true); + expect(result[1]).toContain('\u001B[31m'); + expect(result[1]).toContain('World'); + expect(result[1]?.endsWith('\u001B[0m')).toBe(true); + }); + + it('wraps multiple ANSI color segments onto separate lines', () => { + const result = wrap('\u001B[31mRed\u001B[0m \u001B[32mGreen\u001B[0m \u001B[34mBlue\u001B[0m', 5); + + expect(result).toHaveLength(3); + expect(result[0]).toContain('Red'); + expect(result[1]).toContain('Green'); + expect(result[2]).toContain('Blue'); + }); }); From 0240af926aa40f3c08a275f2f7fe8fce045ef13c Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 23:41:53 +0800 Subject: [PATCH 212/675] Validate data table row shapes --- UPSTREAM.md | 1 + packages/tui/src/output/data-table/rows.ts | 36 +++++++++++-------- .../tui/src/output/validators/data-table.ts | 32 +++++++++++++++++ 3 files changed, 55 insertions(+), 14 deletions(-) diff --git a/UPSTREAM.md b/UPSTREAM.md index 5e870566..608b0fc1 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -60,6 +60,7 @@ TypeScript and OpenTUI internals. | Data table multiline cell rendering | `packages/tui/src/output/data-table/multiline.ts`, `packages/tui/src/output/data-table/render.ts`, `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/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/rows.ts`, `packages/tui/tests/table.test.ts` | +| Data table row-shape validation and normalization | `packages/tui/src/output/validators/data-table.ts`, `packages/tui/src/output/data-table/rows.ts`, `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/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/render.ts`, `packages/tui/tests/table.test.ts` | | Boxed balanced grid rendering | `packages/tui/src/output/grid.ts`, `packages/tui/tests/output.test.ts` | diff --git a/packages/tui/src/output/data-table/rows.ts b/packages/tui/src/output/data-table/rows.ts index 5ccb5cac..346a70c4 100644 --- a/packages/tui/src/output/data-table/rows.ts +++ b/packages/tui/src/output/data-table/rows.ts @@ -1,4 +1,4 @@ -import { isDataObjectRow } from '#tui/output/validators/data-table'; +import { parseDataTableRowShape } from '#tui/output/validators/data-table'; import type { VisibleDataTableRow } from '#tui/output/data-table/types'; import type { DataTablePromptOptions, DataTableRow, TableCell } from '#tui/types'; @@ -7,15 +7,17 @@ const stringify = (value: TableCell): string => { }; export const dataTableRowFields = (row: DataTableRow): Record => { - if (Array.isArray(row)) { - return Object.fromEntries(row.map((value, index) => [String(index), value])); + const shape = parseDataTableRowShape(row); + + if (shape.kind === 'array') { + return Object.fromEntries(shape.row.map((value, index) => [String(index), value])); } - if (isDataObjectRow(row)) { - return row.cells; + if (shape.kind === 'object') { + return shape.row.cells; } - return row; + return shape.row; }; export const deriveDataTableHeaders = (rows: Array>): string[] => { @@ -25,16 +27,20 @@ export const deriveDataTableHeaders = (rows: Array>): string[ return []; } - if (Array.isArray(first)) { - return first.map((_, index) => String(index + 1)); + const shape = parseDataTableRowShape(first); + + if (shape.kind === 'array') { + return shape.row.map((_, index) => String(index + 1)); } return Object.keys(dataTableRowFields(first)).filter((key) => key !== 'value'); }; export const dataTableRowCells = (headers: string[], row: DataTableRow): string[] => { - if (Array.isArray(row)) { - return headers.length > 0 ? headers.map((_, index) => stringify(row[index])) : row.map(stringify); + const shape = parseDataTableRowShape(row); + + if (shape.kind === 'array') { + return headers.length > 0 ? headers.map((_, index) => stringify(shape.row[index])) : shape.row.map(stringify); } const fields = dataTableRowFields(row); @@ -43,12 +49,14 @@ export const dataTableRowCells = (headers: string[], row: DataTableRow): s }; export const dataTableRowValue = (row: DataTableRow, index: number): T | number => { - if (isDataObjectRow(row) && row.value !== undefined) { - return row.value; + const shape = parseDataTableRowShape(row); + + if (shape.kind === 'object' && shape.row.value !== undefined) { + return shape.row.value; } - if (!Array.isArray(row) && 'value' in row && row.value !== undefined) { - return row.value as T; + if (shape.kind === 'record' && shape.row.value !== undefined) { + return shape.row.value as T; } return index; diff --git a/packages/tui/src/output/validators/data-table.ts b/packages/tui/src/output/validators/data-table.ts index 7f7aed6a..948b74c7 100644 --- a/packages/tui/src/output/validators/data-table.ts +++ b/packages/tui/src/output/validators/data-table.ts @@ -2,6 +2,8 @@ import { z } from 'zod'; import type { DataTableObjectRow, DataTableRow, TableCell } from '#tui/types'; const tableCellSchema: z.ZodType = z.union([z.string(), z.number(), z.boolean(), z.null(), z.undefined()]); +const dataTableArrayRowSchema = z.array(tableCellSchema); +const dataTableRecordRowSchema = z.record(z.string(), tableCellSchema); const dataObjectRowSchema = z .object({ @@ -10,6 +12,36 @@ const dataObjectRowSchema = z }) .passthrough(); +export type DataTableRowShape = + | { + kind: 'array'; + row: TableCell[]; + } + | { + kind: 'object'; + row: DataTableObjectRow; + } + | { + kind: 'record'; + row: Record; + }; + export const isDataObjectRow = (row: DataTableRow): row is DataTableObjectRow => { return dataObjectRowSchema.safeParse(row).success; }; + +export const parseDataTableRowShape = (row: DataTableRow): DataTableRowShape => { + const objectRow = dataObjectRowSchema.safeParse(row); + + if (objectRow.success) { + return { kind: 'object', row: objectRow.data as DataTableObjectRow }; + } + + const arrayRow = dataTableArrayRowSchema.safeParse(row); + + if (arrayRow.success) { + return { kind: 'array', row: arrayRow.data }; + } + + return { kind: 'record', row: dataTableRecordRowSchema.parse(row) }; +}; From 0961ff957b8b0dfe0838553e3c6f4196928129ad Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 23:44:39 +0800 Subject: [PATCH 213/675] Pass number transforms through forms --- UPSTREAM.md | 1 + .../tui/src/form/builder/prompts/basic.ts | 4 ++-- .../form/builder/prompts/contracts/basic.ts | 3 ++- packages/tui/src/prompts/number.ts | 6 +++++- packages/tui/tests/form.test.ts | 19 +++++++++++++++++++ packages/tui/tests/number.test.ts | 16 ++++++++++++++++ 6 files changed, 45 insertions(+), 4 deletions(-) diff --git a/UPSTREAM.md b/UPSTREAM.md index 608b0fc1..d3a2a1ac 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -69,6 +69,7 @@ TypeScript and OpenTUI internals. | Form builder label-first search and multisearch methods | `packages/tui/src/form/builder/prompts/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 suggest and autocomplete placeholder/default arguments | `packages/tui/src/form/builder/prompts/choices.ts`, `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/contracts/basic.ts`, `packages/tui/src/prompts/number.ts`, `packages/tui/tests/form.test.ts`, `packages/tui/tests/number.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/tests/output.test.ts`, `packages/tui/tests/form.test.ts` | | Progress boxed frame rendering and formatted fractions | `packages/tui/src/status/progress/render.ts`, `packages/tui/src/theme/box.ts` | | Spinner static and deterministic animated frame rendering | `packages/tui/src/status/spinner.ts`, `packages/tui/src/status/spinner/render.ts` | diff --git a/packages/tui/src/form/builder/prompts/basic.ts b/packages/tui/src/form/builder/prompts/basic.ts index 4e1c741a..248b5e87 100644 --- a/packages/tui/src/form/builder/prompts/basic.ts +++ b/packages/tui/src/form/builder/prompts/basic.ts @@ -4,8 +4,8 @@ import type { FormBuilder } from '#tui/form/builder/index'; import type { BasicPromptBuilderMethods } from '#tui/form/builder/prompts/types'; export const basicPromptBuilderMethods: BasicPromptBuilderMethods & ThisType = { - number(label, placeholder = '', defaultValue = 0, required = false, validate = undefined, hint = '', min, max, step, name) { - return this.add((_, previous) => number(label, placeholder, previousNumber(previous, defaultValue), required, validate, hint, min, max, step), name); + number(label, placeholder = '', defaultValue = 0, required = false, validate = undefined, hint = '', min, max, step, name, transform = undefined) { + return this.add((_, previous) => number(label, placeholder, previousNumber(previous, defaultValue), required, validate, hint, min, max, step, transform), name); }, password(label, placeholder = '', required = false, validate = undefined, hint = '', name, transform = undefined) { return this.add(() => password(label, placeholder, required, validate, hint, transform), name); diff --git a/packages/tui/src/form/builder/prompts/contracts/basic.ts b/packages/tui/src/form/builder/prompts/contracts/basic.ts index a849a23a..5bae8f39 100644 --- a/packages/tui/src/form/builder/prompts/contracts/basic.ts +++ b/packages/tui/src/form/builder/prompts/contracts/basic.ts @@ -1,5 +1,5 @@ import type { FormBuilder } from '#tui/form/builder/index'; -import type { TextPromptOptions } from '#tui/types'; +import type { NumberPromptOptions, TextPromptOptions } from '#tui/types'; import type { MaybePromise } from '#tui/types'; export type BasicPromptBuilderMethods = { @@ -15,6 +15,7 @@ export type BasicPromptBuilderMethods = { max?: number, step?: number, name?: string, + transform?: NumberPromptOptions['transform'], ): FormBuilder; password( this: FormBuilder, diff --git a/packages/tui/src/prompts/number.ts b/packages/tui/src/prompts/number.ts index a2eafcbd..98b874d4 100644 --- a/packages/tui/src/prompts/number.ts +++ b/packages/tui/src/prompts/number.ts @@ -15,6 +15,7 @@ export function number( min?: number, max?: number, step?: number, + transform?: NumberPromptOptions['transform'], ): Promise; export async function number( @@ -27,9 +28,12 @@ export async function number( min: number | undefined = undefined, max: number | undefined = undefined, step: number | undefined = undefined, + transform: NumberPromptOptions['transform'] = undefined, ): Promise { const options: NumberPromptOptions = - typeof message === 'string' ? { message, label: message, placeholder, default: defaultValue, required, validate, hint, min, max, step } : { ...message, default: message.default ?? '' }; + typeof message === 'string' + ? { message, label: message, placeholder, default: defaultValue, required, validate, hint, min, max, step, transform } + : { ...message, default: message.default ?? '' }; return promptUntilValid(options, async () => { const answer = await readNumberValue(options.message, { diff --git a/packages/tui/tests/form.test.ts b/packages/tui/tests/form.test.ts index 7d3f08c9..8b2c63b4 100644 --- a/packages/tui/tests/form.test.ts +++ b/packages/tui/tests/form.test.ts @@ -98,6 +98,25 @@ describe('form builder', () => { expect(responses[1]).toBe('D'); }); + it('applies number transforms from form steps', async () => { + const output = createMemoryOutput(); + + const responses = await withPromptEnvironment( + { + input: createScriptedInput(['4', Key.enter]), + output, + error: output, + interactive: true, + }, + () => + form() + .number('Count', '', '', false, undefined, '', undefined, undefined, undefined, 'count', (value) => Number(value) * 2) + .submit(), + ); + + expect(responses.count).toBe(8); + }); + it('reuses array previous responses when reverting prompt builder steps', async () => { const output = createMemoryOutput(); diff --git a/packages/tui/tests/number.test.ts b/packages/tui/tests/number.test.ts index 4cb33c25..4ce7ed23 100644 --- a/packages/tui/tests/number.test.ts +++ b/packages/tui/tests/number.test.ts @@ -119,6 +119,22 @@ describe('number prompt', () => { expect(result).toBe(1); }); + it('applies transforms from positional number prompts', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['4', Key.enter]), + output, + error: output, + interactive: true, + }, + () => number('Count', '', '', false, undefined, '', undefined, undefined, undefined, (value) => Number(value) * 2), + ); + + expect(result).toBe(8); + }); + it('parses numeric values as integers', () => { expect(parseNumberInput('1.9')).toEqual({ value: 1 }); expect(parseNumberInput('1.9', { integer: true })).toEqual({ value: 1 }); From 4d52cfbbdc3219a66a13b54e9b917a519162bbf3 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 23:46:55 +0800 Subject: [PATCH 214/675] Reuse search defaults after form revert --- UPSTREAM.md | 1 + .../tui/src/form/builder/prompts/choices.ts | 8 +-- packages/tui/tests/form.test.ts | 60 +++++++++++++++++++ 3 files changed, 65 insertions(+), 4 deletions(-) diff --git a/UPSTREAM.md b/UPSTREAM.md index d3a2a1ac..70bbd353 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -70,6 +70,7 @@ TypeScript and OpenTUI internals. | 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 suggest and autocomplete placeholder/default arguments | `packages/tui/src/form/builder/prompts/choices.ts`, `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/contracts/basic.ts`, `packages/tui/src/prompts/number.ts`, `packages/tui/tests/form.test.ts`, `packages/tui/tests/number.test.ts` | +| Form builder search and multisearch previous-response defaults | `packages/tui/src/form/builder/prompts/choices.ts`, `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/tests/output.test.ts`, `packages/tui/tests/form.test.ts` | | Progress boxed frame rendering and formatted fractions | `packages/tui/src/status/progress/render.ts`, `packages/tui/src/theme/box.ts` | | Spinner static and deterministic animated frame rendering | `packages/tui/src/status/spinner.ts`, `packages/tui/src/status/spinner/render.ts` | diff --git a/packages/tui/src/form/builder/prompts/choices.ts b/packages/tui/src/form/builder/prompts/choices.ts index 6024d10f..e8f336af 100644 --- a/packages/tui/src/form/builder/prompts/choices.ts +++ b/packages/tui/src/form/builder/prompts/choices.ts @@ -24,7 +24,7 @@ export const choicePromptBuilderMethods: ChoicePromptBuilderMethods & ThisType['transform'] = undefined, ) { if (!isSearchPromptLabel(optionsOrLabel)) { - return this.add(() => multisearch(optionsOrLabel), options as string | undefined); + return this.add((_, previous) => multisearch({ ...optionsOrLabel, default: previousArray(previous, optionsOrLabel.default ?? []) }), options as string | undefined); } const promptOptions: MultiSearchPromptOptions = { @@ -39,7 +39,7 @@ export const choicePromptBuilderMethods: ChoicePromptBuilderMethods & ThisType multisearch(promptOptions), name); + return this.add((_, previous) => multisearch({ ...promptOptions, default: previousArray(previous, promptOptions.default ?? []) }), name); }, multiselect( label: string, @@ -69,7 +69,7 @@ export const choicePromptBuilderMethods: ChoicePromptBuilderMethods & ThisType['transform'] = undefined, ) { if (!isSearchPromptLabel(optionsOrLabel)) { - return this.add(() => search(optionsOrLabel), options as string | undefined); + return this.add((_, previous) => search({ ...optionsOrLabel, default: previous === undefined ? optionsOrLabel.default : (previous as T) }), options as string | undefined); } const promptOptions: SearchPromptOptions = { @@ -84,7 +84,7 @@ export const choicePromptBuilderMethods: ChoicePromptBuilderMethods & ThisType search(promptOptions), name); + return this.add((_, previous) => search({ ...promptOptions, default: previous === undefined ? promptOptions.default : (previous as T) }), name); }, select(label: string, options: ChoiceOptions, defaultValue?: T, scroll = 5, validate = undefined, hint = '', required: boolean | string = true, name?: string, transform = undefined) { return this.add((_, previous) => select({ message: label, options, default: previous === undefined ? defaultValue : (previous as T), scroll, validate, hint, required, transform }), name); diff --git a/packages/tui/tests/form.test.ts b/packages/tui/tests/form.test.ts index 8b2c63b4..47424d24 100644 --- a/packages/tui/tests/form.test.ts +++ b/packages/tui/tests/form.test.ts @@ -330,6 +330,66 @@ describe('form builder', () => { expect(responses.many).toEqual(['red']); }); + it('reuses previous search responses when reverting object-option form steps', async () => { + const output = createMemoryOutput(); + + const responses = await withPromptEnvironment( + { + input: createScriptedInput(['g', Key.down, Key.enter, Key.ctrlU]), + output, + error: output, + interactive: true, + }, + () => + form() + .search( + { + message: 'Searched color', + options: (value) => { + const options = { green: 'Green', blue: 'Blue', red: 'Red' }; + + return Object.fromEntries(Object.entries(options).filter(([, label]) => label.toLowerCase().includes(value.toLowerCase()))); + }, + }, + 'searched', + ) + .confirm('Done?') + .submit(), + ); + + expect(responses.searched).toBe('green'); + }); + + it('reuses previous multisearch responses when reverting object-option form steps', async () => { + const output = createMemoryOutput(); + + const responses = await withPromptEnvironment( + { + input: createScriptedInput(['r', Key.down, Key.space, Key.enter, Key.ctrlU]), + output, + error: output, + interactive: true, + }, + () => + form() + .multisearch( + { + message: 'Many colors', + options: (value) => { + const options = { red: 'Red', green: 'Green', blue: 'Blue' }; + + return Object.fromEntries(Object.entries(options).filter(([, label]) => label.toLowerCase().includes(value.toLowerCase()))); + }, + }, + 'many', + ) + .confirm('Done?') + .submit(), + ); + + expect(responses.many).toEqual(['red']); + }); + it('runs password and textarea form steps', async () => { const output = createMemoryOutput(); From 1fb32f8b8a14600439f91e78eb20e887f8bdd0c0 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 23:49:43 +0800 Subject: [PATCH 215/675] Cover form revert lifecycle cases --- UPSTREAM.md | 2 + packages/tui/tests/form.test.ts | 73 ++++++++++++++++++++++++++++++++- 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/UPSTREAM.md b/UPSTREAM.md index 70bbd353..2598045d 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -50,6 +50,8 @@ TypeScript and OpenTUI internals. | Choice normalization, matching, navigation, and list rendering concerns | `packages/tui/src/concerns/choices.ts` | | Choice and search scrollbar rendering | `packages/tui/src/concerns/scrollbar.ts`, `packages/tui/src/concerns/choices.ts`, `packages/tui/src/prompts/search/render.ts` | | Form builder | `packages/tui/src/form.ts`, `packages/tui/src/form/builder/*` | +| 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` | | Notes and alert-style output | `packages/tui/src/output/notes.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/*` | diff --git a/packages/tui/tests/form.test.ts b/packages/tui/tests/form.test.ts index 47424d24..5a479a98 100644 --- a/packages/tui/tests/form.test.ts +++ b/packages/tui/tests/form.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { confirm, createMemoryOutput, createScriptedInput, form, Key, withPromptEnvironment } from '#tui/index'; +import { confirm, createMemoryOutput, createScriptedInput, form, Key, outro, text, withPromptEnvironment } from '#tui/index'; describe('form builder', () => { it('runs chained steps and returns positional responses', async () => { @@ -169,6 +169,77 @@ describe('form builder', () => { expect(responses[2]).toBe('D'); }); + it('will not skip over the first step when reverting', async () => { + const output = createMemoryOutput(); + + const responses = await withPromptEnvironment( + { + input: createScriptedInput([Key.ctrlU, Key.enter]), + output, + error: output, + interactive: true, + }, + () => form().info('This should not be skipped').confirm('Are you sure?').submit(), + ); + + expect(responses[0]).toBeNull(); + expect(responses[1]).toBe(true); + }); + + it('stops custom step execution at the moment of reverting', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment( + { + input: createScriptedInput(['2', '7', Key.enter, Key.down, Key.ctrlU, Key.enter, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + form() + .text('Age') + .add(async () => { + const confirmed = await confirm('Are you sure?'); + + if (!confirmed) { + outro('This should not appear!'); + } + }) + .submit(), + ); + + expect(output.text()).not.toContain('This should not appear!'); + }); + + it('re-evaluates conditional steps after reverting prior responses', async () => { + const output = createMemoryOutput(); + + const responses = await withPromptEnvironment( + { + input: createScriptedInput(['A', 'd', 'a', Key.enter, Key.down, Key.enter, Key.ctrlU, Key.up, Key.enter, '1', Key.enter, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + form() + .text('Name') + .select('Runtime', ['TS', 'JS']) + .addIf( + (values) => values[1] === 'TS', + () => text('Version'), + ) + .confirm('Are you sure?') + .submit(), + ); + + expect(responses[0]).toBe('Ada'); + expect(responses[1]).toBe('TS'); + expect(responses[2]).toBe('1'); + expect(responses[3]).toBe(true); + }); + it('does not revert the first form step', async () => { const output = createMemoryOutput(); From c7e8929293a4085da34a2a47fccb35f9fb492039 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 23:53:07 +0800 Subject: [PATCH 216/675] Preserve prompt values after validation --- UPSTREAM.md | 2 + packages/tui/src/prompt/lifecycle.ts | 1 + packages/tui/tests/text.test.ts | 136 ++++++++++++++++++++++++++- 3 files changed, 138 insertions(+), 1 deletion(-) diff --git a/UPSTREAM.md b/UPSTREAM.md index 2598045d..34e3c8fd 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -15,9 +15,11 @@ TypeScript and OpenTUI internals. | Prompt lifecycle and fallback behavior | `packages/tui/src/prompt.ts`, `packages/tui/src/environment.ts`, `packages/tui/src/environment/*` | | Prompt cancellation callback lifecycle | `packages/tui/src/prompt/cancel.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 | `packages/tui/src/prompt/lifecycle.ts`, `packages/tui/tests/text.test.ts` | | Key constants 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/prompt/lifecycle.ts`, `packages/tui/src/typed-value.ts`, `packages/tui/tests/text.test.ts` | | Textarea input | `packages/tui/src/prompts/textarea.ts`, `packages/tui/src/typed-value.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` | diff --git a/packages/tui/src/prompt/lifecycle.ts b/packages/tui/src/prompt/lifecycle.ts index 1524b1b1..73a17e27 100644 --- a/packages/tui/src/prompt/lifecycle.ts +++ b/packages/tui/src/prompt/lifecycle.ts @@ -48,6 +48,7 @@ export const promptUntilValid = async (options: BasePromptOptions, read: P return value; } + options.default = value; environment.error.write(renderError(validation)); attempt += 1; } diff --git a/packages/tui/tests/text.test.ts b/packages/tui/tests/text.test.ts index 5a69773e..a9adeef5 100644 --- a/packages/tui/tests/text.test.ts +++ b/packages/tui/tests/text.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { createMemoryOutput, createScriptedInput, Key, text, withPromptEnvironment } from '#tui/index'; +import { createMemoryOutput, createScriptedInput, Key, PromptValidationError, text, withPromptEnvironment } from '#tui/index'; describe('text prompt', () => { it('renders typed values while reading raw key input', async () => { @@ -34,4 +34,138 @@ describe('text prompt', () => { expect(output.text()).toContain('? Name Jane Doe'); }); + + it('accepts default values', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.enter]), + output, + error: output, + interactive: true, + }, + () => text('Name', '', 'Jess'), + ); + + expect(result).toBe('Jess'); + }); + + it('transforms values before returning them', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([' ', 'J', 'e', 's', 's', ' ', Key.enter]), + output, + error: output, + interactive: true, + }, + () => text({ message: 'Name', transform: (value) => value.trim() }), + ); + + expect(result).toBe('Jess'); + }); + + it('retries after validation errors', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['J', 'e', 's', Key.enter, 's', Key.enter]), + output, + error: output, + interactive: true, + }, + () => text({ message: 'Name', validate: (value) => (value !== 'Jess' ? 'Invalid name.' : '') }), + ); + + expect(result).toBe('Jess'); + expect(output.text()).toContain('Invalid name.'); + }); + + it('edits values with backspace and delete keys', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['J', 'e', 'z', Key.backspace, 's', 's', Key.left, Key.delete, Key.enter]), + output, + error: output, + interactive: true, + }, + () => text('Name'), + ); + + expect(result).toBe('Jes'); + }); + + it('supports line navigation key bindings', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['A', 'r', 'c', 'h', 'e', 'r', Key.ctrlA, 'J', 'e', 's', 's', Key.ctrlE, Key.enter]), + output, + error: output, + interactive: true, + }, + () => text('Name'), + ); + + expect(result).toBe('JessArcher'); + }); + + it('returns empty strings and defaults in non-interactive mode', async () => { + await expect( + withPromptEnvironment( + { + output: createMemoryOutput(), + error: createMemoryOutput(), + interactive: false, + }, + () => text('Name'), + ), + ).resolves.toBe(''); + + await expect( + withPromptEnvironment( + { + output: createMemoryOutput(), + error: createMemoryOutput(), + interactive: false, + }, + () => text('Name', '', 'Jess'), + ), + ).resolves.toBe('Jess'); + }); + + it('validates non-interactive default values', async () => { + await expect( + withPromptEnvironment( + { + output: createMemoryOutput(), + error: createMemoryOutput(), + interactive: false, + }, + () => text({ message: 'Name', required: true }), + ), + ).rejects.toThrow(PromptValidationError); + }); + + it('handles failed empty key reads gracefully', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['', Key.enter]), + output, + error: output, + interactive: true, + }, + () => text('Name'), + ); + + expect(result).toBe(''); + }); }); From e3851269132e6c8f344326b79a85b1a6ed162245 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 23:55:10 +0800 Subject: [PATCH 217/675] Cover password prompt behavior --- UPSTREAM.md | 1 + packages/tui/tests/password.test.ts | 98 ++++++++++++++++++++++++++++- 2 files changed, 98 insertions(+), 1 deletion(-) diff --git a/UPSTREAM.md b/UPSTREAM.md index 34e3c8fd..453d0264 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -27,6 +27,7 @@ TypeScript and OpenTUI internals. | Textarea wrapped frame line windows | `packages/tui/src/typed-value/textarea-frame.ts`, `packages/tui/src/typed-value/lines.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/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/input.ts`, `packages/tui/src/prompt/lifecycle.ts`, `packages/tui/tests/password.test.ts` | | Number input | `packages/tui/src/prompts/number.ts`, `packages/tui/src/typed-value.ts` | | Confirm, select, multiselect | `packages/tui/src/prompts/select/*` | | Select active boxed layout and row styling | `packages/tui/src/prompts/select/render.ts`, `packages/tui/tests/choices.test.ts` | diff --git a/packages/tui/tests/password.test.ts b/packages/tui/tests/password.test.ts index 6d4f07b2..e961131f 100644 --- a/packages/tui/tests/password.test.ts +++ b/packages/tui/tests/password.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { createMemoryOutput, createScriptedInput, Key, password, withPromptEnvironment } from '#tui/index'; +import { createMemoryOutput, createScriptedInput, Key, password, PromptValidationError, withPromptEnvironment } from '#tui/index'; describe('password prompt', () => { it('returns typed values without rendering the raw password', async () => { @@ -73,4 +73,100 @@ describe('password prompt', () => { expect(output.text()).toContain('•••••'); expect(output.text()).toContain('••••••'); }); + + it('transforms values before returning them', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['s', 'e', 'c', 'r', 'e', 't', Key.enter]), + output, + error: output, + interactive: true, + }, + () => password({ message: 'Password', transform: (value) => value.toUpperCase() }), + ); + + expect(result).toBe('SECRET'); + expect(output.text()).not.toContain('secret'); + }); + + it('retries after validation errors without rendering the raw value', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['p', 'a', 's', Key.enter, 's', Key.enter]), + output, + error: output, + interactive: true, + }, + () => password({ message: 'Password', validate: (value) => (value.length < 4 ? 'Password must be at least 4 characters.' : '') }), + ); + + expect(result).toBe('pass'); + expect(output.text()).toContain('Password must be at least 4 characters.'); + expect(output.text()).not.toContain('pas'); + expect(output.text()).not.toContain('pass'); + }); + + it('edits values with backspace and delete keys without rendering the raw value', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['p', 'a', 'z', Key.backspace, 's', 's', Key.left, Key.delete, Key.enter]), + output, + error: output, + interactive: true, + }, + () => password('Password'), + ); + + expect(result).toBe('pas'); + expect(output.text()).not.toContain('pas'); + expect(output.text()).toContain('•••'); + }); + + it('renders cancellation without exposing the raw value', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['s', 'e', 'c', Key.ctrlC]), + output, + error: output, + interactive: true, + }, + () => password('Password'), + ); + + expect(result).toBe('sec'); + expect(output.text()).toContain('Cancelled.'); + expect(output.text()).not.toContain('sec'); + }); + + it('returns empty strings and validates required values in non-interactive mode', async () => { + await expect( + withPromptEnvironment( + { + output: createMemoryOutput(), + error: createMemoryOutput(), + interactive: false, + }, + () => password('Password'), + ), + ).resolves.toBe(''); + + await expect( + withPromptEnvironment( + { + output: createMemoryOutput(), + error: createMemoryOutput(), + interactive: false, + }, + () => password({ message: 'Password', required: true }), + ), + ).rejects.toThrow(PromptValidationError); + }); }); From 573262fc8ca5b0f18657dfd9d64d149a536714af Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 23:57:02 +0800 Subject: [PATCH 218/675] Skip pause output when non-interactive --- UPSTREAM.md | 1 + packages/tui/src/prompts/pause.ts | 8 ++++++-- packages/tui/tests/pause.test.ts | 9 ++++++--- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/UPSTREAM.md b/UPSTREAM.md index 453d0264..1e58299f 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -50,6 +50,7 @@ TypeScript and OpenTUI internals. | Multi-search submitted final frame | `packages/tui/src/prompts/search/render.ts`, `packages/tui/src/prompts/search/read-multi.ts`, `packages/tui/tests/search.test.ts` | | Multi-search cancel final frame | `packages/tui/src/prompts/search/render.ts`, `packages/tui/src/prompts/search/read-multi.ts`, `packages/tui/tests/search.test.ts` | | Pause | `packages/tui/src/prompts/pause.ts` | +| Pause non-interactive no-render behavior | `packages/tui/src/prompts/pause.ts`, `packages/tui/tests/pause.test.ts` | | Choice normalization, matching, navigation, and list rendering concerns | `packages/tui/src/concerns/choices.ts` | | Choice and search scrollbar rendering | `packages/tui/src/concerns/scrollbar.ts`, `packages/tui/src/concerns/choices.ts`, `packages/tui/src/prompts/search/render.ts` | | Form builder | `packages/tui/src/form.ts`, `packages/tui/src/form/builder/*` | diff --git a/packages/tui/src/prompts/pause.ts b/packages/tui/src/prompts/pause.ts index e05c1692..cb3568b7 100644 --- a/packages/tui/src/prompts/pause.ts +++ b/packages/tui/src/prompts/pause.ts @@ -6,10 +6,14 @@ import { renderQuestion } from '#tui/theme'; export const pause = async (message = 'Press enter to continue...'): Promise => { const environment = promptEnvironment(); + if (!environment.interactive) { + return false; + } + if (!environment.input.readKey) { await ask(message); - return environment.interactive; + return true; } environment.output.write(renderQuestion(message)); @@ -20,7 +24,7 @@ export const pause = async (message = 'Press enter to continue...'): Promise { }); it('returns false when the configured environment is non-interactive', async () => { + const output = createMemoryOutput(); + const result = await withPromptEnvironment( { input: createScriptedInput([Key.enter]), - output: createMemoryOutput(), - error: createMemoryOutput(), + output, + error: output, interactive: false, }, - () => pause('Continue'), + () => pause('This should not be rendered'), ); expect(result).toBe(false); + expect(output.text()).not.toContain('This should not be rendered'); }); }); From e5445948923b8cea8aa21e774a2c9e71eaea2d2b Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 11 Jun 2026 23:59:31 +0800 Subject: [PATCH 219/675] Render notes with default theme styles --- UPSTREAM.md | 1 + packages/tui/src/output/notes.ts | 42 +++++++++++++++++++++++-------- packages/tui/src/theme/styles.ts | 12 +++++++++ packages/tui/tests/output.test.ts | 32 ++++++++++++++++++++++- 4 files changed, 75 insertions(+), 12 deletions(-) diff --git a/UPSTREAM.md b/UPSTREAM.md index 1e58299f..7b586633 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -57,6 +57,7 @@ TypeScript and OpenTUI internals. | 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` | | Notes and alert-style output | `packages/tui/src/output/notes.ts` | +| Note multiline and typed theme rendering | `packages/tui/src/output/notes.ts`, `packages/tui/src/theme/styles.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/data-table.ts` | diff --git a/packages/tui/src/output/notes.ts b/packages/tui/src/output/notes.ts index 82bda5e0..c7f85f45 100644 --- a/packages/tui/src/output/notes.ts +++ b/packages/tui/src/output/notes.ts @@ -1,35 +1,55 @@ import { promptEnvironment } from '#tui/environment'; -import { symbols } from '#tui/theme'; +import { backgroundCyan, backgroundRed, black, green, red, white, yellow } from '#tui/theme/styles'; export type NoteType = 'alert' | 'error' | 'info' | 'intro' | 'outro' | 'warning' | string; -const notePrefix = (type?: NoteType | null): string => { +const paddedIntroLines = (lines: string[]): string[] => { + const padded = lines.map((line) => ` ${line} `); + const longest = Math.max(...padded.map((line) => line.length)); + + return padded.map((line) => line.padEnd(longest, ' ')); +}; + +const renderNoteLine = (line: string, type?: NoteType | null): string => { switch (type) { - case 'error': - return symbols.error; + case 'intro': + + case 'outro': + return ` ${backgroundCyan(black(line))}`; case 'warning': + return yellow(` ${line}`); + + case 'error': + return red(` ${line}`); case 'alert': - return symbols.warning; + return ` ${backgroundRed(white(` ${line} `))}`; case 'info': - return symbols.info; + return green(` ${line}`); + + default: + return ` ${line}`; + } +}; + +const renderNoteLines = (message: string, type?: NoteType | null): string[] => { + const lines = message.split('\n'); + switch (type) { case 'intro': case 'outro': - return symbols.success; + return paddedIntroLines(lines).map((line) => renderNoteLine(line, type)); default: - return symbols.info; + return lines.map((line) => renderNoteLine(line, type)); } }; export const note = (message: string, type?: NoteType | null): void => { - const prefix = notePrefix(type); - - promptEnvironment().output.write(`${prefix} ${message}\n`); + promptEnvironment().output.write(`${renderNoteLines(message, type).join('\n')}\n`); }; export const error = (message: string): void => note(message, 'error'); diff --git a/packages/tui/src/theme/styles.ts b/packages/tui/src/theme/styles.ts index 3998388c..676490b3 100644 --- a/packages/tui/src/theme/styles.ts +++ b/packages/tui/src/theme/styles.ts @@ -10,12 +10,24 @@ export const dim = (value: string): string => ansi(2, 22, value); export const cyan = (value: string): string => ansi(36, 39, value); +export const green = (value: string): string => ansi(32, 39, value); + export const inverse = (value: string): string => ansi(7, 27, value); +export const black = (value: string): string => ansi(30, 39, value); + export const red = (value: string): string => ansi(31, 39, value); export const strikethrough = (value: string): string => ansi(9, 29, value); +export const white = (value: string): string => ansi(37, 39, value); + +export const yellow = (value: string): string => ansi(33, 39, value); + +export const backgroundCyan = (value: string): string => ansi(46, 49, value); + +export const backgroundRed = (value: string): string => ansi(41, 49, value); + export const foregroundRgb = (value: string, red: number, green: number, blue: number): string => { if (value.length === 0) { return ''; diff --git a/packages/tui/tests/output.test.ts b/packages/tui/tests/output.test.ts index d1d6e338..f787bc55 100644 --- a/packages/tui/tests/output.test.ts +++ b/packages/tui/tests/output.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { alert, createMemoryOutput, error, grid, info, intro, note, outro, table, warning, withPromptEnvironment } from '#tui/index'; +import { alert, createMemoryOutput, error, grid, info, intro, note, outro, parseAnsiText, table, warning, withPromptEnvironment } from '#tui/index'; import { renderGrid } from '#tui/output/grid'; describe('output helpers', () => { @@ -20,6 +20,36 @@ describe('output helpers', () => { expect(output.text()).toContain('Done'); }); + it('renders plain multiline notes without symbol prefixes', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + note('Hello\nWorld'); + }); + + expect(output.text()).toBe(' Hello\n World\n'); + }); + + it('renders typed note helpers with line-oriented styling', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + warning('Careful'); + error('Nope'); + alert('Heads up'); + info('Facts'); + intro('Start\nLonger'); + outro('Done'); + }); + + expect(parseAnsiText(output.text())).toBe(' Careful\n Nope\n Heads up \n Facts\n Start \n Longer \n Done \n'); + expect(output.text()).toContain('\u001B[33m Careful\u001B[39m'); + expect(output.text()).toContain('\u001B[31m Nope\u001B[39m'); + expect(output.text()).toContain('\u001B[41m\u001B[37m Heads up \u001B[39m\u001B[49m'); + expect(output.text()).toContain('\u001B[32m Facts\u001B[39m'); + expect(output.text()).toContain('\u001B[46m\u001B[30m Start \u001B[39m\u001B[49m'); + }); + it('renders table and boxed grid helpers without returning a value', async () => { const output = createMemoryOutput(); From 832499864ef7c3dbcbb3612b824d2298869680dd Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Fri, 12 Jun 2026 00:01:55 +0800 Subject: [PATCH 220/675] Cover textarea prompt behavior --- UPSTREAM.md | 1 + packages/tui/tests/textarea.test.ts | 136 +++++++++++++++++++++++++++- 2 files changed, 136 insertions(+), 1 deletion(-) diff --git a/UPSTREAM.md b/UPSTREAM.md index 7b586633..b8377e63 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -21,6 +21,7 @@ TypeScript and OpenTUI internals. | 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/prompt/lifecycle.ts`, `packages/tui/src/typed-value.ts`, `packages/tui/tests/text.test.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/tests/textarea.test.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/tests/textarea.test.ts` | diff --git a/packages/tui/tests/textarea.test.ts b/packages/tui/tests/textarea.test.ts index a8483589..cd526f8a 100644 --- a/packages/tui/tests/textarea.test.ts +++ b/packages/tui/tests/textarea.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { createMemoryOutput, createScriptedInput, Key, textarea, withPromptEnvironment } from '#tui/index'; +import { createMemoryOutput, createScriptedInput, Key, PromptValidationError, textarea, withPromptEnvironment } from '#tui/index'; describe('textarea prompt', () => { it('accepts multiline input and submits with ctrl-d', async () => { @@ -39,6 +39,71 @@ describe('textarea prompt', () => { expect(output.text()).toContain('\u001B[2mType here\u001B[22m'); }); + it('accepts default values', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.ctrlD]), + output, + error: output, + interactive: true, + }, + () => textarea('Description', '', 'Jess\nJoe'), + ); + + expect(result).toBe('Jess\nJoe'); + }); + + it('transforms values before returning them', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([' ', 'J', 'e', 's', 's', ' ', Key.ctrlD]), + output, + error: output, + interactive: true, + }, + () => textarea({ message: 'Description', transform: (value) => value.trim() }), + ); + + expect(result).toBe('Jess'); + }); + + it('retries after validation errors', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['J', 'e', 's', Key.ctrlD, 's', Key.ctrlD]), + output, + error: output, + interactive: true, + }, + () => textarea({ message: 'Description', validate: (value) => (value !== 'Jess' ? 'Invalid name.' : '') }), + ); + + expect(result).toBe('Jess'); + expect(output.text()).toContain('Invalid name.'); + }); + + it('edits values with backspace and delete keys', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['J', 'e', 'z', Key.backspace, 's', 's', Key.left, Key.delete, Key.ctrlD]), + output, + error: output, + interactive: true, + }, + () => textarea('Description'), + ); + + expect(result).toBe('Jes'); + }); + it('keeps rendered textarea output within the configured row window', async () => { const output = createMemoryOutput(); @@ -150,4 +215,73 @@ describe('textarea prompt', () => { expect(result).toBe('Jess\nJoe'); }); + + it('moves through ascending textarea line lengths', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['a', Key.enter, 'b', 'c', Key.enter, 'd', 'e', 'f', Key.up, Key.up, Key.down, 'g', Key.ctrlD]), + output, + error: output, + interactive: true, + }, + () => textarea('Description'), + ); + + expect(result).toBe('a\nbgc\ndef'); + }); + + it('moves through descending textarea line lengths', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['a', 'b', 'c', Key.enter, 'd', 'e', Key.enter, 'f', Key.up, Key.up, Key.right, Key.right, Key.down, 'g', Key.ctrlD]), + output, + error: output, + interactive: true, + }, + () => textarea('Description'), + ); + + expect(result).toBe('abc\ndeg\nf'); + }); + + it('returns empty strings and defaults in non-interactive mode', async () => { + await expect( + withPromptEnvironment( + { + output: createMemoryOutput(), + error: createMemoryOutput(), + interactive: false, + }, + () => textarea('Description'), + ), + ).resolves.toBe(''); + + await expect( + withPromptEnvironment( + { + output: createMemoryOutput(), + error: createMemoryOutput(), + interactive: false, + }, + () => textarea('Description', '', 'Jess'), + ), + ).resolves.toBe('Jess'); + }); + + it('validates non-interactive default values', async () => { + await expect( + withPromptEnvironment( + { + output: createMemoryOutput(), + error: createMemoryOutput(), + interactive: false, + }, + () => textarea({ message: 'Description', required: true }), + ), + ).rejects.toThrow(PromptValidationError); + }); }); From 49e054d85fdf3aa33fd499cbecaf7c8a0ff80fdd Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Fri, 12 Jun 2026 12:32:12 +0800 Subject: [PATCH 221/675] Render confirm prompts with themed frames --- UPSTREAM.md | 1 + .../tui/src/prompts/select/read-confirm.ts | 23 ++-- .../tui/src/prompts/select/render-confirm.ts | 37 ++++++ packages/tui/tests/choices.test.ts | 114 +++++++++++++++++- 4 files changed, 160 insertions(+), 15 deletions(-) create mode 100644 packages/tui/src/prompts/select/render-confirm.ts diff --git a/UPSTREAM.md b/UPSTREAM.md index b8377e63..e047f410 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -31,6 +31,7 @@ TypeScript and OpenTUI internals. | Password input transforms, validation retry, editing keys, cancellation, masking, and non-interactive behavior | `packages/tui/src/prompts/password.ts`, `packages/tui/src/prompts/password/input.ts`, `packages/tui/src/prompt/lifecycle.ts`, `packages/tui/tests/password.test.ts` | | Number input | `packages/tui/src/prompts/number.ts`, `packages/tui/src/typed-value.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/render-confirm.ts`, `packages/tui/tests/choices.test.ts` | | Select active boxed layout and row styling | `packages/tui/src/prompts/select/render.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/tests/choices.test.ts` | | Select non-interactive default validation | `packages/tui/src/prompts/select/index.ts`, `packages/tui/tests/choices.test.ts` | diff --git a/packages/tui/src/prompts/select/read-confirm.ts b/packages/tui/src/prompts/select/read-confirm.ts index f10764de..6f2983d8 100644 --- a/packages/tui/src/prompts/select/read-confirm.ts +++ b/packages/tui/src/prompts/select/read-confirm.ts @@ -2,18 +2,11 @@ import { promptEnvironment } from '#tui/environment'; import { Key } from '#tui/key'; import { ask, cancelPrompt } from '#tui/prompt'; import { rejectPromptRevert } from '#tui/prompt/revert'; -import { renderQuestion } from '#tui/theme'; +import { renderActiveConfirm, renderCancelledConfirm, renderSubmittedConfirm } from '#tui/prompts/select/render-confirm'; import type { ConfirmPromptOptions } from '#tui/types'; const toggleKeys = 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']); -const renderConfirm = (options: ConfirmPromptOptions, confirmed: boolean): void => { - const yes = confirmed ? `[${options.yes ?? 'Yes'}]` : ` ${options.yes ?? 'Yes'} `; - const no = confirmed ? ` ${options.no ?? 'No'} ` : `[${options.no ?? 'No'}]`; - - promptEnvironment().output.write(`${renderQuestion(options.message, options.hint)}${yes} / ${no}\n`); -}; - export const readConfirm = async (options: ConfirmPromptOptions): Promise => { const environment = promptEnvironment(); @@ -31,12 +24,14 @@ export const readConfirm = async (options: ConfirmPromptOptions): Promise { + return confirmed ? (options.yes ?? 'Yes') : (options.no ?? 'No'); +}; + +const renderConfirmOptions = (options: ConfirmPromptOptions, confirmed: boolean): string => { + const yes = options.yes ?? 'Yes'; + const no = options.no ?? 'No'; + + return confirmed ? `${green('●')} ${yes} ${dim(`/ ○ ${no}`)}` : `${dim(`○ ${yes} /`)} ${green('●')} ${no}`; +}; + +const renderCancelledConfirmOptions = (options: ConfirmPromptOptions, confirmed: boolean): string => { + const yes = strikethrough(options.yes ?? 'Yes'); + const no = strikethrough(options.no ?? 'No'); + + return confirmed ? dim(`● ${yes} / ○ ${no}`) : dim(`○ ${yes} / ● ${no}`); +}; + +export const renderActiveConfirm = (options: ConfirmPromptOptions, confirmed: boolean): void => { + promptEnvironment().output.write(`${renderBox({ body: renderConfirmOptions(options, confirmed), borderStyle: cyan, info: options.hint, title: cyan(options.message) })}\n`); +}; + +export const renderSubmittedConfirm = (options: ConfirmPromptOptions, confirmed: boolean): void => { + promptEnvironment().output.write(`${renderBox({ body: confirmLabel(options, confirmed), title: dim(options.message) })}\n`); +}; + +export const renderCancelledConfirm = (options: ConfirmPromptOptions, confirmed: boolean): void => { + const environment = promptEnvironment(); + + environment.output.write(`${renderBox({ body: renderCancelledConfirmOptions(options, confirmed), borderStyle: red, title: options.message })}\n`); + environment.error.write(`${red(' ⚠ Cancelled.')}\n`); +}; diff --git a/packages/tui/tests/choices.test.ts b/packages/tui/tests/choices.test.ts index a52ac2b9..03284c6d 100644 --- a/packages/tui/tests/choices.test.ts +++ b/packages/tui/tests/choices.test.ts @@ -71,8 +71,118 @@ describe('choice prompts', () => { ); expect(result).toBe(true); - expect(output.text()).toContain('[Yes]'); - expect(output.text()).toContain('[No]'); + expect(output.text()).toContain('\u001B[36m ┌\u001B[39m \u001B[36mContinue?\u001B[39m '); + expect(output.text()).toContain('\u001B[32m●\u001B[39m Yes'); + expect(output.text()).toContain('\u001B[32m●\u001B[39m No'); + expect(output.text()).toContain('┌ \u001B[2mContinue?\u001B[22m '); + }); + + it('renders custom confirm labels in active and submitted frames', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.enter]), + output, + error: output, + interactive: true, + }, + () => confirm('¿Listo?', true, 'Sí, por favor', 'No, gracias'), + ); + + expect(result).toBe(true); + expect(output.text()).toContain('Sí, por favor'); + expect(output.text()).toContain('No, gracias'); + expect(output.text()).toContain('┌ \u001B[2m¿Listo?\u001B[22m '); + }); + + it('renders cancelled confirm frames with the current value', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.ctrlC]), + output, + error: output, + interactive: true, + }, + () => confirm('Continue?'), + ); + + expect(result).toBe(false); + expect(output.text()).toContain('Cancelled.'); + expect(output.text()).toContain('\u001B[31m ┌\u001B[39m Continue? '); + expect(parseAnsiText(output.text())).toContain('○ Yes / ● No'); + }); + + it('reads line-mode confirm answers when raw keys are unavailable', async () => { + const output = createMemoryOutput(); + const questions: string[] = []; + + const accepted = await withPromptEnvironment( + { + input: { + async readLine(question) { + questions.push(question); + + return ' absolutely '; + }, + }, + output, + error: output, + interactive: true, + }, + () => confirm('Continue?', false, 'Absolutely', 'Nope'), + ); + + const declined = await withPromptEnvironment( + { + input: { + async readLine(question) { + questions.push(question); + + return 'no'; + }, + }, + output, + error: output, + interactive: true, + }, + () => confirm('Continue?'), + ); + + expect(accepted).toBe(true); + expect(declined).toBe(false); + expect(questions).toEqual(['? Continue? [y/N] ', '? Continue? [Y/n] ']); + }); + + it('returns and validates confirm defaults in non-interactive mode', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([]), + output, + error: output, + interactive: false, + }, + () => confirm('Continue?', false), + ); + + await expect( + withPromptEnvironment( + { + input: createScriptedInput([]), + output, + error: output, + interactive: false, + }, + () => confirm('Continue?', false, 'Yes', 'No', true), + ), + ).rejects.toThrow(PromptValidationError); + + expect(result).toBe(false); + expect(output.text()).toBe(''); }); it('treats false confirm answers as invalid when required', async () => { From 68a8c040a319303428a9b9af80b0397e8b22b551 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Fri, 12 Jun 2026 12:35:53 +0800 Subject: [PATCH 222/675] Preserve form defaults after skipped responses --- UPSTREAM.md | 1 + packages/tui/src/form/builder/previous.ts | 6 ++++- .../tui/src/form/builder/prompts/choices.ts | 8 +++---- .../src/form/builder/validators/previous.ts | 7 ++++++ packages/tui/tests/form.test.ts | 23 +++++++++++++++++++ 5 files changed, 40 insertions(+), 5 deletions(-) diff --git a/UPSTREAM.md b/UPSTREAM.md index e047f410..0fd1fb9e 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -80,6 +80,7 @@ TypeScript and OpenTUI internals. | Form builder suggest and autocomplete placeholder/default arguments | `packages/tui/src/form/builder/prompts/choices.ts`, `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/contracts/basic.ts`, `packages/tui/src/prompts/number.ts`, `packages/tui/tests/form.test.ts`, `packages/tui/tests/number.test.ts` | | Form builder search and multisearch previous-response defaults | `packages/tui/src/form/builder/prompts/choices.ts`, `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/validators/previous.ts`, `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/tests/output.test.ts`, `packages/tui/tests/form.test.ts` | | Progress boxed frame rendering and formatted fractions | `packages/tui/src/status/progress/render.ts`, `packages/tui/src/theme/box.ts` | | Spinner static and deterministic animated frame rendering | `packages/tui/src/status/spinner.ts`, `packages/tui/src/status/spinner/render.ts` | diff --git a/packages/tui/src/form/builder/previous.ts b/packages/tui/src/form/builder/previous.ts index ca45bd1d..9df58152 100644 --- a/packages/tui/src/form/builder/previous.ts +++ b/packages/tui/src/form/builder/previous.ts @@ -1,4 +1,4 @@ -import { parsePreviousArray, parsePreviousBoolean, parsePreviousNumber, parsePreviousString } from '#tui/form/builder/validators/previous'; +import { parsePreviousArray, parsePreviousBoolean, parsePreviousNumber, parsePreviousString, parsePreviousValue } from '#tui/form/builder/validators/previous'; export const previousString = (previous: unknown, defaultValue: string): string => { return parsePreviousString(previous, defaultValue); @@ -15,3 +15,7 @@ export const previousArray = (previous: unknown, defaultValue: T[]): T[] => { export const previousBoolean = (previous: unknown, defaultValue: boolean): boolean => { return parsePreviousBoolean(previous, defaultValue); }; + +export const previousValue = (previous: unknown, defaultValue: T): T => { + return parsePreviousValue(previous, defaultValue); +}; diff --git a/packages/tui/src/form/builder/prompts/choices.ts b/packages/tui/src/form/builder/prompts/choices.ts index e8f336af..ea4968fb 100644 --- a/packages/tui/src/form/builder/prompts/choices.ts +++ b/packages/tui/src/form/builder/prompts/choices.ts @@ -1,5 +1,5 @@ import { autocomplete, confirm, multiselect, multisearch, pause, search, select, suggest } from '#tui/prompts/choices'; -import { previousArray, previousBoolean, previousString } from '#tui/form/builder/previous'; +import { previousArray, previousBoolean, previousString, previousValue } from '#tui/form/builder/previous'; import { isSearchPromptLabel } from '#tui/form/builder/prompts/validators/search'; import type { FormBuilder } from '#tui/form/builder/index'; import type { ChoicePromptBuilderMethods } from '#tui/form/builder/prompts/types'; @@ -69,7 +69,7 @@ export const choicePromptBuilderMethods: ChoicePromptBuilderMethods & ThisType['transform'] = undefined, ) { if (!isSearchPromptLabel(optionsOrLabel)) { - return this.add((_, previous) => search({ ...optionsOrLabel, default: previous === undefined ? optionsOrLabel.default : (previous as T) }), options as string | undefined); + return this.add((_, previous) => search({ ...optionsOrLabel, default: previousValue(previous, optionsOrLabel.default) }), options as string | undefined); } const promptOptions: SearchPromptOptions = { @@ -84,10 +84,10 @@ export const choicePromptBuilderMethods: ChoicePromptBuilderMethods & ThisType search({ ...promptOptions, default: previous === undefined ? promptOptions.default : (previous as T) }), name); + return this.add((_, previous) => search({ ...promptOptions, default: previousValue(previous, promptOptions.default) }), name); }, select(label: string, options: ChoiceOptions, defaultValue?: T, scroll = 5, validate = undefined, hint = '', required: boolean | string = true, name?: string, transform = undefined) { - return this.add((_, previous) => select({ message: label, options, default: previous === undefined ? defaultValue : (previous as T), scroll, validate, hint, required, transform }), name); + return this.add((_, previous) => select({ message: label, options, default: previousValue(previous, defaultValue), scroll, validate, hint, required, transform }), name); }, suggest(label, options, placeholder = '', defaultValue = '', scroll = 5, required = false, validate = undefined, hint = '', name, transform = undefined) { return this.add((_, previous) => suggest({ message: label, label, options, placeholder, default: previousString(previous, defaultValue), scroll, required, validate, hint, transform }), name); diff --git a/packages/tui/src/form/builder/validators/previous.ts b/packages/tui/src/form/builder/validators/previous.ts index abef437b..85f2954a 100644 --- a/packages/tui/src/form/builder/validators/previous.ts +++ b/packages/tui/src/form/builder/validators/previous.ts @@ -3,6 +3,7 @@ import { z } from 'zod'; const previousNumberSchema = z.union([z.number(), z.string()]); const previousArraySchema = z.array(z.unknown()); const previousBooleanSchema = z.boolean(); +const previousValueSchema = z.unknown().refine((value) => value !== undefined && value !== null); export const parsePreviousString = (previous: unknown, defaultValue: string): string => { return previous === undefined || previous === null ? defaultValue : String(previous); @@ -25,3 +26,9 @@ export const parsePreviousBoolean = (previous: unknown, defaultValue: boolean): return parsed.success ? parsed.data : defaultValue; }; + +export const parsePreviousValue = (previous: unknown, defaultValue: T): T => { + const parsed = previousValueSchema.safeParse(previous); + + return parsed.success ? (parsed.data as T) : defaultValue; +}; diff --git a/packages/tui/tests/form.test.ts b/packages/tui/tests/form.test.ts index 5a479a98..5254a053 100644 --- a/packages/tui/tests/form.test.ts +++ b/packages/tui/tests/form.test.ts @@ -240,6 +240,29 @@ describe('form builder', () => { expect(responses[3]).toBe(true); }); + it('uses declared defaults when named choice steps follow skipped conditional responses', async () => { + const output = createMemoryOutput(); + + const responses = await withPromptEnvironment( + { + input: createScriptedInput([Key.enter]), + output, + error: output, + interactive: true, + }, + () => + form() + .addIf(false, () => 'skipped', 'choice') + .select('Choice', { a: 'A', b: 'B' }, 'b', 5, undefined, '', true, 'choice') + .addIf(false, () => 'skipped', 'searched') + .search({ message: 'Search', options: { red: 'Red', blue: 'Blue' }, default: 'blue' }, 'searched') + .submit(), + ); + + expect(responses.choice).toBe('b'); + expect(responses.searched).toBe('blue'); + }); + it('does not revert the first form step', async () => { const output = createMemoryOutput(); From 3fe96ab90fa1366875f6673aa2744003f3706a1e Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Fri, 12 Jun 2026 12:39:57 +0800 Subject: [PATCH 223/675] Render number prompts with themed frames --- UPSTREAM.md | 1 + packages/tui/src/prompts/number.ts | 22 ++++++++++-- packages/tui/src/prompts/number/input.ts | 33 ++++++++++++----- packages/tui/src/prompts/number/render.ts | 37 +++++++++++++++++-- packages/tui/tests/number.test.ts | 44 +++++++++++++++++------ 5 files changed, 113 insertions(+), 24 deletions(-) diff --git a/UPSTREAM.md b/UPSTREAM.md index 0fd1fb9e..1198ccca 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -30,6 +30,7 @@ TypeScript and OpenTUI internals. | 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/input.ts`, `packages/tui/src/prompt/lifecycle.ts`, `packages/tui/tests/password.test.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/prompts/number.ts`, `packages/tui/src/prompts/number/input.ts`, `packages/tui/src/prompts/number/render.ts`, `packages/tui/tests/number.test.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/render-confirm.ts`, `packages/tui/tests/choices.test.ts` | | Select active boxed layout and row styling | `packages/tui/src/prompts/select/render.ts`, `packages/tui/tests/choices.test.ts` | diff --git a/packages/tui/src/prompts/number.ts b/packages/tui/src/prompts/number.ts index 98b874d4..00c0efd6 100644 --- a/packages/tui/src/prompts/number.ts +++ b/packages/tui/src/prompts/number.ts @@ -1,5 +1,7 @@ +import { promptEnvironment } from '#tui/environment'; import { promptUntilValid, PromptValidationError } from '#tui/prompt'; import { readNumberValue } from '#tui/prompts/number/input'; +import { renderSubmittedNumberValue } from '#tui/prompts/number/render'; import { parseNumberInput } from '#tui/prompts/number/validators/value'; import type { NumberPromptOptions } from '#tui/types'; @@ -36,6 +38,8 @@ export async function number( : { ...message, default: message.default ?? '' }; return promptUntilValid(options, async () => { + const shouldRenderSubmittedFrame = Boolean(promptEnvironment().input.readKey); + const answer = await readNumberValue(options.message, { default: options.default, hint: options.hint, @@ -45,16 +49,28 @@ export async function number( step: options.step, }); - if (answer === '' && options.default !== undefined) { + const value = answer.value; + + if (value === '' && options.default !== undefined) { + if (shouldRenderSubmittedFrame && !answer.cancelled) { + renderSubmittedNumberValue(options.message, options.default); + } + return options.default; } - const result = parseNumberInput(answer, options); + const result = parseNumberInput(value, options); if (result.error !== undefined) { throw new PromptValidationError(result.error); } - return options.transform ? options.transform(result.value ?? '') : (result.value ?? ''); + const parsedValue = result.value ?? ''; + + if (shouldRenderSubmittedFrame && !answer.cancelled) { + renderSubmittedNumberValue(options.message, parsedValue); + } + + return options.transform ? options.transform(parsedValue) : parsedValue; }); } diff --git a/packages/tui/src/prompts/number/input.ts b/packages/tui/src/prompts/number/input.ts index 0b910778..a5100041 100644 --- a/packages/tui/src/prompts/number/input.ts +++ b/packages/tui/src/prompts/number/input.ts @@ -3,11 +3,16 @@ import { Key } from '#tui/key'; import { cancelPrompt, PromptValidationError } from '#tui/prompt'; import { renderQuestion } from '#tui/theme'; import { applyTypedKey } from '#tui/typed-value'; -import { renderNumberValue } from '#tui/prompts/number/render'; +import { renderCancelledNumberValue, renderNumberValue } from '#tui/prompts/number/render'; import { steppedNumberValue } from '#tui/prompts/number/step'; import type { NumberInputOptions } from '#tui/prompts/number/types'; -export const readNumberValue = async (message: string, options: NumberInputOptions = {}): Promise => { +export type NumberReadResult = { + cancelled: boolean; + value: string; +}; + +export const readNumberValue = async (message: string, options: NumberInputOptions = {}): Promise => { const environment = promptEnvironment(); if (!environment.input.readKey) { @@ -17,7 +22,10 @@ export const readNumberValue = async (message: string, options: NumberInputOptio const answer = await environment.input.readLine(renderQuestion(message, options.hint)); - return answer === '' && options.default !== undefined ? String(options.default) : answer; + return { + cancelled: false, + value: answer === '' && options.default !== undefined ? String(options.default) : answer, + }; } let state = { @@ -31,7 +39,10 @@ export const readNumberValue = async (message: string, options: NumberInputOptio const key = await environment.input.readKey(); if (key === null) { - return state.value; + return { + cancelled: false, + value: state.value, + }; } if (key === Key.up || key === Key.upArrow) { @@ -51,9 +62,12 @@ export const readNumberValue = async (message: string, options: NumberInputOptio const next = applyTypedKey(state, key); if (next.cancelled) { - environment.error.write('Cancelled.\n'); + renderCancelledNumberValue(message, state.value, options); - return cancelPrompt(state.value); + return { + cancelled: true, + value: await cancelPrompt(state.value), + }; } state = { @@ -62,9 +76,10 @@ export const readNumberValue = async (message: string, options: NumberInputOptio }; if (next.submitted) { - environment.output.write('\n'); - - return state.value; + return { + cancelled: false, + value: state.value, + }; } renderNumberValue(message, state.value, options); diff --git a/packages/tui/src/prompts/number/render.ts b/packages/tui/src/prompts/number/render.ts index 638d7b21..d67d7043 100644 --- a/packages/tui/src/prompts/number/render.ts +++ b/packages/tui/src/prompts/number/render.ts @@ -1,9 +1,42 @@ import { promptEnvironment } from '#tui/environment'; -import { renderQuestion } from '#tui/theme'; +import { renderBox } from '#tui/theme/box'; +import { cyan, dim, red, strikethrough } from '#tui/theme/styles'; 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 numeric = value !== '' && !Number.isNaN(Number(value)); + const number = Number(value); + const up = numeric && options.max !== undefined && number >= options.max ? dim(UP_ARROW) : style(UP_ARROW); + const down = numeric && options.min !== undefined && number <= options.min ? dim(DOWN_ARROW) : style(DOWN_ARROW); + + if (value !== '' && !numeric) { + return `${dim(UP_ARROW)}${dim(DOWN_ARROW)}`; + } + + return `${up}${down}`; +}; + +const renderNumberBody = (value: string, options: NumberInputOptions, style?: (text: string) => string): string => { + const displayValue = value.length > 0 ? value : dim(options.placeholder ?? ''); + + return `${displayValue} ${renderNumberArrows(value, options, style)}`; +}; + export const renderNumberValue = (message: string, value: string, options: NumberInputOptions): void => { + promptEnvironment().output.write(`${renderBox({ body: renderNumberBody(value, options), borderStyle: cyan, info: options.hint, title: cyan(message) })}\n`); +}; + +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 ?? ''); - promptEnvironment().output.write(`${renderQuestion(message, options.hint)}${displayValue}\n`); + environment.output.write(`${renderBox({ body: strikethrough(dim(displayValue)), borderStyle: red, title: message })}\n`); + environment.error.write(`${red(' ⚠ Cancelled.')}\n`); }; diff --git a/packages/tui/tests/number.test.ts b/packages/tui/tests/number.test.ts index 4ce7ed23..a4400d01 100644 --- a/packages/tui/tests/number.test.ts +++ b/packages/tui/tests/number.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { createMemoryOutput, createScriptedInput, Key, number, PromptValidationError, withPromptEnvironment } from '#tui/index'; +import { createMemoryOutput, createScriptedInput, Key, number, parseAnsiText, PromptValidationError, withPromptEnvironment } from '#tui/index'; import { parseNumberInput } from '#tui/prompts/number/validators/value'; describe('number prompt', () => { @@ -65,7 +65,9 @@ describe('number prompt', () => { () => number('Count', '0'), ); - expect(output.text()).toContain('? Count 0'); + expect(output.text()).toContain('\u001B[36m ┌\u001B[39m \u001B[36mCount\u001B[39m '); + expect(parseAnsiText(output.text())).toContain('0 ▲▼'); + expect(output.text()).toContain('┌ \u001B[2mCount\u001B[22m '); }); it('renders typed number values while reading raw key input', async () => { @@ -82,8 +84,9 @@ describe('number prompt', () => { ); expect(result).toBe(42); - expect(output.text()).toContain('? Count 4'); - expect(output.text()).toContain('? Count 42'); + expect(parseAnsiText(output.text())).toContain('4 ▲▼'); + expect(parseAnsiText(output.text())).toContain('42 ▲▼'); + expect(output.text()).toContain('┌ \u001B[2mCount\u001B[22m '); }); it('requires number input when configured', async () => { @@ -173,7 +176,7 @@ describe('number prompt', () => { ); expect(result).toBe(1); - expect(output.text()).toContain('? Count 1'); + expect(parseAnsiText(output.text())).toContain('1 ▲▼'); }); it('uses the max value when decrementing from an empty number input', async () => { @@ -190,7 +193,8 @@ describe('number prompt', () => { ); expect(result).toBe(9); - expect(output.text()).toContain('? Count 9'); + expect(parseAnsiText(output.text())).toContain('9 ▲▼'); + expect(output.text()).toContain('\u001B[2m▲\u001B[22m▼'); }); it('does not increment or decrement with control navigation keys', async () => { @@ -207,7 +211,7 @@ describe('number prompt', () => { ); expect(result).toBe(''); - expect(output.text()).not.toContain('? Count 1'); + expect(parseAnsiText(output.text())).not.toContain('1 ▲▼'); }); it('increments and decrements decimal values with whole steps', async () => { @@ -224,8 +228,8 @@ describe('number prompt', () => { ); expect(result).toBe(0); - expect(output.text()).toContain('? Amount 2'); - expect(output.text()).toContain('? Amount 0'); + expect(parseAnsiText(output.text())).toContain('2 ▲▼'); + expect(parseAnsiText(output.text())).toContain('0 ▲▼'); }); it('falls back to a whole step for invalid step sizes', async () => { @@ -258,7 +262,8 @@ describe('number prompt', () => { ); expect(result).toBe(7); - expect(output.text()).toContain('? Count 7'); + expect(parseAnsiText(output.text())).toContain('7 ▲▼'); + expect(output.text()).toContain('┌ \u001B[2mCount\u001B[22m '); }); it('returns an empty string for non-interactive number prompts without defaults', async () => { @@ -291,6 +296,25 @@ describe('number prompt', () => { ).rejects.toThrow(PromptValidationError); }); + it('renders cancelled number frames with the current value', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['4', Key.ctrlC]), + output, + error: output, + interactive: true, + }, + () => number({ message: 'Count' }), + ); + + expect(result).toBe(4); + expect(output.text()).toContain('Cancelled.'); + expect(output.text()).toContain('\u001B[31m ┌\u001B[39m Count '); + expect(parseAnsiText(output.text())).toContain('4'); + }); + it('clamps arrow key changes to min and max', async () => { const output = createMemoryOutput(); From 4f7b344cbaad2de65405f189bdc3dd76d945e57a Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Fri, 12 Jun 2026 12:44:16 +0800 Subject: [PATCH 224/675] Render text prompts with themed frames --- UPSTREAM.md | 2 + packages/tui/src/prompt/lifecycle.ts | 6 ++- packages/tui/src/prompts/number.ts | 59 +++++++++++++------------- packages/tui/src/prompts/text.ts | 31 ++++++++++---- packages/tui/src/prompts/textarea.ts | 2 +- packages/tui/src/typed-value.ts | 39 ++++++++++++----- packages/tui/src/typed-value/render.ts | 20 +++++++-- packages/tui/tests/number.test.ts | 1 + packages/tui/tests/text.test.ts | 29 +++++++++++-- 9 files changed, 132 insertions(+), 57 deletions(-) diff --git a/UPSTREAM.md b/UPSTREAM.md index 1198ccca..e05cd5d1 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -16,10 +16,12 @@ TypeScript and OpenTUI internals. | Prompt cancellation callback lifecycle | `packages/tui/src/prompt/cancel.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 | `packages/tui/src/prompt/lifecycle.ts`, `packages/tui/tests/text.test.ts` | +| Prompt post-validation submitted-frame lifecycle | `packages/tui/src/prompt/lifecycle.ts`, `packages/tui/src/prompts/text.ts`, `packages/tui/src/prompts/number.ts`, `packages/tui/tests/text.test.ts`, `packages/tui/tests/number.test.ts` | | Key constants 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/prompt/lifecycle.ts`, `packages/tui/src/typed-value.ts`, `packages/tui/tests/text.test.ts` | +| Text active, submitted, cancelled, placeholder, and validation-safe rendering | `packages/tui/src/prompts/text.ts`, `packages/tui/src/typed-value.ts`, `packages/tui/src/typed-value/render.ts`, `packages/tui/tests/text.test.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/tests/textarea.test.ts` | | Textarea control-key line navigation and empty-line movement | `packages/tui/src/typed-value/edit/navigation.ts`, `packages/tui/tests/textarea.test.ts` | diff --git a/packages/tui/src/prompt/lifecycle.ts b/packages/tui/src/prompt/lifecycle.ts index 73a17e27..59f4faa5 100644 --- a/packages/tui/src/prompt/lifecycle.ts +++ b/packages/tui/src/prompt/lifecycle.ts @@ -6,11 +6,13 @@ import type { BasePromptOptions } from '#tui/types'; type PromptReader = (attempt: number) => Promise; +type PromptValidHandler = (value: T) => void | Promise; + const validatedPromptValue = async (options: BasePromptOptions, value: T): Promise => { return ensureRequired(value, options.required) ?? (await validationMessage(value, options.validate)); }; -export const promptUntilValid = async (options: BasePromptOptions, read: PromptReader): Promise => { +export const promptUntilValid = async (options: BasePromptOptions, read: PromptReader, onValid?: PromptValidHandler): Promise => { const environment = promptEnvironment(); if (!environment.interactive) { @@ -45,6 +47,8 @@ export const promptUntilValid = async (options: BasePromptOptions, read: P const validation = await validatedPromptValue(options, value); if (!validation) { + await onValid?.(value); + return value; } diff --git a/packages/tui/src/prompts/number.ts b/packages/tui/src/prompts/number.ts index 00c0efd6..a6879a27 100644 --- a/packages/tui/src/prompts/number.ts +++ b/packages/tui/src/prompts/number.ts @@ -1,4 +1,3 @@ -import { promptEnvironment } from '#tui/environment'; import { promptUntilValid, PromptValidationError } from '#tui/prompt'; import { readNumberValue } from '#tui/prompts/number/input'; import { renderSubmittedNumberValue } from '#tui/prompts/number/render'; @@ -37,40 +36,42 @@ export async function number( ? { message, label: message, placeholder, default: defaultValue, required, validate, hint, min, max, step, transform } : { ...message, default: message.default ?? '' }; - return promptUntilValid(options, async () => { - const shouldRenderSubmittedFrame = Boolean(promptEnvironment().input.readKey); + let shouldRenderSubmittedFrame = false; - const answer = await readNumberValue(options.message, { - default: options.default, - hint: options.hint, - max: options.max, - min: options.min, - placeholder: options.placeholder, - step: options.step, - }); + return promptUntilValid( + options, + async () => { + const answer = await readNumberValue(options.message, { + default: options.default, + hint: options.hint, + max: options.max, + min: options.min, + placeholder: options.placeholder, + step: options.step, + }); - const value = answer.value; + const value = answer.value; - if (value === '' && options.default !== undefined) { - if (shouldRenderSubmittedFrame && !answer.cancelled) { - renderSubmittedNumberValue(options.message, options.default); - } - - return options.default; - } + shouldRenderSubmittedFrame = !answer.cancelled; - const result = parseNumberInput(value, options); + if (value === '' && options.default !== undefined) { + return options.default; + } - if (result.error !== undefined) { - throw new PromptValidationError(result.error); - } + const result = parseNumberInput(value, options); - const parsedValue = result.value ?? ''; + if (result.error !== undefined) { + throw new PromptValidationError(result.error); + } - if (shouldRenderSubmittedFrame && !answer.cancelled) { - renderSubmittedNumberValue(options.message, parsedValue); - } + const parsedValue = result.value ?? ''; - return options.transform ? options.transform(parsedValue) : parsedValue; - }); + return options.transform ? options.transform(parsedValue) : parsedValue; + }, + (value) => { + if (shouldRenderSubmittedFrame) { + renderSubmittedNumberValue(options.message, value); + } + }, + ); } diff --git a/packages/tui/src/prompts/text.ts b/packages/tui/src/prompts/text.ts index e6a70483..d8cc3da2 100644 --- a/packages/tui/src/prompts/text.ts +++ b/packages/tui/src/prompts/text.ts @@ -1,6 +1,7 @@ import { textOptions } from '#tui/concerns/text-options'; import { promptUntilValid } from '#tui/prompt'; import { readTypedValue } from '#tui/typed-value'; +import { renderSubmittedTypedValue } from '#tui/typed-value/render'; import type { TextPromptOptions } from '#tui/types'; export function text(options: TextPromptOptions): Promise; @@ -26,15 +27,27 @@ export async function text( ): Promise { const options = typeof message === 'string' ? textOptions({ message, label: message, placeholder, default: defaultValue, required, validate, hint, transform }) : textOptions(message); - return promptUntilValid(options, async () => { - const answer = await readTypedValue(options.message, { - default: options.default, - hint: options.hint, - placeholder: options.placeholder, - }); + let shouldRenderSubmittedFrame = false; - const value = answer === '' && options.default !== undefined ? options.default : answer; + return promptUntilValid( + options, + async () => { + const answer = await readTypedValue(options.message, { + default: options.default, + hint: options.hint, + placeholder: options.placeholder, + }); - return options.transform ? options.transform(value) : value; - }); + const value = answer.value === '' && options.default !== undefined ? options.default : answer.value; + + shouldRenderSubmittedFrame = !answer.cancelled; + + return options.transform ? options.transform(value) : value; + }, + (value) => { + if (shouldRenderSubmittedFrame) { + renderSubmittedTypedValue(options.message, value); + } + }, + ); } diff --git a/packages/tui/src/prompts/textarea.ts b/packages/tui/src/prompts/textarea.ts index 88034dd7..21ea926a 100644 --- a/packages/tui/src/prompts/textarea.ts +++ b/packages/tui/src/prompts/textarea.ts @@ -39,7 +39,7 @@ export async function textarea( rows: options.rows ?? rows, }); - const value = answer === '' && options.default !== undefined ? options.default : answer; + const value = answer.value === '' && options.default !== undefined ? options.default : answer.value; return options.transform ? options.transform(value) : value; }); diff --git a/packages/tui/src/typed-value.ts b/packages/tui/src/typed-value.ts index eb81c648..f7ccbe34 100644 --- a/packages/tui/src/typed-value.ts +++ b/packages/tui/src/typed-value.ts @@ -2,14 +2,19 @@ import { promptEnvironment } from '#tui/environment'; import { cancelPrompt, PromptValidationError } from '#tui/prompt'; import { renderQuestion } from '#tui/theme'; import { applyTypedKey, initialTypedValueState } from '#tui/typed-value/edit'; -import { renderTypedValue } from '#tui/typed-value/render'; +import { renderCancelledTypedValue, 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 { applyTypedKey }; export type { TypedValueOptions, TypedValueState }; -export const readTypedValue = async (message: string, options: TypedValueOptions = {}): Promise => { +export type TypedValueReadResult = { + cancelled: boolean; + value: string; +}; + +export const readTypedValue = async (message: string, options: TypedValueOptions = {}): Promise => { const environment = promptEnvironment(); if (!environment.input.readKey) { @@ -19,7 +24,10 @@ export const readTypedValue = async (message: string, options: TypedValueOptions const answer = await environment.input.readLine(renderQuestion(message, options.hint)); - return answer === '' && options.default !== undefined ? options.default : answer; + return { + cancelled: false, + value: answer === '' && options.default !== undefined ? options.default : answer, + }; } let state: TypedValueState = initialTypedValueState(options.default ?? ''); @@ -30,15 +38,25 @@ export const readTypedValue = async (message: string, options: TypedValueOptions const key = await environment.input.readKey(); if (key === null) { - return state.value; + return { + cancelled: false, + value: state.value, + }; } const next = applyTypedKey(state, key, options.allowNewLine, options.allowNewLine ? TEXTAREA_CONTENT_WIDTH : undefined); if (next.cancelled) { - environment.error.write('Cancelled.\n'); - - return cancelPrompt(state.value); + if (!options.allowNewLine) { + renderCancelledTypedValue(message, state.value, options); + } else { + environment.error.write('Cancelled.\n'); + } + + return { + cancelled: true, + value: await cancelPrompt(state.value), + }; } state = { @@ -47,9 +65,10 @@ export const readTypedValue = async (message: string, options: TypedValueOptions }; if (next.submitted) { - environment.output.write('\n'); - - return state.value; + return { + cancelled: false, + value: state.value, + }; } renderTypedValue(message, state, options); diff --git a/packages/tui/src/typed-value/render.ts b/packages/tui/src/typed-value/render.ts index 2b6bd791..5cb3817e 100644 --- a/packages/tui/src/typed-value/render.ts +++ b/packages/tui/src/typed-value/render.ts @@ -1,6 +1,6 @@ import { promptEnvironment } from '#tui/environment'; -import { renderQuestion } from '#tui/theme'; -import { dim } from '#tui/theme/styles'; +import { renderBox } from '#tui/theme/box'; +import { cyan, dim, red, strikethrough } from '#tui/theme/styles'; import { visibleLines } from '#tui/typed-value/lines'; import { renderTextareaFrame } from '#tui/typed-value/textarea-frame'; import type { TypedValueOptions, TypedValueState } from '#tui/typed-value/types'; @@ -16,7 +16,19 @@ export const renderTypedValue = (message: string, state: TypedValueState, option return; } - const displayValue = state.value.length > 0 ? visibleLines(state.value, state.cursor, options.rows) : (options.placeholder ?? ''); + const displayValue = state.value.length > 0 ? visibleLines(state.value, state.cursor, options.rows) : dim(options.placeholder ?? ''); - promptEnvironment().output.write(`${renderQuestion(message, options.hint)}${displayValue}\n`); + promptEnvironment().output.write(`${renderBox({ body: displayValue, borderStyle: cyan, info: options.hint, title: cyan(message) })}\n`); +}; + +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/tests/number.test.ts b/packages/tui/tests/number.test.ts index a4400d01..4a972079 100644 --- a/packages/tui/tests/number.test.ts +++ b/packages/tui/tests/number.test.ts @@ -160,6 +160,7 @@ describe('number prompt', () => { expect(result).toBe(3); expect(output.text()).toContain('Must be at least 2'); expect(output.text()).toContain('Must be less than 4'); + expect(output.text().split('┌ \u001B[2mCount\u001B[22m').length - 1).toBe(1); }); it('increments and decrements with arrow keys', async () => { diff --git a/packages/tui/tests/text.test.ts b/packages/tui/tests/text.test.ts index a9adeef5..aee28f0e 100644 --- a/packages/tui/tests/text.test.ts +++ b/packages/tui/tests/text.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { createMemoryOutput, createScriptedInput, Key, PromptValidationError, text, withPromptEnvironment } from '#tui/index'; +import { createMemoryOutput, createScriptedInput, Key, parseAnsiText, PromptValidationError, text, withPromptEnvironment } from '#tui/index'; describe('text prompt', () => { it('renders typed values while reading raw key input', async () => { @@ -16,7 +16,9 @@ describe('text prompt', () => { ); expect(result).toBe('Ada'); - expect(output.text()).toContain('? Name Ada'); + expect(output.text()).toContain('\u001B[36m ┌\u001B[39m \u001B[36mName\u001B[39m '); + expect(parseAnsiText(output.text())).toContain('Ada'); + expect(output.text()).toContain('┌ \u001B[2mName\u001B[22m '); }); it('renders placeholders before text input', async () => { @@ -32,7 +34,8 @@ describe('text prompt', () => { () => text('Name', 'Jane Doe'), ); - expect(output.text()).toContain('? Name Jane Doe'); + expect(output.text()).toContain('\u001B[36m ┌\u001B[39m \u001B[36mName\u001B[39m '); + expect(output.text()).toContain('\u001B[2mJane Doe\u001B[22m'); }); it('accepts default values', async () => { @@ -82,6 +85,7 @@ describe('text prompt', () => { expect(result).toBe('Jess'); expect(output.text()).toContain('Invalid name.'); + expect(output.text().split('┌ \u001B[2mName\u001B[22m').length - 1).toBe(1); }); it('edits values with backspace and delete keys', async () => { @@ -168,4 +172,23 @@ describe('text prompt', () => { expect(result).toBe(''); }); + + it('renders cancelled text frames with the current value', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['A', Key.ctrlC]), + output, + error: output, + interactive: true, + }, + () => text('Name'), + ); + + expect(result).toBe('A'); + expect(output.text()).toContain('Cancelled.'); + expect(output.text()).toContain('\u001B[31m ┌\u001B[39m Name '); + expect(parseAnsiText(output.text())).toContain('A'); + }); }); From 5ea21012b4b18dd3a0343abbb9d6d65a60af2606 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Fri, 12 Jun 2026 12:46:46 +0800 Subject: [PATCH 225/675] Render password prompts with themed frames --- UPSTREAM.md | 1 + packages/tui/src/prompts/password.ts | 29 +++++++++---- packages/tui/src/prompts/password/input.ts | 45 ++++++++++----------- packages/tui/src/prompts/password/render.ts | 28 +++++++++++++ packages/tui/src/prompts/password/types.ts | 10 +++++ packages/tui/tests/password.test.ts | 8 +++- 6 files changed, 88 insertions(+), 33 deletions(-) create mode 100644 packages/tui/src/prompts/password/render.ts create mode 100644 packages/tui/src/prompts/password/types.ts diff --git a/UPSTREAM.md b/UPSTREAM.md index e05cd5d1..f0b7f709 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -31,6 +31,7 @@ TypeScript and OpenTUI internals. | 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/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/input.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/prompts/password.ts`, `packages/tui/src/prompts/password/input.ts`, `packages/tui/src/prompts/password/render.ts`, `packages/tui/tests/password.test.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/prompts/number.ts`, `packages/tui/src/prompts/number/input.ts`, `packages/tui/src/prompts/number/render.ts`, `packages/tui/tests/number.test.ts` | | Confirm, select, multiselect | `packages/tui/src/prompts/select/*` | diff --git a/packages/tui/src/prompts/password.ts b/packages/tui/src/prompts/password.ts index 4a548566..84dc83e7 100644 --- a/packages/tui/src/prompts/password.ts +++ b/packages/tui/src/prompts/password.ts @@ -1,6 +1,7 @@ import { textOptions } from '#tui/concerns/text-options'; import { promptUntilValid } from '#tui/prompt'; import { readPasswordValue } from '#tui/prompts/password/input'; +import { renderSubmittedPasswordValue } from '#tui/prompts/password/render'; import type { TextPromptOptions } from '#tui/types'; export function password(options: TextPromptOptions): Promise; @@ -24,13 +25,25 @@ export async function password( ): Promise { const options = typeof message === 'string' ? textOptions({ message, label: message, placeholder, required, validate, hint, transform }) : textOptions(message); - return promptUntilValid(options, async () => { - const value = await readPasswordValue(options.message, { - default: options.default, - hint: options.hint, - placeholder: options.placeholder, - }); + let shouldRenderSubmittedFrame = false; - return options.transform ? options.transform(value) : value; - }); + return promptUntilValid( + options, + async () => { + const answer = await readPasswordValue(options.message, { + default: options.default, + hint: options.hint, + placeholder: options.placeholder, + }); + + shouldRenderSubmittedFrame = !answer.cancelled; + + return options.transform ? options.transform(answer.value) : answer.value; + }, + (value) => { + if (shouldRenderSubmittedFrame) { + renderSubmittedPasswordValue(options.message, value); + } + }, + ); } diff --git a/packages/tui/src/prompts/password/input.ts b/packages/tui/src/prompts/password/input.ts index 09c89a24..0fc5b61a 100644 --- a/packages/tui/src/prompts/password/input.ts +++ b/packages/tui/src/prompts/password/input.ts @@ -2,23 +2,10 @@ import { promptEnvironment } from '#tui/environment'; import { cancelPrompt, PromptValidationError } from '#tui/prompt'; import { renderQuestion } from '#tui/theme'; import { applyTypedKey } from '#tui/typed-value'; +import { passwordLength, renderCancelledPasswordValue, renderPasswordValue } from '#tui/prompts/password/render'; +import type { PasswordInputOptions, PasswordReadResult } from '#tui/prompts/password/types'; -type PasswordInputOptions = { - default?: string; - hint?: string; - placeholder?: string; -}; - -const mask = (value: string): string => '•'.repeat([...value].length); -const characterLength = (value: string): number => [...value].length; - -const renderPasswordValue = (message: string, stateValue: string, options: PasswordInputOptions): void => { - const value = stateValue.length > 0 ? mask(stateValue) : (options.placeholder ?? ''); - - promptEnvironment().output.write(`${renderQuestion(message, options.hint)}${value}\n`); -}; - -export const readPasswordValue = async (message: string, options: PasswordInputOptions = {}): Promise => { +export const readPasswordValue = async (message: string, options: PasswordInputOptions = {}): Promise => { const environment = promptEnvironment(); if (!environment.input.readKey) { @@ -28,11 +15,14 @@ export const readPasswordValue = async (message: string, options: PasswordInputO const answer = await environment.input.readLine(renderQuestion(message, options.hint)); - return answer === '' && options.default !== undefined ? options.default : answer; + return { + cancelled: false, + value: answer === '' && options.default !== undefined ? options.default : answer, + }; } let state = { - cursor: characterLength(options.default ?? ''), + cursor: passwordLength(options.default ?? ''), value: options.default ?? '', }; @@ -42,15 +32,21 @@ export const readPasswordValue = async (message: string, options: PasswordInputO const key = await environment.input.readKey(); if (key === null) { - return state.value; + return { + cancelled: false, + value: state.value, + }; } const next = applyTypedKey(state, key); if (next.cancelled) { - environment.error.write('Cancelled.\n'); + renderCancelledPasswordValue(message, state.value, options); - return cancelPrompt(state.value); + return { + cancelled: true, + value: await cancelPrompt(state.value), + }; } state = { @@ -59,9 +55,10 @@ export const readPasswordValue = async (message: string, options: PasswordInputO }; if (next.submitted) { - environment.output.write('\n'); - - return state.value; + return { + cancelled: false, + value: state.value, + }; } renderPasswordValue(message, state.value, options); diff --git a/packages/tui/src/prompts/password/render.ts b/packages/tui/src/prompts/password/render.ts new file mode 100644 index 00000000..3767c62b --- /dev/null +++ b/packages/tui/src/prompts/password/render.ts @@ -0,0 +1,28 @@ +import { promptEnvironment } from '#tui/environment'; +import { renderBox } from '#tui/theme/box'; +import { cyan, dim, red, strikethrough } from '#tui/theme/styles'; +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, options: PasswordInputOptions): string => { + return value.length > 0 ? maskPassword(value) : dim(options.placeholder ?? ''); +}; + +export const renderPasswordValue = (message: string, value: string, options: PasswordInputOptions): void => { + promptEnvironment().output.write(`${renderBox({ body: passwordDisplay(value, options), borderStyle: cyan, info: options.hint, title: cyan(message) })}\n`); +}; + +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/types.ts b/packages/tui/src/prompts/password/types.ts new file mode 100644 index 00000000..4f014508 --- /dev/null +++ b/packages/tui/src/prompts/password/types.ts @@ -0,0 +1,10 @@ +export type PasswordInputOptions = { + default?: string; + hint?: string; + placeholder?: string; +}; + +export type PasswordReadResult = { + cancelled: boolean; + value: string; +}; diff --git a/packages/tui/tests/password.test.ts b/packages/tui/tests/password.test.ts index e961131f..e668349e 100644 --- a/packages/tui/tests/password.test.ts +++ b/packages/tui/tests/password.test.ts @@ -18,6 +18,8 @@ describe('password prompt', () => { expect(result).toBe('secret'); expect(output.text()).not.toContain('secret'); expect(output.text()).toContain('••••••'); + expect(output.text()).toContain('\u001B[36m ┌\u001B[39m \u001B[36mPassword\u001B[39m '); + expect(output.text()).toContain('┌ \u001B[2mPassword\u001B[22m '); }); it('renders password placeholders before input', async () => { @@ -33,7 +35,8 @@ describe('password prompt', () => { () => password('Password', 'Required'), ); - expect(output.text()).toContain('? Password Required'); + expect(output.text()).toContain('\u001B[36m ┌\u001B[39m \u001B[36mPassword\u001B[39m '); + expect(output.text()).toContain('\u001B[2mRequired\u001B[22m'); }); it('returns object-option defaults without rendering the raw value', async () => { @@ -108,6 +111,7 @@ describe('password prompt', () => { expect(output.text()).toContain('Password must be at least 4 characters.'); expect(output.text()).not.toContain('pas'); expect(output.text()).not.toContain('pass'); + expect(output.text().split('┌ \u001B[2mPassword\u001B[22m').length - 1).toBe(1); }); it('edits values with backspace and delete keys without rendering the raw value', async () => { @@ -143,6 +147,8 @@ describe('password prompt', () => { expect(result).toBe('sec'); expect(output.text()).toContain('Cancelled.'); + expect(output.text()).toContain('\u001B[31m ┌\u001B[39m Password '); + expect(output.text()).toContain('•••'); expect(output.text()).not.toContain('sec'); }); From 14fe3629a880d20691f8f7852a40a9a62d2ea651 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Fri, 12 Jun 2026 12:49:45 +0800 Subject: [PATCH 226/675] Render textarea lifecycle frames --- UPSTREAM.md | 1 + packages/tui/src/prompts/textarea.ts | 35 +++++++++---- packages/tui/src/typed-value.ts | 3 +- .../tui/src/typed-value/textarea-frame.ts | 24 ++++++++- packages/tui/tests/textarea.test.ts | 51 ++++++++++++++----- 5 files changed, 86 insertions(+), 28 deletions(-) diff --git a/UPSTREAM.md b/UPSTREAM.md index f0b7f709..a1b781f3 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -24,6 +24,7 @@ TypeScript and OpenTUI internals. | Text active, submitted, cancelled, placeholder, and validation-safe rendering | `packages/tui/src/prompts/text.ts`, `packages/tui/src/typed-value.ts`, `packages/tui/src/typed-value/render.ts`, `packages/tui/tests/text.test.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/tests/textarea.test.ts` | +| Textarea active, submitted, cancelled, scrollbar, placeholder, and validation-safe rendering | `packages/tui/src/prompts/textarea.ts`, `packages/tui/src/typed-value.ts`, `packages/tui/src/typed-value/textarea-frame.ts`, `packages/tui/tests/textarea.test.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/tests/textarea.test.ts` | diff --git a/packages/tui/src/prompts/textarea.ts b/packages/tui/src/prompts/textarea.ts index 21ea926a..f4b278b7 100644 --- a/packages/tui/src/prompts/textarea.ts +++ b/packages/tui/src/prompts/textarea.ts @@ -1,5 +1,6 @@ import { promptUntilValid } from '#tui/prompt'; import { readTypedValue } from '#tui/typed-value'; +import { renderSubmittedTextareaFrame } from '#tui/typed-value/textarea-frame'; import type { TextareaPromptOptions } from '#tui/types'; export function textarea(options: TextareaPromptOptions): Promise; @@ -30,17 +31,29 @@ export async function textarea( ? { message, label: message, placeholder, default: defaultValue, required, validate, hint, rows, transform } : { ...message, default: message.default ?? '', rows: message.rows ?? rows }; - return promptUntilValid(options, async () => { - const answer = await readTypedValue(options.message, { - default: options.default, - hint: options.hint, - allowNewLine: true, - placeholder: options.placeholder, - rows: options.rows ?? rows, - }); + let shouldRenderSubmittedFrame = false; - const value = answer.value === '' && options.default !== undefined ? options.default : answer.value; + return promptUntilValid( + options, + async () => { + const answer = await readTypedValue(options.message, { + default: options.default, + hint: options.hint, + allowNewLine: true, + placeholder: options.placeholder, + rows: options.rows ?? rows, + }); - return options.transform ? options.transform(value) : value; - }); + const value = answer.value === '' && options.default !== undefined ? options.default : answer.value; + + shouldRenderSubmittedFrame = !answer.cancelled; + + return options.transform ? options.transform(value) : value; + }, + (value) => { + if (shouldRenderSubmittedFrame) { + renderSubmittedTextareaFrame(options.message, value); + } + }, + ); } diff --git a/packages/tui/src/typed-value.ts b/packages/tui/src/typed-value.ts index f7ccbe34..9b045269 100644 --- a/packages/tui/src/typed-value.ts +++ b/packages/tui/src/typed-value.ts @@ -4,6 +4,7 @@ import { renderQuestion } from '#tui/theme'; import { applyTypedKey, initialTypedValueState } from '#tui/typed-value/edit'; import { renderCancelledTypedValue, renderTypedValue } from '#tui/typed-value/render'; import { TEXTAREA_CONTENT_WIDTH } from '#tui/typed-value/textarea'; +import { renderCancelledTextareaFrame } from '#tui/typed-value/textarea-frame'; import type { TypedValueOptions, TypedValueState } from '#tui/typed-value/types'; export { applyTypedKey }; @@ -50,7 +51,7 @@ export const readTypedValue = async (message: string, options: TypedValueOptions if (!options.allowNewLine) { renderCancelledTypedValue(message, state.value, options); } else { - environment.error.write('Cancelled.\n'); + renderCancelledTextareaFrame(message, state.value, options); } return { diff --git a/packages/tui/src/typed-value/textarea-frame.ts b/packages/tui/src/typed-value/textarea-frame.ts index a7b4c02e..36be90fb 100644 --- a/packages/tui/src/typed-value/textarea-frame.ts +++ b/packages/tui/src/typed-value/textarea-frame.ts @@ -1,12 +1,25 @@ import { renderScrollbarRows } from '#tui/concerns/scrollbar'; +import { promptEnvironment } from '#tui/environment'; import { renderBox } from '#tui/theme/box'; -import { dim } from '#tui/theme/styles'; +import { cyan, dim, red, strikethrough } from '#tui/theme/styles'; import { visibleLineWindow } from '#tui/typed-value/lines'; import { TEXTAREA_CONTENT_WIDTH } from '#tui/typed-value/textarea'; import type { TypedValueOptions, TypedValueState } from '#tui/typed-value/types'; export const renderTextareaFrame = (message: string, state: TypedValueState, options: TypedValueOptions): string => { - return renderBox({ body: textareaBody(state, options), title: message }); + return renderBox({ body: textareaBody(state, options), borderStyle: cyan, info: 'Ctrl+D to submit', title: cyan(message) }); +}; + +export const renderSubmittedTextareaFrame = (message: string, value: string): void => { + promptEnvironment().output.write(`${renderBox({ body: value, title: dim(message) })}\n`); +}; + +export const renderCancelledTextareaFrame = (message: string, value: string, options: TypedValueOptions): void => { + const environment = promptEnvironment(); + const body = textareaCancelledBody(value, options); + + environment.output.write(`${renderBox({ body, borderStyle: red, title: message })}\n`); + environment.error.write(`${red(' ⚠ Cancelled.')}\n`); }; const textareaBody = (state: TypedValueState, options: TypedValueOptions): string => { @@ -36,3 +49,10 @@ const placeholderBody = (options: TypedValueOptions, rows: number | undefined): return [...lines, ...Array.from({ length: Math.max(0, rows - lines.length) }, () => '')].join('\n'); }; + +const textareaCancelledBody = (value: string, options: TypedValueOptions): string => { + const text = value.length > 0 ? value : (options.placeholder ?? ''); + const lines = text.split('\n'); + + return lines.map((line) => strikethrough(dim(line))).join('\n'); +}; diff --git a/packages/tui/tests/textarea.test.ts b/packages/tui/tests/textarea.test.ts index cd526f8a..694ce9f2 100644 --- a/packages/tui/tests/textarea.test.ts +++ b/packages/tui/tests/textarea.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { createMemoryOutput, createScriptedInput, Key, PromptValidationError, textarea, withPromptEnvironment } from '#tui/index'; +import { createMemoryOutput, createScriptedInput, Key, parseAnsiText, PromptValidationError, textarea, withPromptEnvironment } from '#tui/index'; describe('textarea prompt', () => { it('accepts multiline input and submits with ctrl-d', async () => { @@ -17,7 +17,9 @@ describe('textarea prompt', () => { expect(result).toBe('A\nB'); expect(output.text()).toContain('Description'); - expect(output.text()).toContain('┌ Description '); + expect(output.text()).toContain('\u001B[36m ┌\u001B[39m \u001B[36mDescription\u001B[39m '); + expect(output.text()).toContain('Ctrl+D to submit'); + expect(output.text()).toContain('┌ \u001B[2mDescription\u001B[22m '); expect(output.text()).toContain('│ A'); expect(output.text()).toContain('│ B'); }); @@ -35,7 +37,7 @@ describe('textarea prompt', () => { () => textarea('Description', 'Type here'), ); - expect(output.text()).toContain('┌ Description '); + expect(output.text()).toContain('\u001B[36m ┌\u001B[39m \u001B[36mDescription\u001B[39m '); expect(output.text()).toContain('\u001B[2mType here\u001B[22m'); }); @@ -86,6 +88,7 @@ describe('textarea prompt', () => { expect(result).toBe('Jess'); expect(output.text()).toContain('Invalid name.'); + expect(output.text().split('┌ \u001B[2mDescription\u001B[22m').length - 1).toBe(1); }); it('edits values with backspace and delete keys', async () => { @@ -117,11 +120,11 @@ describe('textarea prompt', () => { () => textarea('Description', '', '', false, undefined, '', 2), ); - const latestFrame = output.text().split('┌ Description ').at(-1) ?? ''; + const latestFrame = (output.text().split('\u001B[36mDescription\u001B[39m').at(-1) ?? '').split('┌ \u001B[2mDescription\u001B[22m').at(0) ?? ''; - expect(latestFrame).toContain('│ B'); - expect(latestFrame).toContain('│ C'); - expect(latestFrame).not.toContain('A\nB\nC'); + expect(parseAnsiText(latestFrame)).toContain('│ B'); + expect(parseAnsiText(latestFrame)).toContain('│ C'); + expect(parseAnsiText(latestFrame)).not.toContain('A\nB\nC'); expect(latestFrame).toContain('\u001B[2m│\u001B[22m'); expect(latestFrame).toContain('\u001B[36m┃\u001B[39m'); }); @@ -139,10 +142,10 @@ describe('textarea prompt', () => { () => textarea('Description', '', '', false, undefined, 'Use full sentences.', 3), ); - const latestFrame = output.text().split('┌ Description ').at(-1) ?? ''; + const latestFrame = (output.text().split('\u001B[36mDescription\u001B[39m').at(-1) ?? '').split('┌ \u001B[2mDescription\u001B[22m').at(0) ?? ''; - expect(latestFrame).toContain('│ A'); - expect(latestFrame).toContain('│ │'); + expect(parseAnsiText(latestFrame)).toContain('│ A'); + expect(parseAnsiText(latestFrame)).toContain('│ │'); expect(output.text()).toContain('\u001B[2mUse full sentences.\u001B[22m'); }); @@ -161,11 +164,11 @@ describe('textarea prompt', () => { () => textarea('Description', '', '', false, undefined, '', 2), ); - const latestFrame = output.text().split('┌ Description ').at(-1) ?? ''; + const latestFrame = (output.text().split('\u001B[36mDescription\u001B[39m').at(-1) ?? '').split('┌ \u001B[2mDescription\u001B[22m').at(0) ?? ''; - expect(latestFrame).toContain(`│ ${firstWrappedLine}`); - expect(latestFrame).toContain('│ 89'); - expect(latestFrame).not.toContain(`│ ${longLine}`); + expect(parseAnsiText(latestFrame)).toContain(`│ ${firstWrappedLine}`); + expect(parseAnsiText(latestFrame)).toContain('│ 89'); + expect(parseAnsiText(latestFrame)).not.toContain(`│ ${longLine}`); }); it('moves the textarea cursor across wrapped rows before editing', async () => { @@ -284,4 +287,24 @@ describe('textarea prompt', () => { ), ).rejects.toThrow(PromptValidationError); }); + + it('renders cancelled textarea frames with the current value', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['A', Key.enter, 'B', Key.ctrlC]), + output, + error: output, + interactive: true, + }, + () => textarea('Description'), + ); + + expect(result).toBe('A\nB'); + expect(output.text()).toContain('Cancelled.'); + expect(output.text()).toContain('\u001B[31m ┌\u001B[39m Description '); + expect(parseAnsiText(output.text())).toContain('A'); + expect(parseAnsiText(output.text())).toContain('B'); + }); }); From e371a58b68d7ecbb3658fa0ee40d75822060a009 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Fri, 12 Jun 2026 12:53:53 +0800 Subject: [PATCH 227/675] Render autocomplete prompts with themed frames --- UPSTREAM.md | 1 + packages/tui/src/prompts/suggest/index.ts | 23 +++++++++--- .../src/prompts/suggest/read-autocomplete.ts | 36 +++++++++++++++---- .../prompts/suggest/render-autocomplete.ts | 19 ++++++++-- packages/tui/tests/suggest.test.ts | 33 ++++++++++++++--- 5 files changed, 93 insertions(+), 19 deletions(-) diff --git a/UPSTREAM.md b/UPSTREAM.md index a1b781f3..e9baafe7 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -47,6 +47,7 @@ TypeScript and OpenTUI internals. | Multi-select cancel final frame | `packages/tui/src/prompts/select/render.ts`, `packages/tui/src/prompts/select/read-multiple.ts`, `packages/tui/tests/choices.test.ts` | | Suggest, autocomplete | `packages/tui/src/prompts/suggest/*` | | 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/prompts/suggest/index.ts`, `packages/tui/src/prompts/suggest/read-autocomplete.ts`, `packages/tui/src/prompts/suggest/render-autocomplete.ts`, `packages/tui/tests/suggest.test.ts` | | Search, multisearch | `packages/tui/src/prompts/search/*` | | Search active boxed layout and row styling | `packages/tui/src/prompts/search/render.ts`, `packages/tui/tests/search.test.ts` | | Search and multisearch empty-result rendering | `packages/tui/src/prompts/search/render.ts`, `packages/tui/tests/search.test.ts` | diff --git a/packages/tui/src/prompts/suggest/index.ts b/packages/tui/src/prompts/suggest/index.ts index b5798677..07b94627 100644 --- a/packages/tui/src/prompts/suggest/index.ts +++ b/packages/tui/src/prompts/suggest/index.ts @@ -1,6 +1,7 @@ import { promptUntilValid } from '#tui/prompt'; import { readAutocompleteValue } from '#tui/prompts/suggest/read-autocomplete'; import { readSuggestionValue } from '#tui/prompts/suggest/read'; +import { renderSubmittedAutocomplete } from '#tui/prompts/suggest/render-autocomplete'; import { suggestOptions } from '#tui/prompts/suggest/options'; import type { SuggestOptions } from '#tui/prompts/suggest/options'; import type { MaybePromise, TextPromptOptions } from '#tui/types'; @@ -69,11 +70,23 @@ export async function autocomplete( const options = typeof message === 'string' ? suggestOptions({ message, label: message, options: source, placeholder, default: defaultValue, required, validate, hint, transform }) : suggestOptions(message); - return promptUntilValid(options, async () => { - const answer = await readAutocompleteValue(options); + let shouldRenderSubmittedFrame = false; - const value = answer === '' && options.default !== undefined ? options.default : answer; + return promptUntilValid( + options, + async () => { + const answer = await readAutocompleteValue(options); - return options.transform ? options.transform(value) : value; - }); + const value = answer.value === '' && options.default !== undefined ? options.default : answer.value; + + shouldRenderSubmittedFrame = answer.rendered && !answer.cancelled; + + return options.transform ? options.transform(value) : value; + }, + (value) => { + if (shouldRenderSubmittedFrame) { + renderSubmittedAutocomplete(options.message, value); + } + }, + ); } diff --git a/packages/tui/src/prompts/suggest/read-autocomplete.ts b/packages/tui/src/prompts/suggest/read-autocomplete.ts index 981c3044..074e513c 100644 --- a/packages/tui/src/prompts/suggest/read-autocomplete.ts +++ b/packages/tui/src/prompts/suggest/read-autocomplete.ts @@ -3,16 +3,26 @@ import { Key } from '#tui/key'; import { ask, cancelPrompt } from '#tui/prompt'; import { applyTypedKey } from '#tui/typed-value'; import { acceptAutocompleteMatch, autocompleteNavigationDirection, canAcceptAutocomplete, moveAutocompleteHighlight } from '#tui/prompts/suggest/autocomplete'; -import { renderAutocomplete } from '#tui/prompts/suggest/render-autocomplete'; +import { renderAutocomplete, renderCancelledAutocomplete } from '#tui/prompts/suggest/render-autocomplete'; import { resolveSuggestions } from '#tui/prompts/suggest/resolve'; import { characterLength } from '#tui/typed-value/characters'; import type { SuggestOptions } from '#tui/prompts/suggest/options'; -export const readAutocompleteValue = async (options: SuggestOptions): Promise => { +export type AutocompleteReadResult = { + cancelled: boolean; + rendered: boolean; + value: string; +}; + +export const readAutocompleteValue = async (options: SuggestOptions): Promise => { const environment = promptEnvironment(); if (!environment.input.readKey) { - return ask(options.message, options.hint); + return { + cancelled: false, + rendered: false, + value: await ask(options.message, options.hint), + }; } let state = { @@ -29,7 +39,11 @@ export const readAutocompleteValue = async (options: SuggestOptions): Promise { - promptEnvironment().output.write(`${renderQuestion(message, hint)}${autocompleteDisplayValue(state, matches[highlighted], placeholder)}\n`); + const value = state.value.length === 0 ? dim(placeholder) : autocompleteDisplayValue(state, matches[highlighted], placeholder); + + promptEnvironment().output.write(`${renderBox({ body: value, borderStyle: cyan, info: hint, title: cyan(message) })}\n`); +}; + +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/tests/suggest.test.ts b/packages/tui/tests/suggest.test.ts index 272455d1..5365807b 100644 --- a/packages/tui/tests/suggest.test.ts +++ b/packages/tui/tests/suggest.test.ts @@ -124,7 +124,9 @@ describe('autocomplete prompt', () => { () => autocomplete('Favorite color?', ['Red', 'Green', 'Blue'], 'Type a color', '', false, undefined, 'Optional'), ); - expect(output.text()).toContain('? Favorite color? Optional Type a color'); + expect(output.text()).toContain('\u001B[36m ┌\u001B[39m \u001B[36mFavorite color?\u001B[39m '); + expect(output.text()).toContain('\u001B[2mType a color\u001B[22m'); + expect(output.text()).toContain('┌ \u001B[2mFavorite color?\u001B[22m '); }); it('renders autocomplete ghost text without a suggestion list', async () => { @@ -140,7 +142,7 @@ describe('autocomplete prompt', () => { () => autocomplete('Favorite color?', ['Red', 'Green', 'Blue']), ); - expect(parseAnsiText(output.text())).toContain('Favorite color? blue'); + expect(parseAnsiText(output.text())).toContain('blue'); expect(output.text()).toContain('b\u001B[7ml\u001B[27m\u001B[2mue\u001B[22m'); expect(output.text()).not.toContain('›'); }); @@ -159,7 +161,8 @@ describe('autocomplete prompt', () => { ); expect(result).toBe('Blue'); - expect(output.text()).toContain('Favorite color? Blue'); + expect(output.text()).toContain('┌ \u001B[2mFavorite color?\u001B[22m '); + expect(parseAnsiText(output.text())).toContain('Blue'); }); it('does not accept tab completion before the cursor reaches the end', async () => { @@ -193,7 +196,7 @@ describe('autocomplete prompt', () => { const frames = output.text().trimEnd().split('\n'); - expect(frames.at(-1)).toContain('Favorite color? b'); + expect(parseAnsiText(output.text())).toContain('b'); expect(frames.at(-1)).not.toContain('Blue'); }); @@ -275,6 +278,26 @@ describe('autocomplete prompt', () => { ); expect(result).toBe('Blue'); - expect(output.text()).toContain('Favorite color? Blue'); + expect(output.text()).toContain('┌ \u001B[2mFavorite color?\u001B[22m '); + expect(parseAnsiText(output.text())).toContain('Blue'); + }); + + it('renders cancelled autocomplete frames with the current value', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['b', Key.ctrlC]), + output, + error: output, + interactive: true, + }, + () => autocomplete('Favorite color?', ['Blue']), + ); + + expect(result).toBe('b'); + expect(output.text()).toContain('Cancelled.'); + expect(output.text()).toContain('\u001B[31m ┌\u001B[39m Favorite color? '); + expect(parseAnsiText(output.text())).toContain('b'); }); }); From 4f3f42828a3cdb4e3b96ae1dc1898bfd8db3391c Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Fri, 12 Jun 2026 12:56:01 +0800 Subject: [PATCH 228/675] Render suggest prompts with themed frames --- UPSTREAM.md | 1 + packages/tui/src/prompts/suggest/index.ts | 23 +++++++-- packages/tui/src/prompts/suggest/read.ts | 56 +++++++++++++++++----- packages/tui/src/prompts/suggest/render.ts | 55 ++++++++++++++++----- packages/tui/tests/suggest.test.ts | 25 +++++++++- 5 files changed, 128 insertions(+), 32 deletions(-) diff --git a/UPSTREAM.md b/UPSTREAM.md index e9baafe7..b81d03ad 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -46,6 +46,7 @@ TypeScript and OpenTUI internals. | Multi-select submitted final frame | `packages/tui/src/prompts/select/render.ts`, `packages/tui/src/prompts/select/read-multiple.ts`, `packages/tui/tests/choices.test.ts` | | Multi-select cancel final frame | `packages/tui/src/prompts/select/render.ts`, `packages/tui/src/prompts/select/read-multiple.ts`, `packages/tui/tests/choices.test.ts` | | Suggest, autocomplete | `packages/tui/src/prompts/suggest/*` | +| Suggest active, submitted, cancelled, placeholder, info, scrolling, and validation-safe rendering | `packages/tui/src/prompts/suggest/index.ts`, `packages/tui/src/prompts/suggest/read.ts`, `packages/tui/src/prompts/suggest/render.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/prompts/suggest/index.ts`, `packages/tui/src/prompts/suggest/read-autocomplete.ts`, `packages/tui/src/prompts/suggest/render-autocomplete.ts`, `packages/tui/tests/suggest.test.ts` | | Search, multisearch | `packages/tui/src/prompts/search/*` | diff --git a/packages/tui/src/prompts/suggest/index.ts b/packages/tui/src/prompts/suggest/index.ts index 07b94627..ce741ed6 100644 --- a/packages/tui/src/prompts/suggest/index.ts +++ b/packages/tui/src/prompts/suggest/index.ts @@ -2,6 +2,7 @@ import { promptUntilValid } from '#tui/prompt'; import { readAutocompleteValue } from '#tui/prompts/suggest/read-autocomplete'; import { readSuggestionValue } from '#tui/prompts/suggest/read'; import { renderSubmittedAutocomplete } from '#tui/prompts/suggest/render-autocomplete'; +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'; @@ -35,13 +36,25 @@ export async function suggest( ): Promise { const options = suggestOptions(message, source, placeholder, defaultValue, scroll, required, validate, hint, transform); - return promptUntilValid(options, async () => { - const answer = await readSuggestionValue(options); + let shouldRenderSubmittedFrame = false; + + return promptUntilValid( + options, + async () => { + const answer = await readSuggestionValue(options); + + const value = answer.value === '' && options.default !== undefined ? options.default : answer.value; - const value = answer === '' && options.default !== undefined ? options.default : answer; + shouldRenderSubmittedFrame = answer.rendered && !answer.cancelled; - return options.transform ? options.transform(value) : value; - }); + return options.transform ? options.transform(value) : value; + }, + (value) => { + if (shouldRenderSubmittedFrame) { + renderSubmittedSuggestion(options.message, value); + } + }, + ); } export function autocomplete(options: SuggestOptions): Promise; diff --git a/packages/tui/src/prompts/suggest/read.ts b/packages/tui/src/prompts/suggest/read.ts index ca8a8442..a78fdbef 100644 --- a/packages/tui/src/prompts/suggest/read.ts +++ b/packages/tui/src/prompts/suggest/read.ts @@ -3,16 +3,26 @@ import { Key } from '#tui/key'; import { ask, cancelPrompt } from '#tui/prompt'; import { applyTypedKey } from '#tui/typed-value'; import { clearsSuggestionHighlight, moveSuggestionHighlight, suggestNavigationAction } from '#tui/prompts/suggest/keys'; -import { renderSuggestions } from '#tui/prompts/suggest/render'; +import { renderCancelledSuggestion, renderSuggestions } from '#tui/prompts/suggest/render'; import { resolveSuggestions } from '#tui/prompts/suggest/resolve'; import { characterLength } from '#tui/typed-value/characters'; import type { SuggestOptions } from '#tui/prompts/suggest/options'; -export const readSuggestionValue = async (options: SuggestOptions): Promise => { +export type SuggestReadResult = { + cancelled: boolean; + rendered: boolean; + value: string; +}; + +export const readSuggestionValue = async (options: SuggestOptions): Promise => { const environment = promptEnvironment(); if (!environment.input.readKey) { - return ask(options.message, options.hint); + return { + cancelled: false, + rendered: false, + value: await ask(options.message, options.hint), + }; } let state = { @@ -23,13 +33,17 @@ export const readSuggestionValue = async (options: SuggestOptions): Promise { - renderInteractiveChoices( - value.length > 0 ? `${message} ${value}` : message, - matches.map((match) => ({ label: match, value: match })), - highlighted ?? 0, - new Set(), - scroll, - ); - +export const renderSuggestions = (message: string, value: string, matches: string[], highlighted: number | null, scroll?: number, info?: SuggestOptions['info'], placeholder = ''): void => { const text = resolveInfo(info, highlighted === null ? null : (matches[highlighted] ?? null)); - if (text.length > 0) { - promptEnvironment().output.write(`${text}\n`); + promptEnvironment().output.write(`${renderBox({ body: renderSuggestBody(value, placeholder, matches, highlighted, scroll), borderStyle: cyan, info: text, title: cyan(message) })}\n`); +}; + +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`); +}; + +const renderSuggestBody = (value: string, placeholder: string, matches: string[], highlighted: number | null, scroll?: number): string => { + const query = value.length > 0 ? value : dim(placeholder); + const rows = renderSuggestRows(matches, highlighted, scroll); + + if (value.length > 0 && matches.length === 0) { + return [query, dim(' No results.')].join('\n'); } + + return rows.length > 0 ? [query, rows].join('\n') : query; +}; + +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/tests/suggest.test.ts b/packages/tui/tests/suggest.test.ts index 5365807b..d5b26b95 100644 --- a/packages/tui/tests/suggest.test.ts +++ b/packages/tui/tests/suggest.test.ts @@ -77,15 +77,17 @@ describe('suggest prompt', () => { it('renders typed queries and suggestion matches', async () => { const output = await outputFor(['b', Key.down, Key.enter], ['Red', 'Green', 'Blue']); - expect(output).toContain('Favorite color? b'); + expect(output).toContain('\u001B[36m ┌\u001B[39m \u001B[36mFavorite color?\u001B[39m '); + expect(parseAnsiText(output)).toContain('b'); expect(output).toContain('›'); expect(output).toContain('Blue'); + expect(output).toContain('┌ \u001B[2mFavorite color?\u001B[22m '); }); it('highlights suggestions on tab without replacing typed input', async () => { const output = await outputFor(['b', Key.tab, Key.enter], ['Red', 'Green', 'Blue']); - expect(output).toContain('Favorite color? b'); + expect(parseAnsiText(output)).toContain('b'); expect(output).not.toContain('Favorite color? Blue'); }); @@ -108,6 +110,25 @@ describe('suggest prompt', () => { expect(output.text()).toContain('About Blue'); }); + + it('renders cancelled suggest frames with the current value', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['b', Key.ctrlC]), + output, + error: output, + interactive: true, + }, + () => suggest('Favorite color?', ['Blue']), + ); + + expect(result).toBe('b'); + expect(output.text()).toContain('Cancelled.'); + expect(output.text()).toContain('\u001B[31m ┌\u001B[39m Favorite color? '); + expect(parseAnsiText(output.text())).toContain('b'); + }); }); describe('autocomplete prompt', () => { From f9e4d24c2a4f916ef7e0a003bed01d4a7cc6849c Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Fri, 12 Jun 2026 12:57:24 +0800 Subject: [PATCH 229/675] Render pause prompts with themed frame --- UPSTREAM.md | 1 + packages/tui/src/prompts/pause.ts | 4 ++-- packages/tui/src/prompts/pause/render.ts | 6 ++++++ packages/tui/tests/pause.test.ts | 2 +- 4 files changed, 10 insertions(+), 3 deletions(-) create mode 100644 packages/tui/src/prompts/pause/render.ts diff --git a/UPSTREAM.md b/UPSTREAM.md index b81d03ad..7ec01793 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -59,6 +59,7 @@ TypeScript and OpenTUI internals. | Multi-search submitted final frame | `packages/tui/src/prompts/search/render.ts`, `packages/tui/src/prompts/search/read-multi.ts`, `packages/tui/tests/search.test.ts` | | Multi-search cancel final frame | `packages/tui/src/prompts/search/render.ts`, `packages/tui/src/prompts/search/read-multi.ts`, `packages/tui/tests/search.test.ts` | | Pause | `packages/tui/src/prompts/pause.ts` | +| Pause themed key-driven frame | `packages/tui/src/prompts/pause.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/tests/pause.test.ts` | | Choice normalization, matching, navigation, and list rendering concerns | `packages/tui/src/concerns/choices.ts` | | Choice and search scrollbar rendering | `packages/tui/src/concerns/scrollbar.ts`, `packages/tui/src/concerns/choices.ts`, `packages/tui/src/prompts/search/render.ts` | diff --git a/packages/tui/src/prompts/pause.ts b/packages/tui/src/prompts/pause.ts index cb3568b7..88264492 100644 --- a/packages/tui/src/prompts/pause.ts +++ b/packages/tui/src/prompts/pause.ts @@ -1,7 +1,7 @@ import { promptEnvironment } from '#tui/environment'; import { Key } from '#tui/key'; import { ask } from '#tui/prompt'; -import { renderQuestion } from '#tui/theme'; +import { renderPause } from '#tui/prompts/pause/render'; export const pause = async (message = 'Press enter to continue...'): Promise => { const environment = promptEnvironment(); @@ -16,7 +16,7 @@ export const pause = async (message = 'Press enter to continue...'): Promise { + return renderBox({ body: '', borderStyle: cyan, title: cyan(message) }); +}; diff --git a/packages/tui/tests/pause.test.ts b/packages/tui/tests/pause.test.ts index 83ba7965..ed25ca1f 100644 --- a/packages/tui/tests/pause.test.ts +++ b/packages/tui/tests/pause.test.ts @@ -16,7 +16,7 @@ describe('pause prompt', () => { ); expect(result).toBe(true); - expect(output.text()).toContain('Continue'); + expect(output.text()).toContain('\u001B[36m ┌\u001B[39m \u001B[36mContinue\u001B[39m '); expect(output.text().endsWith('\n')).toBe(true); }); From 755e074a29fbfa1d0e6c8850f27ba78fdd1a3748 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Fri, 12 Jun 2026 13:00:04 +0800 Subject: [PATCH 230/675] Align data table cancellation theme --- UPSTREAM.md | 1 + packages/acceptance/tests/package.test.ts | 99 ++++++++++++++++++++ packages/tui/src/output/data-table/render.ts | 2 +- packages/tui/tests/table.test.ts | 4 +- 4 files changed, 104 insertions(+), 2 deletions(-) diff --git a/UPSTREAM.md b/UPSTREAM.md index 7ec01793..e58bff73 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -74,6 +74,7 @@ TypeScript and OpenTUI internals. | Data table no-results search rendering | `packages/tui/src/output/data-table/render.ts`, `packages/tui/tests/table.test.ts` | | Data table viewing-info rendering | `packages/tui/src/output/data-table/render.ts`, `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/read.ts`, `packages/tui/tests/table.test.ts` | +| Data table themed cancellation warning | `packages/tui/src/output/data-table/render.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/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/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/rows.ts`, `packages/tui/tests/table.test.ts` | diff --git a/packages/acceptance/tests/package.test.ts b/packages/acceptance/tests/package.test.ts index 91b69202..02a6be2e 100644 --- a/packages/acceptance/tests/package.test.ts +++ b/packages/acceptance/tests/package.test.ts @@ -25,4 +25,103 @@ describe('package consumption', () => { expect(output.trim()).toBe('function,function,function'); }); + + it('exports the complete runtime helper surface from the root entrypoint', () => { + execFileSync('pnpm', ['--filter', '@ollin/tui', 'build'], { + cwd: workspacePath, + stdio: 'pipe', + }); + + const expectedExports = [ + 'FormBuilder', + 'Key', + 'Logger', + 'Progress', + 'PromptValidationError', + 'Stream', + 'alert', + 'applyTypedKey', + 'autocomplete', + 'backgroundColor', + 'cancelUsing', + 'clear', + 'clearTerminal', + 'commandExists', + 'configurePrompts', + 'confirm', + 'createMemoryOutput', + 'createScriptedInput', + 'cursorToStart', + 'dataTable', + 'datatable', + 'eraseLine', + 'erasePreviousLines', + 'error', + 'executeNotificationCommand', + 'foregroundColor', + 'form', + 'grid', + 'hideCursor', + 'info', + 'intro', + 'keyFromEvent', + 'multisearch', + 'multiselect', + 'note', + 'notificationCommand', + 'notificationCommands', + 'notify', + 'notifyForPlatform', + 'number', + 'oneOf', + 'outro', + 'parseAnsiSegments', + 'parseAnsiText', + 'password', + 'pause', + 'progress', + 'promptEnvironment', + 'readTypedValue', + 'renderOpenTuiTextFrame', + 'search', + 'select', + 'setTerminalTitle', + 'showCursor', + 'spin', + 'stream', + 'suggest', + 'supportsTrueColor', + 'table', + 'task', + 'terminalSize', + 'text', + 'textarea', + 'title', + 'truncate', + 'visibleWidth', + 'warning', + 'withPromptEnvironment', + 'wrap', + ]; + + const output = execFileSync( + process.execPath, + [ + '--input-type=module', + '-e', + ` + const module = await import('@ollin/tui'); + const expected = ${JSON.stringify(expectedExports)}; + const missing = expected.filter((name) => typeof module[name] === 'undefined'); + console.log(JSON.stringify({ missing, count: Object.keys(module).length })); + `, + ], + { + cwd: acceptancePath, + encoding: 'utf8', + }, + ); + + expect(JSON.parse(output) as { missing: string[] }).toEqual({ missing: [], count: expectedExports.length }); + }); }); diff --git a/packages/tui/src/output/data-table/render.ts b/packages/tui/src/output/data-table/render.ts index 570047bd..4b926787 100644 --- a/packages/tui/src/output/data-table/render.ts +++ b/packages/tui/src/output/data-table/render.ts @@ -92,5 +92,5 @@ export const renderCancelledDataTableFrame = (message: string, headers: strin environment.output.write(`${message}\n`); environment.output.write(`${dim('/ Search')}\n`); environment.output.write(`${renderTable(renderHeaders(headers), outputRows)}\n`); - environment.error.write(`${red('Cancelled.')}\n`); + environment.error.write(`${red(' ⚠ Cancelled.')}\n`); }; diff --git a/packages/tui/tests/table.test.ts b/packages/tui/tests/table.test.ts index 2ddb249f..eb816ebe 100644 --- a/packages/tui/tests/table.test.ts +++ b/packages/tui/tests/table.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { createMemoryOutput, createScriptedInput, datatable, form, Key, table, visibleWidth, withPromptEnvironment } from '#tui/index'; +import { createMemoryOutput, createScriptedInput, datatable, form, Key, parseAnsiText, table, visibleWidth, withPromptEnvironment } from '#tui/index'; describe('table output', () => { it('renders static table rows', async () => { @@ -295,6 +295,7 @@ describe('data table prompt', () => { expect(result).toBe('beta'); expect(output.text()).toContain('Cancelled.'); + expect(parseAnsiText(output.text())).toContain('⚠ Cancelled.'); expect(output.text()).toContain('/ Search'); }); @@ -320,6 +321,7 @@ describe('data table prompt', () => { expect(result).toBe('beta'); expect(output.text()).toContain('Cancelled.'); + expect(parseAnsiText(output.text())).toContain('⚠ Cancelled.'); }); it('renders a submitted data table row summary', async () => { From a8c6206810abbeb58113b235597a9e14cc7c5585 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Fri, 12 Jun 2026 13:03:29 +0800 Subject: [PATCH 231/675] Support object form suggest options --- UPSTREAM.md | 1 + .../tui/src/form/builder/prompts/choices.ts | 109 ++++++++++++++++-- .../form/builder/prompts/contracts/choices.ts | 2 + .../builder/prompts/validators/suggest.ts | 7 ++ packages/tui/tests/form.test.ts | 21 ++++ 5 files changed, 133 insertions(+), 7 deletions(-) create mode 100644 packages/tui/src/form/builder/prompts/validators/suggest.ts diff --git a/UPSTREAM.md b/UPSTREAM.md index e58bff73..be32da21 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -87,6 +87,7 @@ TypeScript and OpenTUI internals. | Form builder label-first search and multisearch methods | `packages/tui/src/form/builder/prompts/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 suggest and autocomplete placeholder/default arguments | `packages/tui/src/form/builder/prompts/choices.ts`, `packages/tui/src/form/builder/prompts/contracts/choices.ts`, `packages/tui/tests/form.test.ts` | +| Form builder suggest and autocomplete object-option method surface | `packages/tui/src/form/builder/prompts/choices.ts`, `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 number transform argument surface | `packages/tui/src/form/builder/prompts/basic.ts`, `packages/tui/src/form/builder/prompts/contracts/basic.ts`, `packages/tui/src/prompts/number.ts`, `packages/tui/tests/form.test.ts`, `packages/tui/tests/number.test.ts` | | Form builder search and multisearch previous-response defaults | `packages/tui/src/form/builder/prompts/choices.ts`, `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/validators/previous.ts`, `packages/tui/tests/form.test.ts` | diff --git a/packages/tui/src/form/builder/prompts/choices.ts b/packages/tui/src/form/builder/prompts/choices.ts index ea4968fb..ffad22e5 100644 --- a/packages/tui/src/form/builder/prompts/choices.ts +++ b/packages/tui/src/form/builder/prompts/choices.ts @@ -1,14 +1,111 @@ import { autocomplete, confirm, multiselect, multisearch, pause, search, select, suggest } from '#tui/prompts/choices'; import { previousArray, previousBoolean, previousString, previousValue } from '#tui/form/builder/previous'; import { isSearchPromptLabel } from '#tui/form/builder/prompts/validators/search'; +import { isSuggestPromptLabel } from '#tui/form/builder/prompts/validators/suggest'; import type { FormBuilder } from '#tui/form/builder/index'; import type { ChoicePromptBuilderMethods } from '#tui/form/builder/prompts/types'; -import type { ChoiceOptions, MultiSearchPromptOptions, SearchPromptOptions } from '#tui/types'; +import type { ChoiceOptions, MultiSearchPromptOptions, SearchPromptOptions, TextPromptOptions } from '#tui/types'; +import type { SuggestOptions } from '#tui/prompts/choices'; + +function autocompleteFormStep(this: FormBuilder, options: SuggestOptions, name?: string): FormBuilder; +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'], +): FormBuilder; + +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, +): FormBuilder { + if (!isSuggestPromptLabel(optionsOrLabel)) { + return this.add((_, previous) => autocomplete({ ...optionsOrLabel, default: previousString(previous, optionsOrLabel.default ?? '') }), options as string | undefined); + } + + return this.add( + (_, previous) => + autocomplete({ + message: optionsOrLabel, + label: optionsOrLabel, + options: options as SuggestOptions['options'], + placeholder, + default: previousString(previous, defaultValue), + required, + validate, + hint, + transform, + }), + name, + ); +} + +function suggestFormStep(this: FormBuilder, options: SuggestOptions, name?: string): FormBuilder; +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'], +): FormBuilder; + +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, +): FormBuilder { + if (!isSuggestPromptLabel(optionsOrLabel)) { + return this.add((_, previous) => suggest({ ...optionsOrLabel, default: previousString(previous, optionsOrLabel.default ?? '') }), options as string | undefined); + } + + return this.add( + (_, previous) => + suggest({ + message: optionsOrLabel, + label: optionsOrLabel, + options: options as SuggestOptions['options'], + placeholder, + default: previousString(previous, defaultValue), + scroll, + required, + validate, + hint, + transform, + }), + name, + ); +} export const choicePromptBuilderMethods: ChoicePromptBuilderMethods & ThisType = { - autocomplete(label, options, placeholder = '', defaultValue = '', required = false, validate = undefined, hint = '', name, transform = undefined) { - return this.add((_, previous) => autocomplete({ message: label, label, options, placeholder, default: previousString(previous, defaultValue), required, validate, hint, transform }), name); - }, + autocomplete: autocompleteFormStep, confirm(label, defaultValue = true, yes = 'Yes', no = 'No', required = false, validate = undefined, hint = '', name, transform = undefined) { return this.add((_, previous) => confirm(label, previousBoolean(previous, defaultValue), yes, no, required, validate, hint, transform), name); }, @@ -89,7 +186,5 @@ export const choicePromptBuilderMethods: ChoicePromptBuilderMethods & ThisType(label: string, options: ChoiceOptions, defaultValue?: T, scroll = 5, validate = undefined, hint = '', required: boolean | string = true, name?: string, transform = undefined) { return this.add((_, previous) => select({ message: label, options, default: previousValue(previous, defaultValue), scroll, validate, hint, required, transform }), name); }, - suggest(label, options, placeholder = '', defaultValue = '', scroll = 5, required = false, validate = undefined, hint = '', name, transform = undefined) { - return this.add((_, previous) => suggest({ message: label, label, options, placeholder, default: previousString(previous, defaultValue), scroll, required, validate, hint, transform }), name); - }, + suggest: suggestFormStep, }; diff --git a/packages/tui/src/form/builder/prompts/contracts/choices.ts b/packages/tui/src/form/builder/prompts/contracts/choices.ts index 9745f8ae..5751d119 100644 --- a/packages/tui/src/form/builder/prompts/contracts/choices.ts +++ b/packages/tui/src/form/builder/prompts/contracts/choices.ts @@ -3,6 +3,7 @@ import type { SuggestOptions } from '#tui/prompts/choices'; import type { ChoiceOptions, MaybePromise, MultiSearchPromptOptions, SearchPromptOptions, TextPromptOptions } from '#tui/types'; export type ChoicePromptBuilderMethods = { + autocomplete(this: FormBuilder, options: SuggestOptions, name?: string): FormBuilder; autocomplete( this: FormBuilder, label: string, @@ -78,6 +79,7 @@ export type ChoicePromptBuilderMethods = { name?: string, transform?: (value: T) => MaybePromise, ): FormBuilder; + suggest(this: FormBuilder, options: SuggestOptions, name?: string): FormBuilder; suggest( this: FormBuilder, label: string, diff --git a/packages/tui/src/form/builder/prompts/validators/suggest.ts b/packages/tui/src/form/builder/prompts/validators/suggest.ts new file mode 100644 index 00000000..ea523e67 --- /dev/null +++ b/packages/tui/src/form/builder/prompts/validators/suggest.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; + +const suggestLabelSchema = z.string(); + +export const isSuggestPromptLabel = (value: unknown): value is string => { + return suggestLabelSchema.safeParse(value).success; +}; diff --git a/packages/tui/tests/form.test.ts b/packages/tui/tests/form.test.ts index 5254a053..43ec35e8 100644 --- a/packages/tui/tests/form.test.ts +++ b/packages/tui/tests/form.test.ts @@ -377,6 +377,27 @@ describe('form builder', () => { expect(output.text()).toContain('Type a color'); }); + it('runs object-option suggest and autocomplete form steps', async () => { + const output = createMemoryOutput(); + + const responses = await withPromptEnvironment( + { + input: createScriptedInput([Key.enter, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + form() + .suggest({ message: 'Suggested color', options: ['Red', 'Green', 'Blue'], default: 'Blue' }, 'suggested') + .autocomplete({ message: 'Auto color', options: ['Red', 'Green', 'Blue'], default: 'Green' }, 'auto') + .submit(), + ); + + expect(responses.suggested).toBe('Blue'); + expect(responses.auto).toBe('Green'); + }); + it('runs label-first search and multisearch form steps', async () => { const output = createMemoryOutput(); From a3c7a3bedb29fa52f50ff4022567e4b1a30e39af Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Fri, 12 Jun 2026 13:06:42 +0800 Subject: [PATCH 232/675] Support label-first select helpers --- UPSTREAM.md | 1 + packages/tui/src/prompts/select/index.ts | 68 ++++++++++++++++++++++-- packages/tui/tests/choices.test.ts | 36 +++++++++++++ 3 files changed, 100 insertions(+), 5 deletions(-) diff --git a/UPSTREAM.md b/UPSTREAM.md index be32da21..6d4ff922 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -38,6 +38,7 @@ TypeScript and OpenTUI internals. | 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/render-confirm.ts`, `packages/tui/tests/choices.test.ts` | | Select active boxed layout and row styling | `packages/tui/src/prompts/select/render.ts`, `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/tests/choices.test.ts` | | Select non-interactive default validation | `packages/tui/src/prompts/select/index.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/tests/choices.test.ts` | diff --git a/packages/tui/src/prompts/select/index.ts b/packages/tui/src/prompts/select/index.ts index d5d6ca04..9137ce45 100644 --- a/packages/tui/src/prompts/select/index.ts +++ b/packages/tui/src/prompts/select/index.ts @@ -5,9 +5,38 @@ import { normalizeChoices } from '#tui/concerns/choices'; import { readMultipleChoices } from '#tui/prompts/select/read-multiple'; import { readSelectedChoice } from '#tui/prompts/select/read-selected'; import { assertSelectOptions } from '#tui/prompts/select/validators/options'; -import type { MultiSelectPromptOptions, SelectPromptOptions } from '#tui/types'; +import type { ChoiceOptions, MultiSelectPromptOptions, 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 { + const options = + typeof optionsOrLabel === 'string' + ? { message: optionsOrLabel, label: optionsOrLabel, options: source as ChoiceOptions, default: defaultValue, scroll, validate, hint, required, transform, info } + : optionsOrLabel; -export const select = async (options: SelectPromptOptions): Promise => { assertSelectOptions(options); const promptOptions = { ...options, required: options.required ?? true }; @@ -24,9 +53,38 @@ export const select = async (options: SelectPromptOptions): Promise => return promptOptions.transform ? promptOptions.transform(selected) : selected; }); -}; +} + +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 { + const options = + typeof optionsOrLabel === 'string' + ? { message: optionsOrLabel, label: optionsOrLabel, options: source as ChoiceOptions, default: defaultValue, scroll, required, validate, hint, transform, info } + : optionsOrLabel; -export const multiselect = async (options: MultiSelectPromptOptions): Promise => { const promptOptions = { ...options, default: options.default ?? [] }; const choices = normalizeChoices(options.options); @@ -35,4 +93,4 @@ export const multiselect = async (options: MultiSelectPromptOptions): Prom return promptOptions.transform ? promptOptions.transform(selected) : selected; }); -}; +} diff --git a/packages/tui/tests/choices.test.ts b/packages/tui/tests/choices.test.ts index 03284c6d..6da55fe0 100644 --- a/packages/tui/tests/choices.test.ts +++ b/packages/tui/tests/choices.test.ts @@ -459,6 +459,24 @@ describe('choice prompts', () => { expect(output.text()).toContain('About second'); }); + it('supports label-first select helpers with info', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.enter]), + output, + error: output, + interactive: true, + }, + () => select('Pick one', ['first', 'second'], undefined, 5, undefined, '', true, undefined, (value) => `About ${value ?? 'none'}`), + ); + + expect(result).toBe('second'); + expect(output.text()).toContain('About first'); + expect(output.text()).toContain('About second'); + }); + it('transforms selected values before validation and return', async () => { const output = createMemoryOutput(); @@ -748,6 +766,24 @@ describe('choice prompts', () => { expect(output.text()).toContain('About second'); }); + it('supports label-first multiselect helpers with info', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.space, Key.enter]), + output, + error: output, + interactive: true, + }, + () => multiselect('Pick many', ['first', 'second'], [], 5, false, undefined, 'Use the space bar to select options.', undefined, (value) => `About ${value ?? 'none'}`), + ); + + expect(result).toEqual(['second']); + expect(output.text()).toContain('About first'); + expect(output.text()).toContain('About second'); + }); + it('renders multiselect selected counts for scrollable option lists', async () => { const output = createMemoryOutput(); From d7fa3ad203cc6af01b346082b01f33a85095e7b0 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Fri, 12 Jun 2026 13:08:45 +0800 Subject: [PATCH 233/675] Support label-first search helpers --- UPSTREAM.md | 1 + packages/tui/src/prompts/search/index.ts | 68 ++++++++++++++++++++++-- packages/tui/tests/search.test.ts | 35 ++++++++++++ 3 files changed, 99 insertions(+), 5 deletions(-) diff --git a/UPSTREAM.md b/UPSTREAM.md index 6d4ff922..29a6b24b 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -51,6 +51,7 @@ TypeScript and OpenTUI internals. | 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/prompts/suggest/index.ts`, `packages/tui/src/prompts/suggest/read-autocomplete.ts`, `packages/tui/src/prompts/suggest/render-autocomplete.ts`, `packages/tui/tests/suggest.test.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/prompts/search/render.ts`, `packages/tui/tests/search.test.ts` | | Search and multisearch empty-result rendering | `packages/tui/src/prompts/search/render.ts`, `packages/tui/tests/search.test.ts` | | Search highlighted-result row styling | `packages/tui/src/prompts/search/render.ts`, `packages/tui/src/theme/styles.ts`, `packages/tui/tests/search.test.ts` | diff --git a/packages/tui/src/prompts/search/index.ts b/packages/tui/src/prompts/search/index.ts index f31f7ac5..1c63e0f9 100644 --- a/packages/tui/src/prompts/search/index.ts +++ b/packages/tui/src/prompts/search/index.ts @@ -2,9 +2,38 @@ import { promptUntilValid, PromptValidationError } from '#tui/prompt'; import { readMultiSearchChoices } from '#tui/prompts/search/read-multi'; import { readSearchChoice } from '#tui/prompts/search/read-single'; import { assertSearchOptions } from '#tui/prompts/search/validators/options'; -import type { MultiSearchPromptOptions, SearchPromptOptions } from '#tui/types'; +import type { ChoiceOptions, MultiSearchPromptOptions, 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 { + const options = + typeof optionsOrLabel === 'string' + ? { message: optionsOrLabel, label: optionsOrLabel, options: source as SearchPromptOptions['options'], placeholder, scroll, validate, hint, required, transform, info } + : optionsOrLabel; -export const search = async (options: SearchPromptOptions): Promise => { assertSearchOptions(options); return promptUntilValid(options, async (attempt) => { @@ -16,9 +45,38 @@ export const search = async (options: SearchPromptOptions): Promise => return options.transform ? options.transform(selected) : selected; }); -}; +} + +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 { + const options = + typeof optionsOrLabel === 'string' + ? { message: optionsOrLabel, label: optionsOrLabel, options: source as MultiSearchPromptOptions['options'], placeholder, scroll, required, validate, hint, transform, info } + : optionsOrLabel; -export const multisearch = async (options: MultiSearchPromptOptions): Promise => { const promptOptions = { ...options, default: options.default ?? [] }; return promptUntilValid(promptOptions, async () => { @@ -26,4 +84,4 @@ export const multisearch = async (options: MultiSearchPromptOptions): Prom return promptOptions.transform ? promptOptions.transform(selected) : selected; }); -}; +} diff --git a/packages/tui/tests/search.test.ts b/packages/tui/tests/search.test.ts index 4cb62e8f..9739762d 100644 --- a/packages/tui/tests/search.test.ts +++ b/packages/tui/tests/search.test.ts @@ -181,6 +181,23 @@ describe('search prompt', () => { expect(output.text()).toContain('About red'); }); + it('supports label-first search helpers with info', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.enter]), + output, + error: output, + interactive: true, + }, + () => search('Favorite color?', colors, '', 5, undefined, '', true, undefined, (value) => `About ${value ?? 'none'}`), + ); + + expect(result).toBe('red'); + expect(output.text()).toContain('About red'); + }); + it('renders an empty result message for unmatched search queries', async () => { const output = createMemoryOutput(); @@ -556,6 +573,24 @@ describe('multisearch prompt', () => { expect(output.text()).toContain('0 selected'); }); + it('supports label-first multisearch helpers with info', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.space, Key.enter]), + output, + error: output, + interactive: true, + }, + () => multisearch('Favorite colors?', colors, '', 5, false, undefined, 'Use the space bar to select options.', undefined, (value) => `About ${value ?? 'none'}`), + ); + + expect(result).toEqual(['red']); + expect(output.text()).toContain('About red'); + expect(output.text()).toContain('1 selected'); + }); + it('renders an empty result message for unmatched multisearch queries', async () => { const output = createMemoryOutput(); From 9502d7989bed5394b9b751a98f5acc67dd6e1121 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Fri, 12 Jun 2026 13:12:39 +0800 Subject: [PATCH 234/675] Support label-first data table helper --- UPSTREAM.md | 2 + packages/tui/src/contracts/output.ts | 1 + packages/tui/src/output/data-table.ts | 50 +++++++++++++++++++++++-- packages/tui/tests/table.test.ts | 53 +++++++++++++++++++++++++++ 4 files changed, 102 insertions(+), 4 deletions(-) diff --git a/UPSTREAM.md b/UPSTREAM.md index 29a6b24b..d4b6cf39 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -73,6 +73,8 @@ TypeScript and OpenTUI internals. | 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/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/tests/table.test.ts` | | Data table no-results search rendering | `packages/tui/src/output/data-table/render.ts`, `packages/tui/tests/table.test.ts` | | Data table viewing-info rendering | `packages/tui/src/output/data-table/render.ts`, `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/read.ts`, `packages/tui/tests/table.test.ts` | diff --git a/packages/tui/src/contracts/output.ts b/packages/tui/src/contracts/output.ts index 0dca01dd..1eb9a0ca 100644 --- a/packages/tui/src/contracts/output.ts +++ b/packages/tui/src/contracts/output.ts @@ -12,6 +12,7 @@ export type DataTablePromptOptions = BasePromptOptions headers?: string[]; rows: Array>; scroll?: number; + transform?: (value: T | number) => T | number | Promise; }; export type DataTableObjectRow = { diff --git a/packages/tui/src/output/data-table.ts b/packages/tui/src/output/data-table.ts index b3c5d442..6900e483 100644 --- a/packages/tui/src/output/data-table.ts +++ b/packages/tui/src/output/data-table.ts @@ -1,10 +1,52 @@ import { promptUntilValid } from '#tui/prompt'; import { readDataTableSelection } from '#tui/output/data-table/read'; import { deriveDataTableHeaders } from '#tui/output/data-table/rows'; -import type { DataTablePromptOptions } from '#tui/types'; +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 options = Array.isArray(optionsOrHeaders) + ? { + filter, + headers: optionsOrHeaders, + hint, + message: label, + required, + rows: rows ?? [], + scroll, + transform, + validate, + } + : optionsOrHeaders; -export const datatable = async (options: DataTablePromptOptions): Promise => { const headers = options.headers ?? deriveDataTableHeaders(options.rows); - return promptUntilValid(options, async () => readDataTableSelection(options, headers)); -}; + return promptUntilValid(options, async () => { + const selected = await readDataTableSelection(options, headers); + + return options.transform ? options.transform(selected) : selected; + }); +} diff --git a/packages/tui/tests/table.test.ts b/packages/tui/tests/table.test.ts index eb816ebe..3a8bbd60 100644 --- a/packages/tui/tests/table.test.ts +++ b/packages/tui/tests/table.test.ts @@ -146,6 +146,59 @@ describe('data table prompt', () => { expect(output.text()).toContain('›'); }); + it('supports label-first data table helpers with filters', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput(['/', 'slow', Key.enter, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + datatable( + ['Name', 'Tag'], + [ + { Name: 'Alpha', Tag: 'fast', value: 'alpha' }, + { Name: 'Beta', Tag: 'slow', value: 'beta' }, + ], + 10, + 'Pick project', + '', + false, + undefined, + undefined, + (query, row) => !Array.isArray(row) && row.Tag === query, + ), + ); + + expect(result).toBe('beta'); + expect(output.text()).toContain('Pick project slow'); + expect(output.text()).toContain('Beta'); + }); + + it('transforms selected data table values before return', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.enter]), + output, + error: output, + interactive: true, + }, + () => + datatable({ + message: 'Pick project', + rows: [{ Name: 'Alpha', value: 'alpha' }], + transform: (value) => String(value).toUpperCase(), + }), + ); + + expect(result).toBe('ALPHA'); + }); + it('filters rows in explicit search mode', async () => { const output = createMemoryOutput(); From f71205bab9aa0d2b51e6b695036159a4ab243652 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Fri, 12 Jun 2026 13:14:53 +0800 Subject: [PATCH 235/675] Support form data table helper surface --- UPSTREAM.md | 1 + packages/tui/src/form/builder/output.ts | 34 +++++++++++++++++++++--- packages/tui/tests/form.test.ts | 35 +++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 3 deletions(-) diff --git a/UPSTREAM.md b/UPSTREAM.md index d4b6cf39..fabb6ab8 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -95,6 +95,7 @@ TypeScript and OpenTUI internals. | Form builder number transform argument surface | `packages/tui/src/form/builder/prompts/basic.ts`, `packages/tui/src/form/builder/prompts/contracts/basic.ts`, `packages/tui/src/prompts/number.ts`, `packages/tui/tests/form.test.ts`, `packages/tui/tests/number.test.ts` | | Form builder search and multisearch previous-response defaults | `packages/tui/src/form/builder/prompts/choices.ts`, `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/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/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/tests/output.test.ts`, `packages/tui/tests/form.test.ts` | | Progress boxed frame rendering and formatted fractions | `packages/tui/src/status/progress/render.ts`, `packages/tui/src/theme/box.ts` | | Spinner static and deterministic animated frame rendering | `packages/tui/src/status/spinner.ts`, `packages/tui/src/status/spinner/render.ts` | diff --git a/packages/tui/src/form/builder/output.ts b/packages/tui/src/form/builder/output.ts index 27b96988..18c0b178 100644 --- a/packages/tui/src/form/builder/output.ts +++ b/packages/tui/src/form/builder/output.ts @@ -1,11 +1,24 @@ import { alert, datatable, error, info, intro, note, outro, table, warning } from '#tui/output'; import { sideEffectStep } from '#tui/form/builder/step'; import type { FormBuilder } from '#tui/form/builder/index'; -import type { DataTablePromptOptions, MaybePromise, TableOptions } from '#tui/types'; +import type { DataTablePromptOptions, DataTableRow, MaybePromise, TableOptions } from '#tui/types'; export type OutputBuilderMethods = { alert(this: FormBuilder, message: string, name?: string): FormBuilder; datatable(this: FormBuilder, options: DataTablePromptOptions, name?: string): FormBuilder; + datatable( + this: FormBuilder, + headers?: string[], + rows?: Array> | null, + scroll?: number, + label?: string, + hint?: string, + required?: DataTablePromptOptions['required'], + validate?: DataTablePromptOptions['validate'], + transform?: DataTablePromptOptions['transform'], + filter?: DataTablePromptOptions['filter'], + name?: string, + ): FormBuilder; error(this: FormBuilder, message: string, name?: string): FormBuilder; info(this: FormBuilder, message: string, name?: string): FormBuilder; intro(this: FormBuilder, message: string, name?: string): FormBuilder; @@ -25,8 +38,23 @@ export const outputBuilderMethods: OutputBuilderMethods & ThisType true, ); }, - datatable(options: DataTablePromptOptions, name?: string) { - return this.add(() => datatable(options), name); + datatable( + optionsOrHeaders: DataTablePromptOptions | string[] = [], + rowsOrName: Array> | null | string = null, + scroll = 10, + label = '', + hint = '', + required: DataTablePromptOptions['required'] = false, + validate: DataTablePromptOptions['validate'] = undefined, + transform: DataTablePromptOptions['transform'] = undefined, + filter: DataTablePromptOptions['filter'] = undefined, + name?: string, + ) { + if (Array.isArray(optionsOrHeaders)) { + return this.add(() => datatable(optionsOrHeaders, rowsOrName as Array> | null, scroll, label, hint, required, validate, transform, filter), name); + } + + return this.add(() => datatable(optionsOrHeaders), rowsOrName as string | undefined); }, error(message, name) { return this.add( diff --git a/packages/tui/tests/form.test.ts b/packages/tui/tests/form.test.ts index 43ec35e8..f4550522 100644 --- a/packages/tui/tests/form.test.ts +++ b/packages/tui/tests/form.test.ts @@ -600,4 +600,39 @@ describe('form builder', () => { expect(responses.note).toBeNull(); expect(responses.table).toBeNull(); }); + + it('runs label-first data table form steps', async () => { + const output = createMemoryOutput(); + + const responses = await withPromptEnvironment( + { + input: createScriptedInput(['/', 'slow', Key.enter, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + form() + .datatable( + ['Name', 'Tag'], + [ + { Name: 'Alpha', Tag: 'fast', value: 'alpha' }, + { Name: 'Beta', Tag: 'slow', value: 'beta' }, + ], + 10, + 'Pick project', + '', + false, + undefined, + (value) => String(value).toUpperCase(), + (query, row) => !Array.isArray(row) && row.Tag === query, + 'project', + ) + .submit(), + ); + + expect(responses.project).toBe('BETA'); + expect(output.text()).toContain('Pick project slow'); + expect(output.text()).toContain('Beta'); + }); }); From 36b55b8f26ccf97bd1f2510815f458a257ad3217 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Fri, 12 Jun 2026 13:17:11 +0800 Subject: [PATCH 236/675] Expand form output helper surface --- UPSTREAM.md | 1 + packages/tui/src/form/builder/index.ts | 5 +++ packages/tui/src/form/builder/output.ts | 42 ++++++++++++++++++++++++- packages/tui/tests/form.test.ts | 14 +++++++++ 4 files changed, 61 insertions(+), 1 deletion(-) diff --git a/UPSTREAM.md b/UPSTREAM.md index fabb6ab8..cca52705 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -96,6 +96,7 @@ TypeScript and OpenTUI internals. | Form builder search and multisearch previous-response defaults | `packages/tui/src/form/builder/prompts/choices.ts`, `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/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/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/output.ts`, `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/tests/output.test.ts`, `packages/tui/tests/form.test.ts` | | Progress boxed frame rendering and formatted fractions | `packages/tui/src/status/progress/render.ts`, `packages/tui/src/theme/box.ts` | | Spinner static and deterministic animated frame rendering | `packages/tui/src/status/spinner.ts`, `packages/tui/src/status/spinner/render.ts` | diff --git a/packages/tui/src/form/builder/index.ts b/packages/tui/src/form/builder/index.ts index bbcb4517..b9ff7d38 100644 --- a/packages/tui/src/form/builder/index.ts +++ b/packages/tui/src/form/builder/index.ts @@ -11,13 +11,17 @@ export class FormBuilder { readonly alert = outputBuilderMethods.alert; readonly autocomplete = promptBuilderMethods.autocomplete; readonly confirm = promptBuilderMethods.confirm; + readonly clear = outputBuilderMethods.clear; + readonly dataTable = outputBuilderMethods.dataTable; readonly datatable = outputBuilderMethods.datatable; readonly error = outputBuilderMethods.error; + readonly grid = outputBuilderMethods.grid; readonly info = outputBuilderMethods.info; readonly intro = outputBuilderMethods.intro; readonly multisearch = promptBuilderMethods.multisearch; readonly multiselect = promptBuilderMethods.multiselect; readonly note = outputBuilderMethods.note; + readonly notify = outputBuilderMethods.notify; readonly number = promptBuilderMethods.number; readonly outro = outputBuilderMethods.outro; readonly password = promptBuilderMethods.password; @@ -32,6 +36,7 @@ export class FormBuilder { readonly task = statusBuilderMethods.task; readonly text = promptBuilderMethods.text; readonly textarea = promptBuilderMethods.textarea; + readonly title = outputBuilderMethods.title; readonly warning = outputBuilderMethods.warning; add(step: (responses: FormResponses, previous: unknown, name?: string) => MaybePromise, name?: string, ignoreWhenReverting = false): this { diff --git a/packages/tui/src/form/builder/output.ts b/packages/tui/src/form/builder/output.ts index 18c0b178..292df3ff 100644 --- a/packages/tui/src/form/builder/output.ts +++ b/packages/tui/src/form/builder/output.ts @@ -1,10 +1,12 @@ -import { alert, datatable, error, info, intro, note, outro, table, warning } from '#tui/output'; +import { alert, clear, dataTable, datatable, error, grid, info, intro, note, notify, outro, table, title, warning } from '#tui/output'; import { sideEffectStep } from '#tui/form/builder/step'; import type { FormBuilder } from '#tui/form/builder/index'; import type { DataTablePromptOptions, DataTableRow, MaybePromise, TableOptions } from '#tui/types'; export type OutputBuilderMethods = { alert(this: FormBuilder, message: string, name?: string): FormBuilder; + clear(this: FormBuilder, name?: string): FormBuilder; + dataTable(this: FormBuilder, headersOrOptions?: TableOptions | string[], rows?: TableOptions['rows'] | null, name?: string): FormBuilder; datatable(this: FormBuilder, options: DataTablePromptOptions, name?: string): FormBuilder; datatable( this: FormBuilder, @@ -20,11 +22,14 @@ export type OutputBuilderMethods = { name?: string, ): FormBuilder; error(this: FormBuilder, message: string, name?: string): FormBuilder; + grid(this: FormBuilder, items?: Array, maxWidth?: number, name?: string): FormBuilder; info(this: FormBuilder, message: string, name?: string): FormBuilder; intro(this: FormBuilder, message: string, name?: string): FormBuilder; note(this: FormBuilder, message: string, type?: string | null, name?: string): FormBuilder; + notify(this: FormBuilder, title: string, body?: string, subtitle?: string, sound?: string, icon?: string, name?: string): FormBuilder; outro(this: FormBuilder, message: string, name?: string): FormBuilder; table(this: FormBuilder, headersOrOptions?: TableOptions | string[], rows?: TableOptions['rows'] | null, name?: string): FormBuilder; + title(this: FormBuilder, value: string, name?: string): FormBuilder; warning(this: FormBuilder, message: string, name?: string): FormBuilder; }; @@ -38,6 +43,20 @@ export const outputBuilderMethods: OutputBuilderMethods & ThisType true, ); }, + clear(name) { + return this.add( + displayStep(() => clear()), + name, + true, + ); + }, + dataTable(headersOrOptions = [], rows = null, name) { + return this.add( + displayStep(() => dataTable(headersOrOptions, rows)), + name, + true, + ); + }, datatable( optionsOrHeaders: DataTablePromptOptions | string[] = [], rowsOrName: Array> | null | string = null, @@ -63,6 +82,13 @@ export const outputBuilderMethods: OutputBuilderMethods & ThisType true, ); }, + grid(items = [], maxWidth, name) { + return this.add( + displayStep(() => grid(items, maxWidth)), + name, + true, + ); + }, info(message, name) { return this.add( displayStep(() => info(message)), @@ -84,6 +110,13 @@ export const outputBuilderMethods: OutputBuilderMethods & ThisType true, ); }, + notify(message, body = '', subtitle = '', sound = '', icon = '', name) { + return this.add( + displayStep(() => notify(message, body, subtitle, sound, icon)), + name, + true, + ); + }, outro(message, name) { return this.add( displayStep(() => outro(message)), @@ -98,6 +131,13 @@ export const outputBuilderMethods: OutputBuilderMethods & ThisType true, ); }, + title(value, name) { + return this.add( + displayStep(() => title(value)), + name, + true, + ); + }, warning(message, name) { return this.add( displayStep(() => warning(message)), diff --git a/packages/tui/tests/form.test.ts b/packages/tui/tests/form.test.ts index f4550522..d4e761b2 100644 --- a/packages/tui/tests/form.test.ts +++ b/packages/tui/tests/form.test.ts @@ -594,11 +594,25 @@ describe('form builder', () => { form() .note('Saved', null, 'note') .table(['Name'], [['Ollin']], 'table') + .dataTable(['Alias'], [['Display']], 'dataTable') + .grid(['A', 'B'], 80, 'grid') + .title('Prompt', 'title') + .clear('clear') .submit(), ); expect(responses.note).toBeNull(); expect(responses.table).toBeNull(); + expect(responses.dataTable).toBeNull(); + expect(responses.grid).toBeNull(); + expect(responses.title).toBeNull(); + expect(responses.clear).toBeNull(); + expect(output.text()).toContain('Ollin'); + expect(output.text()).toContain('Display'); + expect(output.text()).toContain('A'); + expect(output.text()).toContain('B'); + expect(output.text()).toContain('\u001B]0;Prompt\u0007'); + expect(output.text()).toContain('\u001B[H\u001B[J'); }); it('runs label-first data table form steps', async () => { From a8202e1164c797035726260438609c74a1724369 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Fri, 12 Jun 2026 13:19:04 +0800 Subject: [PATCH 237/675] Align form status overloads --- UPSTREAM.md | 1 + packages/tui/src/form/builder/status.ts | 20 +++++++++++---- packages/tui/tests/form.test.ts | 33 +++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 5 deletions(-) diff --git a/UPSTREAM.md b/UPSTREAM.md index cca52705..040f3896 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -102,6 +102,7 @@ TypeScript and OpenTUI internals. | Spinner static and deterministic animated frame rendering | `packages/tui/src/status/spinner.ts`, `packages/tui/src/status/spinner/render.ts` | | Spinner empty default message | `packages/tui/src/status/spinner.ts`, `packages/tui/src/form/builder/status.ts`, `packages/tui/tests/spinner.test.ts`, `packages/tui/tests/form.test.ts` | | Task static and deterministic animated frame rendering, bounded logs, and stable summary symbols | `packages/tui/src/status/task.ts`, `packages/tui/src/status/task/*` | +| Form builder root-aligned spinner and task overloads | `packages/tui/src/form/builder/status.ts`, `packages/tui/tests/form.test.ts` | | Task process output capture | `packages/tui/src/status/task/process-output.ts`, `packages/tui/tests/task.test.ts` | | Stream frame rendering, wrapping, fade styles, and close flushing | `packages/tui/src/status/stream.ts`, `packages/tui/src/status/stream/*` | | Status cursor visibility cleanup | `packages/tui/src/status/spinner.ts`, `packages/tui/src/status/stream.ts`, `packages/tui/src/status/task.ts` | diff --git a/packages/tui/src/form/builder/status.ts b/packages/tui/src/form/builder/status.ts index a976c1fb..de4ccbd4 100644 --- a/packages/tui/src/form/builder/status.ts +++ b/packages/tui/src/form/builder/status.ts @@ -2,11 +2,13 @@ import { progress, spin, stream, task } from '#tui/status'; import { sideEffectStep } from '#tui/form/builder/step'; import type { FormBuilder } from '#tui/form/builder/index'; import type { MaybePromise } from '#tui/types'; -import type { Logger, Progress } from '#tui/status'; +import type { Logger, Progress, TaskDefinition } from '#tui/status'; export type StatusBuilderMethods = { 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, source: AsyncIterable | Iterable, name?: string): FormBuilder; task(this: FormBuilder, label: string, callback: (logger: Logger) => MaybePromise, limit?: number, keepSummary?: boolean, subLabel?: string, name?: string): FormBuilder; }; @@ -15,8 +17,12 @@ export const statusBuilderMethods: StatusBuilderMethods & ThisType progress(label: string, steps: Iterable | number, callback?: (step: T | number, bar: Progress) => MaybePromise, hint = '', name?: string) { return this.add(() => progress(label, steps, callback, hint), name, true); }, - spin(callback: () => MaybePromise, message = '', name?: string) { - return this.add(() => spin(callback, { message }), name, true); + spin(callbackOrMessage: (() => MaybePromise) | string, messageOrCallback: string | (() => MaybePromise) = '', name?: string) { + if (typeof callbackOrMessage === 'string') { + return this.add(() => spin(callbackOrMessage, messageOrCallback as () => MaybePromise), name, true); + } + + return this.add(() => spin(callbackOrMessage, { message: messageOrCallback as string }), name, true); }, stream(source: AsyncIterable | Iterable, name?: string) { return this.add( @@ -25,7 +31,11 @@ export const statusBuilderMethods: StatusBuilderMethods & ThisType true, ); }, - task(label: string, callback: (logger: Logger) => MaybePromise, limit = 10, keepSummary = false, subLabel = '', name?: string) { - return this.add(() => task(label, callback, limit, keepSummary, subLabel), name); + task(definitionOrLabel: TaskDefinition | string, callbackOrName?: ((logger: Logger) => MaybePromise) | string, limit = 10, keepSummary = false, subLabel = '', name?: string) { + if (typeof definitionOrLabel === 'string') { + return this.add(() => task(definitionOrLabel, callbackOrName as (logger: Logger) => MaybePromise, limit, keepSummary, subLabel), name); + } + + return this.add(() => task(definitionOrLabel), callbackOrName as string | undefined); }, }; diff --git a/packages/tui/tests/form.test.ts b/packages/tui/tests/form.test.ts index d4e761b2..741104fa 100644 --- a/packages/tui/tests/form.test.ts +++ b/packages/tui/tests/form.test.ts @@ -582,6 +582,39 @@ describe('form builder', () => { expect(output.text()).toContain('2 / 2'); }); + it('runs root-aligned status overloads from form steps', async () => { + const output = createMemoryOutput(); + + const responses = await withPromptEnvironment( + { + output, + error: output, + }, + () => + form() + .spin('Loading', async () => 'spun', 'spin') + .task( + { + keepSummary: true, + task: (logger) => { + logger.info('done'); + + return 2; + }, + title: 'Build', + }, + 'task', + ) + .submit(), + ); + + expect(responses.spin).toBe('spun'); + expect(responses.task).toBe(2); + expect(output.text()).toContain('Loading'); + expect(output.text()).toContain('Build'); + expect(output.text()).toContain('done'); + }); + it('stores null for display-only output helper responses', async () => { const output = createMemoryOutput(); From 0de46b0c2ab108bb93e04556bc6f319af7ca190e Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Fri, 12 Jun 2026 13:23:12 +0800 Subject: [PATCH 238/675] Apply transforms to non-interactive defaults --- UPSTREAM.md | 1 + packages/tui/src/prompt/lifecycle.ts | 14 ++++++- packages/tui/tests/choices.test.ts | 63 ++++++++++++++++++++++++++++ packages/tui/tests/number.test.ts | 21 ++++++++++ packages/tui/tests/search.test.ts | 44 +++++++++++++++++++ packages/tui/tests/text.test.ts | 19 +++++++++ 6 files changed, 160 insertions(+), 2 deletions(-) diff --git a/UPSTREAM.md b/UPSTREAM.md index 040f3896..8c4e4c4f 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -17,6 +17,7 @@ TypeScript and OpenTUI internals. | 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 | `packages/tui/src/prompt/lifecycle.ts`, `packages/tui/tests/text.test.ts` | | Prompt post-validation submitted-frame lifecycle | `packages/tui/src/prompt/lifecycle.ts`, `packages/tui/src/prompts/text.ts`, `packages/tui/src/prompts/number.ts`, `packages/tui/tests/text.test.ts`, `packages/tui/tests/number.test.ts` | +| Prompt non-interactive transform lifecycle before validation | `packages/tui/src/prompt/lifecycle.ts`, `packages/tui/tests/text.test.ts`, `packages/tui/tests/number.test.ts`, `packages/tui/tests/choices.test.ts`, `packages/tui/tests/search.test.ts` | | Key constants 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` | diff --git a/packages/tui/src/prompt/lifecycle.ts b/packages/tui/src/prompt/lifecycle.ts index 59f4faa5..367e6653 100644 --- a/packages/tui/src/prompt/lifecycle.ts +++ b/packages/tui/src/prompt/lifecycle.ts @@ -2,12 +2,22 @@ import { promptEnvironment } from '#tui/environment'; import { PromptValidationError } from '#tui/prompt/error'; import { ensureRequired, validationMessage } from '#tui/prompt/validation'; import { renderError } from '#tui/theme'; -import type { BasePromptOptions } from '#tui/types'; +import type { BasePromptOptions, MaybePromise } from '#tui/types'; type PromptReader = (attempt: number) => Promise; type PromptValidHandler = (value: T) => void | Promise; +type TransformablePromptOptions = BasePromptOptions & { + transform?: (value: T) => MaybePromise; +}; + +const defaultPromptValue = async (options: TransformablePromptOptions): Promise => { + const value = options.default as T; + + return options.transform ? options.transform(value) : value; +}; + const validatedPromptValue = async (options: BasePromptOptions, value: T): Promise => { return ensureRequired(value, options.required) ?? (await validationMessage(value, options.validate)); }; @@ -16,7 +26,7 @@ export const promptUntilValid = async (options: BasePromptOptions, read: P const environment = promptEnvironment(); if (!environment.interactive) { - const value = options.default as T; + const value = await defaultPromptValue(options); const validation = await validatedPromptValue(options, value); diff --git a/packages/tui/tests/choices.test.ts b/packages/tui/tests/choices.test.ts index 6da55fe0..fc24afe7 100644 --- a/packages/tui/tests/choices.test.ts +++ b/packages/tui/tests/choices.test.ts @@ -218,6 +218,27 @@ describe('choice prompts', () => { expect(result).toBe(false); }); + it('transforms non-interactive confirm defaults before validation and return', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + output, + error: output, + interactive: false, + }, + () => + confirm({ + message: 'Continue?', + default: true, + transform: (value) => !value, + validate: (value) => (value === false ? null : 'Unexpected value.'), + }), + ); + + expect(result).toBe(false); + }); + it('selects with arrow keys and enter', async () => { const output = createMemoryOutput(); @@ -311,6 +332,26 @@ describe('choice prompts', () => { expect(result).toBe('second'); }); + it('transforms non-interactive select defaults before validation and return', async () => { + const result = await withPromptEnvironment( + { + output: createMemoryOutput(), + error: createMemoryOutput(), + interactive: false, + }, + () => + select({ + message: 'Pick one', + options: ['first', 'second'], + default: 'second', + transform: (value) => value.toUpperCase(), + validate: (value) => (value === 'SECOND' ? null : 'Unexpected value.'), + }), + ); + + expect(result).toBe('SECOND'); + }); + it('rejects non-interactive select prompts without defaults', async () => { await expect( withPromptEnvironment( @@ -649,6 +690,28 @@ describe('choice prompts', () => { expect(result).toEqual([]); }); + it('transforms non-interactive multiselect defaults before validation and return', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + output, + error: output, + interactive: false, + }, + () => + multiselect({ + message: 'Pick many', + options: ['first', 'second'], + default: ['first'], + transform: (value) => [...value, 'second'], + validate: (value) => (value.length === 2 ? null : 'Unexpected value.'), + }), + ); + + expect(result).toEqual(['first', 'second']); + }); + it('rejects invalid comma-separated line-mode multiselect answers and retries', async () => { const output = createMemoryOutput(); const answers = ['1,missing', '1,2']; diff --git a/packages/tui/tests/number.test.ts b/packages/tui/tests/number.test.ts index 4a972079..07006d32 100644 --- a/packages/tui/tests/number.test.ts +++ b/packages/tui/tests/number.test.ts @@ -297,6 +297,27 @@ describe('number prompt', () => { ).rejects.toThrow(PromptValidationError); }); + it('transforms non-interactive number defaults before validation and return', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + output, + error: output, + interactive: false, + }, + () => + number({ + message: 'Count', + default: 2, + transform: (value) => Number(value) * 2, + validate: (value) => (value === 4 ? null : 'Unexpected value.'), + }), + ); + + expect(result).toBe(4); + }); + it('renders cancelled number frames with the current value', async () => { const output = createMemoryOutput(); diff --git a/packages/tui/tests/search.test.ts b/packages/tui/tests/search.test.ts index 9739762d..0f279f87 100644 --- a/packages/tui/tests/search.test.ts +++ b/packages/tui/tests/search.test.ts @@ -371,6 +371,28 @@ describe('search prompt', () => { expect(result).toBe('green'); }); + it('transforms non-interactive search defaults before validation and return', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + output, + error: output, + interactive: false, + }, + () => + search({ + message: 'Favorite color?', + options: colors, + default: 'green', + transform: (value) => value.toUpperCase(), + validate: (value) => (value === 'GREEN' ? null : 'Unexpected value.'), + }), + ); + + expect(result).toBe('GREEN'); + }); + it('trims line-mode search answers before matching choices', async () => { const output = createMemoryOutput(); @@ -512,6 +534,28 @@ describe('multisearch prompt', () => { expect(result).toEqual([]); }); + it('transforms non-interactive multisearch defaults before validation and return', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + output, + error: output, + interactive: false, + }, + () => + multisearch({ + message: 'Favorite colors?', + options: colors, + default: ['green'], + transform: (value) => [...value, 'red'], + validate: (value) => (value.length === 2 ? null : 'Unexpected value.'), + }), + ); + + expect(result).toEqual(['green', 'red']); + }); + it('rejects disabled line-mode multisearch choices and retries', async () => { const output = createMemoryOutput(); const answers = ['Red', 'Green']; diff --git a/packages/tui/tests/text.test.ts b/packages/tui/tests/text.test.ts index aee28f0e..b64b2867 100644 --- a/packages/tui/tests/text.test.ts +++ b/packages/tui/tests/text.test.ts @@ -157,6 +157,25 @@ describe('text prompt', () => { ).rejects.toThrow(PromptValidationError); }); + it('transforms non-interactive default values before validation and return', async () => { + const result = await withPromptEnvironment( + { + output: createMemoryOutput(), + error: createMemoryOutput(), + interactive: false, + }, + () => + text({ + message: 'Name', + default: ' Ada ', + transform: (value) => value.trim(), + validate: (value) => (value === 'Ada' ? null : 'Unexpected value.'), + }), + ); + + expect(result).toBe('Ada'); + }); + it('handles failed empty key reads gracefully', async () => { const output = createMemoryOutput(); From 1eba0822defca50c0cf681fe3533ad308aaabf1a Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Fri, 12 Jun 2026 13:25:01 +0800 Subject: [PATCH 239/675] Style validation errors in red --- UPSTREAM.md | 1 + packages/tui/src/theme.ts | 3 ++- packages/tui/tests/prompt.test.ts | 1 + 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/UPSTREAM.md b/UPSTREAM.md index 8c4e4c4f..be648fdb 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -18,6 +18,7 @@ TypeScript and OpenTUI internals. | Prompt validation retry default lifecycle | `packages/tui/src/prompt/lifecycle.ts`, `packages/tui/tests/text.test.ts` | | Prompt post-validation submitted-frame lifecycle | `packages/tui/src/prompt/lifecycle.ts`, `packages/tui/src/prompts/text.ts`, `packages/tui/src/prompts/number.ts`, `packages/tui/tests/text.test.ts`, `packages/tui/tests/number.test.ts` | | Prompt non-interactive transform lifecycle before validation | `packages/tui/src/prompt/lifecycle.ts`, `packages/tui/tests/text.test.ts`, `packages/tui/tests/number.test.ts`, `packages/tui/tests/choices.test.ts`, `packages/tui/tests/search.test.ts` | +| Prompt validation error styling | `packages/tui/src/theme.ts`, `packages/tui/tests/prompt.test.ts` | | Key constants 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` | diff --git a/packages/tui/src/theme.ts b/packages/tui/src/theme.ts index 602e4e0b..f0bd3dda 100644 --- a/packages/tui/src/theme.ts +++ b/packages/tui/src/theme.ts @@ -1,4 +1,5 @@ import { visibleWidth } from '#tui/strings'; +import { red } from '#tui/theme/styles'; import type { Choice } from '#tui/types'; export const symbols = { @@ -19,7 +20,7 @@ export const renderQuestion = (message: string, hint?: string): string => { return `${symbols.question} ${message}${suffix} `; }; -export const renderError = (message: string): string => `${symbols.error} ${message}\n`; +export const renderError = (message: string): string => `${red(`${symbols.error} ${message}`)}\n`; export const renderChoices = (choices: Array>): string => { return choices diff --git a/packages/tui/tests/prompt.test.ts b/packages/tui/tests/prompt.test.ts index 6db7b481..9920701b 100644 --- a/packages/tui/tests/prompt.test.ts +++ b/packages/tui/tests/prompt.test.ts @@ -81,6 +81,7 @@ describe('prompt validation', () => { expect(result).toBe('Ada'); expect(output.text()).toContain('Required.'); + expect(output.text()).toContain('\u001B[31m! Required.\u001B[39m'); }); it('rejects missing line input support', async () => { From 51a64e28fc24e6a7007b862f79f3c4601d196bdc Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Fri, 12 Jun 2026 13:28:59 +0800 Subject: [PATCH 240/675] Support form manual progress overload --- UPSTREAM.md | 1 + packages/tui/src/form/builder/status.ts | 15 +++++++++++-- packages/tui/tests/form.test.ts | 29 ++++++++++++++++++++++++- 3 files changed, 42 insertions(+), 3 deletions(-) diff --git a/UPSTREAM.md b/UPSTREAM.md index be648fdb..d3187441 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -105,6 +105,7 @@ TypeScript and OpenTUI internals. | Spinner empty default message | `packages/tui/src/status/spinner.ts`, `packages/tui/src/form/builder/status.ts`, `packages/tui/tests/spinner.test.ts`, `packages/tui/tests/form.test.ts` | | Task static and deterministic animated frame rendering, bounded logs, and stable summary symbols | `packages/tui/src/status/task.ts`, `packages/tui/src/status/task/*` | | Form builder root-aligned spinner and task overloads | `packages/tui/src/form/builder/status.ts`, `packages/tui/tests/form.test.ts` | +| Form builder root-aligned manual progress overload | `packages/tui/src/form/builder/status.ts`, `packages/tui/tests/form.test.ts` | | Task process output capture | `packages/tui/src/status/task/process-output.ts`, `packages/tui/tests/task.test.ts` | | Stream frame rendering, wrapping, fade styles, and close flushing | `packages/tui/src/status/stream.ts`, `packages/tui/src/status/stream/*` | | Status cursor visibility cleanup | `packages/tui/src/status/spinner.ts`, `packages/tui/src/status/stream.ts`, `packages/tui/src/status/task.ts` | diff --git a/packages/tui/src/form/builder/status.ts b/packages/tui/src/form/builder/status.ts index de4ccbd4..163a91d2 100644 --- a/packages/tui/src/form/builder/status.ts +++ b/packages/tui/src/form/builder/status.ts @@ -5,6 +5,7 @@ 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; @@ -14,8 +15,18 @@ export type StatusBuilderMethods = { }; export const statusBuilderMethods: StatusBuilderMethods & ThisType = { - progress(label: string, steps: Iterable | number, callback?: (step: T | number, bar: Progress) => MaybePromise, hint = '', name?: string) { - return this.add(() => progress(label, steps, callback, hint), name, true); + progress( + labelOrTotal: string | number, + stepsOrMessage?: Iterable | number | string, + callbackOrName?: ((step: T | number, bar: Progress) => MaybePromise) | string, + hint = '', + name?: string, + ) { + if (typeof labelOrTotal === 'number') { + return this.add(() => progress(labelOrTotal, typeof stepsOrMessage === 'string' ? stepsOrMessage : undefined), callbackOrName as string | undefined, true); + } + + return this.add(() => progress(labelOrTotal, stepsOrMessage as Iterable | number, callbackOrName as (step: T | number, bar: Progress) => MaybePromise, hint), name, true); }, spin(callbackOrMessage: (() => MaybePromise) | string, messageOrCallback: string | (() => MaybePromise) = '', name?: string) { if (typeof callbackOrMessage === 'string') { diff --git a/packages/tui/tests/form.test.ts b/packages/tui/tests/form.test.ts index 741104fa..750386e0 100644 --- a/packages/tui/tests/form.test.ts +++ b/packages/tui/tests/form.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { confirm, createMemoryOutput, createScriptedInput, form, Key, outro, text, withPromptEnvironment } from '#tui/index'; +import { confirm, createMemoryOutput, createScriptedInput, form, Key, outro, Progress, text, withPromptEnvironment } from '#tui/index'; describe('form builder', () => { it('runs chained steps and returns positional responses', async () => { @@ -615,6 +615,33 @@ describe('form builder', () => { expect(output.text()).toContain('done'); }); + it('creates manual progress bars from form steps', async () => { + const output = createMemoryOutput(); + + const responses = await withPromptEnvironment( + { + output, + error: output, + }, + async () => { + const values = await form().progress(2, 'Manual', 'bar').submit(); + + const bar = values.bar as Progress; + + bar.start(); + bar.advance(); + bar.finish(); + + return values; + }, + ); + + expect(responses.bar).toBeInstanceOf(Progress); + expect((responses.bar as Progress).total).toBe(2); + expect(output.text()).toContain('Manual'); + expect(output.text()).toContain('1 / 2'); + }); + it('stores null for display-only output helper responses', async () => { const output = createMemoryOutput(); From 10e01fd2d73d4f84c0777cf81de2e61f5e9fe535 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Fri, 12 Jun 2026 13:30:41 +0800 Subject: [PATCH 241/675] Support form manual stream overload --- UPSTREAM.md | 1 + packages/tui/src/form/builder/status.ts | 9 ++++++-- packages/tui/tests/form.test.ts | 28 ++++++++++++++++++++++++- 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/UPSTREAM.md b/UPSTREAM.md index d3187441..872e91c4 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -108,6 +108,7 @@ TypeScript and OpenTUI internals. | Form builder root-aligned manual progress overload | `packages/tui/src/form/builder/status.ts`, `packages/tui/tests/form.test.ts` | | Task process output capture | `packages/tui/src/status/task/process-output.ts`, `packages/tui/tests/task.test.ts` | | Stream frame rendering, wrapping, fade styles, and close flushing | `packages/tui/src/status/stream.ts`, `packages/tui/src/status/stream/*` | +| Form builder root-aligned manual stream overload | `packages/tui/src/form/builder/status.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 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.ts`, `packages/tui/src/status/frame.ts` | diff --git a/packages/tui/src/form/builder/status.ts b/packages/tui/src/form/builder/status.ts index 163a91d2..75c55050 100644 --- a/packages/tui/src/form/builder/status.ts +++ b/packages/tui/src/form/builder/status.ts @@ -2,7 +2,7 @@ import { progress, spin, stream, task } from '#tui/status'; import { sideEffectStep } from '#tui/form/builder/step'; import type { FormBuilder } from '#tui/form/builder/index'; import type { MaybePromise } from '#tui/types'; -import type { Logger, Progress, TaskDefinition } from '#tui/status'; +import type { Logger, Progress, Stream, TaskDefinition } from '#tui/status'; export type StatusBuilderMethods = { progress(this: FormBuilder, total: number, message?: string, name?: string): FormBuilder; @@ -10,6 +10,7 @@ export type StatusBuilderMethods = { 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): 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; }; @@ -35,7 +36,11 @@ export const statusBuilderMethods: StatusBuilderMethods & ThisType return this.add(() => spin(callbackOrMessage, { message: messageOrCallback as string }), name, true); }, - stream(source: AsyncIterable | Iterable, name?: string) { + stream(source?: AsyncIterable | Iterable, name?: string) { + if (source === undefined) { + return this.add(() => stream() as Stream); + } + return this.add( sideEffectStep(() => stream(source)), name, diff --git a/packages/tui/tests/form.test.ts b/packages/tui/tests/form.test.ts index 750386e0..b169635e 100644 --- a/packages/tui/tests/form.test.ts +++ b/packages/tui/tests/form.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { confirm, createMemoryOutput, createScriptedInput, form, Key, outro, Progress, text, withPromptEnvironment } from '#tui/index'; +import { confirm, createMemoryOutput, createScriptedInput, form, Key, outro, Progress, Stream, text, withPromptEnvironment } from '#tui/index'; describe('form builder', () => { it('runs chained steps and returns positional responses', async () => { @@ -642,6 +642,32 @@ describe('form builder', () => { expect(output.text()).toContain('1 / 2'); }); + it('creates manual streams from form steps', async () => { + const output = createMemoryOutput(); + + const responses = await withPromptEnvironment( + { + output, + error: output, + }, + async () => { + const values = await form().stream().submit(); + + const outputStream = values[0] as Stream; + + outputStream.write('hello'); + outputStream.close(); + + return values; + }, + ); + + expect(responses[0]).toBeInstanceOf(Stream); + expect((responses[0] as Stream).value()).toBe('hello'); + expect(output.text()).toContain('hello'); + expect(output.text()).toContain('\u001B[?25h'); + }); + it('stores null for display-only output helper responses', async () => { const output = createMemoryOutput(); From 43399d94239afb1ebd6dcee29c1b9d8818a4c6e9 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Fri, 12 Jun 2026 13:34:28 +0800 Subject: [PATCH 242/675] Support object basic form prompts --- UPSTREAM.md | 1 + .../tui/src/form/builder/prompts/basic.ts | 148 ++++++++++++++++-- .../form/builder/prompts/contracts/basic.ts | 6 +- .../form/builder/prompts/validators/basic.ts | 7 + packages/tui/tests/form.test.ts | 25 +++ 5 files changed, 174 insertions(+), 13 deletions(-) create mode 100644 packages/tui/src/form/builder/prompts/validators/basic.ts diff --git a/UPSTREAM.md b/UPSTREAM.md index 872e91c4..3d1b17a5 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -92,6 +92,7 @@ TypeScript and OpenTUI internals. | 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/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 | `packages/tui/src/form/builder/prompts/basic.ts`, `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 suggest and autocomplete placeholder/default arguments | `packages/tui/src/form/builder/prompts/choices.ts`, `packages/tui/src/form/builder/prompts/contracts/choices.ts`, `packages/tui/tests/form.test.ts` | | Form builder suggest and autocomplete object-option method surface | `packages/tui/src/form/builder/prompts/choices.ts`, `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 number transform argument surface | `packages/tui/src/form/builder/prompts/basic.ts`, `packages/tui/src/form/builder/prompts/contracts/basic.ts`, `packages/tui/src/prompts/number.ts`, `packages/tui/tests/form.test.ts`, `packages/tui/tests/number.test.ts` | diff --git a/packages/tui/src/form/builder/prompts/basic.ts b/packages/tui/src/form/builder/prompts/basic.ts index 248b5e87..6323bfec 100644 --- a/packages/tui/src/form/builder/prompts/basic.ts +++ b/packages/tui/src/form/builder/prompts/basic.ts @@ -1,19 +1,143 @@ import { number, password, text, textarea } from '#tui/prompts/basic'; import { previousNumber, previousString } from '#tui/form/builder/previous'; +import { isBasicPromptLabel } from '#tui/form/builder/prompts/validators/basic'; import type { FormBuilder } from '#tui/form/builder/index'; import type { BasicPromptBuilderMethods } from '#tui/form/builder/prompts/types'; +import type { NumberPromptOptions, TextareaPromptOptions, TextPromptOptions } from '#tui/types'; + +function numberFormStep(this: FormBuilder, options: NumberPromptOptions, name?: string): FormBuilder; +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; + +function numberFormStep( + this: FormBuilder, + optionsOrLabel: NumberPromptOptions | string, + placeholder = '', + defaultValue: number | string = 0, + required: NumberPromptOptions['required'] = false, + validate: NumberPromptOptions['validate'] = undefined, + hint = '', + min?: number, + max?: number, + step?: number, + name?: string, + transform: NumberPromptOptions['transform'] = undefined, +): FormBuilder { + if (!isBasicPromptLabel(optionsOrLabel)) { + return this.add((_, previous) => number({ ...optionsOrLabel, default: previousNumber(previous, optionsOrLabel.default ?? 0) }), placeholder); + } + + return this.add((_, previous) => number(optionsOrLabel, placeholder, previousNumber(previous, defaultValue), required, validate, hint, min, max, step, transform), name); +} + +function passwordFormStep(this: FormBuilder, options: TextPromptOptions, name?: string): FormBuilder; +function passwordFormStep( + this: FormBuilder, + label: string, + placeholder?: string, + required?: boolean | string, + validate?: TextPromptOptions['validate'], + hint?: string, + name?: string, + transform?: TextPromptOptions['transform'], +): FormBuilder; + +function passwordFormStep( + this: FormBuilder, + optionsOrLabel: TextPromptOptions | string, + placeholder = '', + required: TextPromptOptions['required'] = false, + validate: TextPromptOptions['validate'] = undefined, + hint = '', + name?: string, + transform: TextPromptOptions['transform'] = undefined, +): FormBuilder { + if (!isBasicPromptLabel(optionsOrLabel)) { + return this.add((_, previous) => password({ ...optionsOrLabel, default: previousString(previous, optionsOrLabel.default ?? '') }), placeholder); + } + + return this.add(() => password(optionsOrLabel, placeholder, required, validate, hint, transform), name); +} + +function textFormStep(this: FormBuilder, options: TextPromptOptions, name?: string): FormBuilder; +function textFormStep( + this: FormBuilder, + label: string, + placeholder?: string, + defaultValue?: string, + required?: boolean | string, + validate?: TextPromptOptions['validate'], + hint?: string, + name?: string, + transform?: TextPromptOptions['transform'], +): FormBuilder; + +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 { + if (!isBasicPromptLabel(optionsOrLabel)) { + return this.add((_, previous) => text({ ...optionsOrLabel, default: previousString(previous, optionsOrLabel.default ?? '') }), placeholder); + } + + return this.add((_, previous) => text(optionsOrLabel, placeholder, previousString(previous, defaultValue), required, validate, hint, transform), name); +} + +function textareaFormStep(this: FormBuilder, options: TextareaPromptOptions, name?: string): FormBuilder; +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; + +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 { + if (!isBasicPromptLabel(optionsOrLabel)) { + return this.add((_, previous) => textarea({ ...optionsOrLabel, default: previousString(previous, optionsOrLabel.default ?? '') }), placeholder); + } + + return this.add((_, previous) => textarea(optionsOrLabel, placeholder, previousString(previous, defaultValue), required, validate, hint, rows, transform), name); +} export const basicPromptBuilderMethods: BasicPromptBuilderMethods & ThisType = { - number(label, placeholder = '', defaultValue = 0, required = false, validate = undefined, hint = '', min, max, step, name, transform = undefined) { - return this.add((_, previous) => number(label, placeholder, previousNumber(previous, defaultValue), required, validate, hint, min, max, step, transform), name); - }, - password(label, placeholder = '', required = false, validate = undefined, hint = '', name, transform = undefined) { - return this.add(() => password(label, placeholder, required, validate, hint, transform), name); - }, - text(label, placeholder = '', defaultValue = '', required = false, validate = undefined, hint = '', name, transform = undefined) { - return this.add((_, previous) => text(label, placeholder, previousString(previous, defaultValue), required, validate, hint, transform), name); - }, - textarea(label, placeholder = '', defaultValue = '', required = false, validate = undefined, hint = '', rows = 5, name, transform = undefined) { - return this.add((_, previous) => textarea(label, placeholder, previousString(previous, defaultValue), required, validate, hint, rows, transform), name); - }, + number: numberFormStep, + password: passwordFormStep, + text: textFormStep, + textarea: textareaFormStep, }; diff --git a/packages/tui/src/form/builder/prompts/contracts/basic.ts b/packages/tui/src/form/builder/prompts/contracts/basic.ts index 5bae8f39..46f27a1f 100644 --- a/packages/tui/src/form/builder/prompts/contracts/basic.ts +++ b/packages/tui/src/form/builder/prompts/contracts/basic.ts @@ -1,8 +1,9 @@ import type { FormBuilder } from '#tui/form/builder/index'; -import type { NumberPromptOptions, TextPromptOptions } from '#tui/types'; +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, @@ -17,6 +18,7 @@ export type BasicPromptBuilderMethods = { name?: string, transform?: NumberPromptOptions['transform'], ): FormBuilder; + password(this: FormBuilder, options: TextPromptOptions, name?: string): FormBuilder; password( this: FormBuilder, label: string, @@ -27,6 +29,7 @@ export type BasicPromptBuilderMethods = { name?: string, transform?: TextPromptOptions['transform'], ): FormBuilder; + text(this: FormBuilder, options: TextPromptOptions, name?: string): FormBuilder; text( this: FormBuilder, label: string, @@ -38,6 +41,7 @@ export type BasicPromptBuilderMethods = { name?: string, transform?: TextPromptOptions['transform'], ): FormBuilder; + textarea(this: FormBuilder, options: TextareaPromptOptions, name?: string): FormBuilder; textarea( this: FormBuilder, label: string, diff --git a/packages/tui/src/form/builder/prompts/validators/basic.ts b/packages/tui/src/form/builder/prompts/validators/basic.ts new file mode 100644 index 00000000..cca39e67 --- /dev/null +++ b/packages/tui/src/form/builder/prompts/validators/basic.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; + +const basicPromptLabelSchema = z.string(); + +export const isBasicPromptLabel = (value: unknown): value is string => { + return basicPromptLabelSchema.safeParse(value).success; +}; diff --git a/packages/tui/tests/form.test.ts b/packages/tui/tests/form.test.ts index b169635e..60130dff 100644 --- a/packages/tui/tests/form.test.ts +++ b/packages/tui/tests/form.test.ts @@ -117,6 +117,31 @@ describe('form builder', () => { expect(responses.count).toBe(8); }); + it('runs object-option basic prompt form steps', async () => { + const output = createMemoryOutput(); + + const responses = await withPromptEnvironment( + { + input: createScriptedInput([Key.enter, Key.enter, Key.enter, Key.ctrlD]), + output, + error: output, + interactive: true, + }, + () => + form() + .text({ message: 'Name', default: 'Ada', transform: (value) => value.toUpperCase() }, 'name') + .number({ message: 'Count', default: 2, transform: (value) => Number(value) * 2 }, 'count') + .password({ message: 'Secret', default: 'shh', transform: (value) => `${value}!` }, 'secret') + .textarea({ message: 'Body', default: 'Line', rows: 3, transform: (value) => `${value}\nDone` }, 'body') + .submit(), + ); + + expect(responses.name).toBe('ADA'); + expect(responses.count).toBe(4); + expect(responses.secret).toBe('shh!'); + expect(responses.body).toBe('Line\nDone'); + }); + it('reuses array previous responses when reverting prompt builder steps', async () => { const output = createMemoryOutput(); From 8b32fbf0c677ca07830f023255812b9974343005 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Fri, 12 Jun 2026 13:39:01 +0800 Subject: [PATCH 243/675] Support object choice form prompts --- UPSTREAM.md | 1 + .../tui/src/form/builder/prompts/choices.ts | 152 +++++++++++++++--- .../form/builder/prompts/contracts/choices.ts | 5 +- .../form/builder/prompts/validators/select.ts | 7 + packages/tui/tests/form.test.ts | 39 +++++ 5 files changed, 183 insertions(+), 21 deletions(-) create mode 100644 packages/tui/src/form/builder/prompts/validators/select.ts diff --git a/UPSTREAM.md b/UPSTREAM.md index 3d1b17a5..e5ce8e1c 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -93,6 +93,7 @@ TypeScript and OpenTUI internals. | Form builder label-first search and multisearch methods | `packages/tui/src/form/builder/prompts/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 | `packages/tui/src/form/builder/prompts/basic.ts`, `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 | `packages/tui/src/form/builder/prompts/choices.ts`, `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/contracts/choices.ts`, `packages/tui/tests/form.test.ts` | | Form builder suggest and autocomplete object-option method surface | `packages/tui/src/form/builder/prompts/choices.ts`, `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 number transform argument surface | `packages/tui/src/form/builder/prompts/basic.ts`, `packages/tui/src/form/builder/prompts/contracts/basic.ts`, `packages/tui/src/prompts/number.ts`, `packages/tui/tests/form.test.ts`, `packages/tui/tests/number.test.ts` | diff --git a/packages/tui/src/form/builder/prompts/choices.ts b/packages/tui/src/form/builder/prompts/choices.ts index ffad22e5..e859c296 100644 --- a/packages/tui/src/form/builder/prompts/choices.ts +++ b/packages/tui/src/form/builder/prompts/choices.ts @@ -1,10 +1,11 @@ import { autocomplete, confirm, multiselect, multisearch, pause, search, select, suggest } from '#tui/prompts/choices'; import { previousArray, previousBoolean, previousString, previousValue } from '#tui/form/builder/previous'; import { isSearchPromptLabel } from '#tui/form/builder/prompts/validators/search'; +import { isSelectPromptLabel } from '#tui/form/builder/prompts/validators/select'; import { isSuggestPromptLabel } from '#tui/form/builder/prompts/validators/suggest'; import type { FormBuilder } from '#tui/form/builder/index'; import type { ChoicePromptBuilderMethods } from '#tui/form/builder/prompts/types'; -import type { ChoiceOptions, MultiSearchPromptOptions, SearchPromptOptions, TextPromptOptions } from '#tui/types'; +import type { ChoiceOptions, ConfirmPromptOptions, MultiSearchPromptOptions, MultiSelectPromptOptions, SearchPromptOptions, SelectPromptOptions, TextPromptOptions } from '#tui/types'; import type { SuggestOptions } from '#tui/prompts/choices'; function autocompleteFormStep(this: FormBuilder, options: SuggestOptions, name?: string): FormBuilder; @@ -104,11 +105,136 @@ function suggestFormStep( ); } +function confirmFormStep(this: FormBuilder, options: ConfirmPromptOptions, name?: string): FormBuilder; +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; + +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 { + if (!isSelectPromptLabel(optionsOrLabel)) { + const formStepName = isSelectPromptLabel(defaultValueOrName) ? defaultValueOrName : undefined; + + return this.add((_, previous) => confirm({ ...optionsOrLabel, default: previousBoolean(previous, optionsOrLabel.default ?? true) }), formStepName); + } + + return this.add((_, previous) => confirm(optionsOrLabel, previousBoolean(previous, defaultValueOrName as boolean), yes, no, required, validate, hint, transform), name); +} + +function multiselectFormStep(this: FormBuilder, options: MultiSelectPromptOptions, name?: string): FormBuilder; +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'], +): FormBuilder; + +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, +): FormBuilder { + if (!isSelectPromptLabel(optionsOrLabel)) { + return this.add((_, previous) => multiselect({ ...optionsOrLabel, default: previousArray(previous, optionsOrLabel.default ?? []) }), optionsOrName as string | undefined); + } + + return this.add( + (_, previous) => + multiselect({ + message: optionsOrLabel, + options: optionsOrName as ChoiceOptions, + default: previousArray(previous, defaultValue), + scroll, + required, + validate, + hint, + transform, + }), + name, + ); +} + +function selectFormStep(this: FormBuilder, options: SelectPromptOptions, name?: string): FormBuilder; +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'], +): FormBuilder; + +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, +): FormBuilder { + if (!isSelectPromptLabel(optionsOrLabel)) { + return this.add((_, previous) => select({ ...optionsOrLabel, default: previousValue(previous, optionsOrLabel.default) }), optionsOrName as string | undefined); + } + + return this.add( + (_, previous) => + select({ + message: optionsOrLabel, + options: optionsOrName as ChoiceOptions, + default: previousValue(previous, defaultValue), + scroll, + validate, + hint, + required, + transform, + }), + name, + ); +} + export const choicePromptBuilderMethods: ChoicePromptBuilderMethods & ThisType = { autocomplete: autocompleteFormStep, - confirm(label, defaultValue = true, yes = 'Yes', no = 'No', required = false, validate = undefined, hint = '', name, transform = undefined) { - return this.add((_, previous) => confirm(label, previousBoolean(previous, defaultValue), yes, no, required, validate, hint, transform), name); - }, + confirm: confirmFormStep, multisearch( optionsOrLabel: MultiSearchPromptOptions | string, options?: MultiSearchPromptOptions['options'] | string, @@ -138,19 +264,7 @@ export const choicePromptBuilderMethods: ChoicePromptBuilderMethods & ThisType multisearch({ ...promptOptions, default: previousArray(previous, promptOptions.default ?? []) }), name); }, - multiselect( - label: string, - options: ChoiceOptions, - defaultValue: T[] = [], - scroll = 5, - required: boolean | string = false, - validate = undefined, - hint = 'Use the space bar to select options.', - name?: string, - transform = undefined, - ) { - return this.add((_, previous) => multiselect({ message: label, options, default: previousArray(previous, defaultValue), scroll, required, validate, hint, transform }), name); - }, + multiselect: multiselectFormStep, pause(message = 'Press enter to continue...', name?: string) { return this.add(() => pause(message), name); }, @@ -183,8 +297,6 @@ export const choicePromptBuilderMethods: ChoicePromptBuilderMethods & ThisType search({ ...promptOptions, default: previousValue(previous, promptOptions.default) }), name); }, - select(label: string, options: ChoiceOptions, defaultValue?: T, scroll = 5, validate = undefined, hint = '', required: boolean | string = true, name?: string, transform = undefined) { - return this.add((_, previous) => select({ message: label, options, default: previousValue(previous, defaultValue), scroll, validate, hint, required, transform }), name); - }, + select: selectFormStep, suggest: suggestFormStep, }; diff --git a/packages/tui/src/form/builder/prompts/contracts/choices.ts b/packages/tui/src/form/builder/prompts/contracts/choices.ts index 5751d119..97ca2ea5 100644 --- a/packages/tui/src/form/builder/prompts/contracts/choices.ts +++ b/packages/tui/src/form/builder/prompts/contracts/choices.ts @@ -1,6 +1,6 @@ import type { FormBuilder } from '#tui/form/builder/index'; import type { SuggestOptions } from '#tui/prompts/choices'; -import type { ChoiceOptions, MaybePromise, MultiSearchPromptOptions, SearchPromptOptions, TextPromptOptions } from '#tui/types'; +import type { ChoiceOptions, ConfirmPromptOptions, MaybePromise, MultiSearchPromptOptions, MultiSelectPromptOptions, SearchPromptOptions, SelectPromptOptions, TextPromptOptions } from '#tui/types'; export type ChoicePromptBuilderMethods = { autocomplete(this: FormBuilder, options: SuggestOptions, name?: string): FormBuilder; @@ -16,6 +16,7 @@ export type ChoicePromptBuilderMethods = { name?: string, transform?: TextPromptOptions['transform'], ): FormBuilder; + confirm(this: FormBuilder, options: ConfirmPromptOptions, name?: string): FormBuilder; confirm( this: FormBuilder, label: string, @@ -41,6 +42,7 @@ export type ChoicePromptBuilderMethods = { name?: string, transform?: MultiSearchPromptOptions['transform'], ): FormBuilder; + multiselect(this: FormBuilder, options: MultiSelectPromptOptions, name?: string): FormBuilder; multiselect( this: FormBuilder, label: string, @@ -67,6 +69,7 @@ export type ChoicePromptBuilderMethods = { name?: string, transform?: SearchPromptOptions['transform'], ): FormBuilder; + select(this: FormBuilder, options: SelectPromptOptions, name?: string): FormBuilder; select( this: FormBuilder, label: string, diff --git a/packages/tui/src/form/builder/prompts/validators/select.ts b/packages/tui/src/form/builder/prompts/validators/select.ts new file mode 100644 index 00000000..b710e1ae --- /dev/null +++ b/packages/tui/src/form/builder/prompts/validators/select.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; + +const selectPromptLabelSchema = z.string(); + +export const isSelectPromptLabel = (value: unknown): value is string => { + return selectPromptLabelSchema.safeParse(value).success; +}; diff --git a/packages/tui/tests/form.test.ts b/packages/tui/tests/form.test.ts index 60130dff..1e6b017a 100644 --- a/packages/tui/tests/form.test.ts +++ b/packages/tui/tests/form.test.ts @@ -142,6 +142,45 @@ describe('form builder', () => { expect(responses.body).toBe('Line\nDone'); }); + it('runs object-option choice prompt form steps', async () => { + const output = createMemoryOutput(); + + const responses = await withPromptEnvironment( + { + input: createScriptedInput([Key.enter, Key.enter, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + form() + .confirm({ message: 'Continue?', default: false, transform: (value) => !value }, 'confirmed') + .select({ message: 'Color', options: { red: 'Red', green: 'Green' }, default: 'green', transform: (value) => value.toUpperCase() }, 'color') + .multiselect({ message: 'Colors', options: { red: 'Red', blue: 'Blue' }, default: ['red'], transform: (value) => [...value, 'blue'] }, 'colors') + .submit(), + ); + + expect(responses.confirmed).toBe(true); + expect(responses.color).toBe('GREEN'); + expect(responses.colors).toEqual(['red', 'blue']); + }); + + it('stores unnamed object-option confirm form steps positionally', async () => { + const output = createMemoryOutput(); + + const responses = await withPromptEnvironment( + { + input: createScriptedInput([Key.enter]), + output, + error: output, + interactive: true, + }, + () => form().confirm({ message: 'Continue?' }).submit(), + ); + + expect(responses[0]).toBe(true); + }); + it('reuses array previous responses when reverting prompt builder steps', async () => { const output = createMemoryOutput(); From 29f85e52407c18fcf6c0b1c98f6c0730d05b50d4 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Fri, 12 Jun 2026 13:42:11 +0800 Subject: [PATCH 244/675] Support object table form output names --- UPSTREAM.md | 1 + packages/tui/src/form/builder/output.ts | 57 ++++++++++++++++----- packages/tui/src/output/validators/table.ts | 11 ++++ packages/tui/tests/form.test.ts | 22 ++++++++ 4 files changed, 77 insertions(+), 14 deletions(-) diff --git a/UPSTREAM.md b/UPSTREAM.md index e5ce8e1c..6f1a2c01 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -100,6 +100,7 @@ TypeScript and OpenTUI internals. | Form builder search and multisearch previous-response defaults | `packages/tui/src/form/builder/prompts/choices.ts`, `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/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/tests/form.test.ts` | +| Form builder object-option table output method surface | `packages/tui/src/form/builder/output.ts`, `packages/tui/src/output/validators/table.ts`, `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/output.ts`, `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/tests/output.test.ts`, `packages/tui/tests/form.test.ts` | | Progress boxed frame rendering and formatted fractions | `packages/tui/src/status/progress/render.ts`, `packages/tui/src/theme/box.ts` | diff --git a/packages/tui/src/form/builder/output.ts b/packages/tui/src/form/builder/output.ts index 292df3ff..9af860a7 100644 --- a/packages/tui/src/form/builder/output.ts +++ b/packages/tui/src/form/builder/output.ts @@ -1,11 +1,13 @@ import { alert, clear, dataTable, datatable, error, grid, info, intro, note, notify, outro, table, title, warning } from '#tui/output'; import { sideEffectStep } from '#tui/form/builder/step'; +import { isTableOptions, tableStepName } from '#tui/output/validators/table'; import type { FormBuilder } from '#tui/form/builder/index'; import type { DataTablePromptOptions, DataTableRow, MaybePromise, TableOptions } from '#tui/types'; export type OutputBuilderMethods = { alert(this: FormBuilder, message: string, name?: string): FormBuilder; clear(this: FormBuilder, name?: string): FormBuilder; + dataTable(this: FormBuilder, options: TableOptions, name?: string): FormBuilder; dataTable(this: FormBuilder, headersOrOptions?: TableOptions | string[], rows?: TableOptions['rows'] | null, name?: string): FormBuilder; datatable(this: FormBuilder, options: DataTablePromptOptions, name?: string): FormBuilder; datatable( @@ -28,6 +30,7 @@ export type OutputBuilderMethods = { note(this: FormBuilder, message: string, type?: string | null, name?: string): FormBuilder; notify(this: FormBuilder, title: string, body?: string, subtitle?: string, sound?: string, icon?: string, name?: string): FormBuilder; outro(this: FormBuilder, message: string, name?: string): FormBuilder; + table(this: FormBuilder, options: TableOptions, name?: string): FormBuilder; table(this: FormBuilder, headersOrOptions?: TableOptions | string[], rows?: TableOptions['rows'] | null, name?: string): FormBuilder; title(this: FormBuilder, value: string, name?: string): FormBuilder; warning(this: FormBuilder, message: string, name?: string): FormBuilder; @@ -35,6 +38,44 @@ export type OutputBuilderMethods = { const displayStep = (callback: () => void): (() => MaybePromise) => sideEffectStep(callback); +function dataTableFormStep(this: FormBuilder, options: TableOptions, name?: string): FormBuilder; +function dataTableFormStep(this: FormBuilder, headersOrOptions?: TableOptions | string[], rows?: TableOptions['rows'] | null, name?: string): FormBuilder; + +function dataTableFormStep(this: FormBuilder, headersOrOptions: TableOptions | string[] = [], rowsOrName: TableOptions['rows'] | string | null = null, name?: string): FormBuilder { + if (isTableOptions(headersOrOptions)) { + return this.add( + displayStep(() => dataTable(headersOrOptions)), + tableStepName(rowsOrName), + true, + ); + } + + return this.add( + displayStep(() => dataTable(headersOrOptions, rowsOrName as TableOptions['rows'] | null)), + name, + true, + ); +} + +function tableFormStep(this: FormBuilder, options: TableOptions, name?: string): FormBuilder; +function tableFormStep(this: FormBuilder, headersOrOptions?: TableOptions | string[], rows?: TableOptions['rows'] | null, name?: string): FormBuilder; + +function tableFormStep(this: FormBuilder, headersOrOptions: TableOptions | string[] = [], rowsOrName: TableOptions['rows'] | string | null = null, name?: string): FormBuilder { + if (isTableOptions(headersOrOptions)) { + return this.add( + displayStep(() => table(headersOrOptions)), + tableStepName(rowsOrName), + true, + ); + } + + return this.add( + displayStep(() => table(headersOrOptions, rowsOrName as TableOptions['rows'] | null)), + name, + true, + ); +} + export const outputBuilderMethods: OutputBuilderMethods & ThisType = { alert(message, name) { return this.add( @@ -50,13 +91,7 @@ export const outputBuilderMethods: OutputBuilderMethods & ThisType true, ); }, - dataTable(headersOrOptions = [], rows = null, name) { - return this.add( - displayStep(() => dataTable(headersOrOptions, rows)), - name, - true, - ); - }, + dataTable: dataTableFormStep, datatable( optionsOrHeaders: DataTablePromptOptions | string[] = [], rowsOrName: Array> | null | string = null, @@ -124,13 +159,7 @@ export const outputBuilderMethods: OutputBuilderMethods & ThisType true, ); }, - table(headersOrOptions = [], rows = null, name) { - return this.add( - displayStep(() => table(headersOrOptions, rows)), - name, - true, - ); - }, + table: tableFormStep, title(value, name) { return this.add( displayStep(() => title(value)), diff --git a/packages/tui/src/output/validators/table.ts b/packages/tui/src/output/validators/table.ts index 25a0d58a..bf313c02 100644 --- a/packages/tui/src/output/validators/table.ts +++ b/packages/tui/src/output/validators/table.ts @@ -5,6 +5,7 @@ const tableCellSchema: z.ZodType = z.union([z.string(), z.number(), z const tableRowSchema = z.union([z.array(tableCellSchema), z.record(z.string(), tableCellSchema)]); const tableRowsSchema = z.array(tableRowSchema); const tableHeadersSchema = z.array(z.string()); +const tableStepNameSchema = z.string(); const tableOptionsSchema = z .object({ @@ -13,6 +14,16 @@ const tableOptionsSchema = z }) .passthrough(); +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); diff --git a/packages/tui/tests/form.test.ts b/packages/tui/tests/form.test.ts index 1e6b017a..bf741ce2 100644 --- a/packages/tui/tests/form.test.ts +++ b/packages/tui/tests/form.test.ts @@ -765,6 +765,28 @@ describe('form builder', () => { expect(output.text()).toContain('\u001B[H\u001B[J'); }); + it('stores null for named object-option table output form steps', async () => { + const output = createMemoryOutput(); + + const responses = await withPromptEnvironment( + { + output, + error: output, + }, + () => + form() + .table({ headers: ['Name'], rows: [['Ada']] }, 'tableOptions') + .dataTable({ headers: ['Alias'], rows: [['Display']] }, 'dataTableOptions') + .submit(), + ); + + expect(responses.tableOptions).toBeNull(); + expect(responses.dataTableOptions).toBeNull(); + expect(responses[0]).toBeUndefined(); + expect(output.text()).toContain('Ada'); + expect(output.text()).toContain('Display'); + }); + it('runs label-first data table form steps', async () => { const output = createMemoryOutput(); From db50d0aa437ed026af2618986cf00d0a486bb44f Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Fri, 12 Jun 2026 13:50:15 +0800 Subject: [PATCH 245/675] Support data table form defaults --- UPSTREAM.md | 2 + packages/tui/src/form/builder/output.ts | 56 +++++++++++++------ packages/tui/src/output/data-table/read.ts | 13 ++++- .../tui/src/output/validators/data-table.ts | 22 +++++++- packages/tui/tests/form.test.ts | 30 ++++++++++ packages/tui/tests/table.test.ts | 25 +++++++++ 6 files changed, 128 insertions(+), 20 deletions(-) diff --git a/UPSTREAM.md b/UPSTREAM.md index 6f1a2c01..4963ca2e 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -77,6 +77,7 @@ TypeScript and OpenTUI internals. | Tables, data tables, and grids | `packages/tui/src/output/table.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/tests/table.test.ts` | +| Data table interactive default selection | `packages/tui/src/output/data-table/read.ts`, `packages/tui/tests/table.test.ts` | | Data table no-results search rendering | `packages/tui/src/output/data-table/render.ts`, `packages/tui/tests/table.test.ts` | | Data table viewing-info rendering | `packages/tui/src/output/data-table/render.ts`, `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/read.ts`, `packages/tui/tests/table.test.ts` | @@ -100,6 +101,7 @@ TypeScript and OpenTUI internals. | Form builder search and multisearch previous-response defaults | `packages/tui/src/form/builder/prompts/choices.ts`, `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/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/tests/form.test.ts` | +| Form builder interactive data table object-option method surface | `packages/tui/src/form/builder/output.ts`, `packages/tui/src/output/validators/data-table.ts`, `packages/tui/tests/form.test.ts` | | Form builder object-option table output method surface | `packages/tui/src/form/builder/output.ts`, `packages/tui/src/output/validators/table.ts`, `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/output.ts`, `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/tests/output.test.ts`, `packages/tui/tests/form.test.ts` | diff --git a/packages/tui/src/form/builder/output.ts b/packages/tui/src/form/builder/output.ts index 9af860a7..78a3112b 100644 --- a/packages/tui/src/form/builder/output.ts +++ b/packages/tui/src/form/builder/output.ts @@ -1,5 +1,7 @@ import { alert, clear, dataTable, datatable, error, grid, info, intro, note, notify, outro, table, title, warning } from '#tui/output'; +import { previousValue } from '#tui/form/builder/previous'; import { sideEffectStep } from '#tui/form/builder/step'; +import { dataTableStepName, isDataTablePromptOptions } from '#tui/output/validators/data-table'; import { isTableOptions, tableStepName } from '#tui/output/validators/table'; import type { FormBuilder } from '#tui/form/builder/index'; import type { DataTablePromptOptions, DataTableRow, MaybePromise, TableOptions } from '#tui/types'; @@ -76,6 +78,41 @@ function tableFormStep(this: FormBuilder, headersOrOptions: TableOptions | strin ); } +function datatableFormStep(this: FormBuilder, options: DataTablePromptOptions, name?: string): FormBuilder; +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; + +function datatableFormStep( + this: FormBuilder, + optionsOrHeaders: DataTablePromptOptions | string[] = [], + rowsOrName: Array> | null | string = null, + scroll = 10, + label = '', + hint = '', + required: DataTablePromptOptions['required'] = false, + validate: DataTablePromptOptions['validate'] = undefined, + transform: DataTablePromptOptions['transform'] = undefined, + filter: DataTablePromptOptions['filter'] = undefined, + name?: string, +): FormBuilder { + if (isDataTablePromptOptions(optionsOrHeaders)) { + return this.add((_, previous) => datatable({ ...optionsOrHeaders, default: previousValue(previous, optionsOrHeaders.default) }), dataTableStepName(rowsOrName)); + } + + return this.add(() => datatable(optionsOrHeaders, rowsOrName as Array> | null, scroll, label, hint, required, validate, transform, filter), name); +} + export const outputBuilderMethods: OutputBuilderMethods & ThisType = { alert(message, name) { return this.add( @@ -92,24 +129,7 @@ export const outputBuilderMethods: OutputBuilderMethods & ThisType ); }, dataTable: dataTableFormStep, - datatable( - optionsOrHeaders: DataTablePromptOptions | string[] = [], - rowsOrName: Array> | null | string = null, - scroll = 10, - label = '', - hint = '', - required: DataTablePromptOptions['required'] = false, - validate: DataTablePromptOptions['validate'] = undefined, - transform: DataTablePromptOptions['transform'] = undefined, - filter: DataTablePromptOptions['filter'] = undefined, - name?: string, - ) { - if (Array.isArray(optionsOrHeaders)) { - return this.add(() => datatable(optionsOrHeaders, rowsOrName as Array> | null, scroll, label, hint, required, validate, transform, filter), name); - } - - return this.add(() => datatable(optionsOrHeaders), rowsOrName as string | undefined); - }, + datatable: datatableFormStep, error(message, name) { return this.add( displayStep(() => error(message)), diff --git a/packages/tui/src/output/data-table/read.ts b/packages/tui/src/output/data-table/read.ts index 0e4307fa..5bfa291c 100644 --- a/packages/tui/src/output/data-table/read.ts +++ b/packages/tui/src/output/data-table/read.ts @@ -22,14 +22,25 @@ const selectedDataTableValue = (rows: Array>, selected return dataTableRowValue(selectedRow.row, selectedRow.index); }; +const initialDataTableSelection = (rows: Array>, defaultValue: T | number | undefined): number => { + if (defaultValue === undefined) { + return 0; + } + + const selected = rows.findIndex(({ index, row }) => Object.is(dataTableRowValue(row, index), defaultValue)); + + return Math.max(0, selected); +}; + export const readDataTableSelection = async (options: DataTablePromptOptions, headers: string[]): Promise => { const environment = promptEnvironment(); - let selected = 0; let search: DataTableSearchState = initialDataTableSearchState(); const visibleRows = () => visibleDataTableRows(options, headers, search.query.value); + let selected = initialDataTableSelection(visibleRows(), options.default); + const render = (): void => { selected = renderDataTableFrame({ allRows: options.rows, diff --git a/packages/tui/src/output/validators/data-table.ts b/packages/tui/src/output/validators/data-table.ts index 948b74c7..da773176 100644 --- a/packages/tui/src/output/validators/data-table.ts +++ b/packages/tui/src/output/validators/data-table.ts @@ -1,9 +1,10 @@ import { z } from 'zod'; -import type { DataTableObjectRow, DataTableRow, TableCell } from '#tui/types'; +import type { DataTableObjectRow, DataTablePromptOptions, DataTableRow, TableCell } from '#tui/types'; const tableCellSchema: z.ZodType = z.union([z.string(), z.number(), z.boolean(), z.null(), z.undefined()]); const dataTableArrayRowSchema = z.array(tableCellSchema); const dataTableRecordRowSchema = z.record(z.string(), tableCellSchema); +const dataTableStepNameSchema = z.string(); const dataObjectRowSchema = z .object({ @@ -12,6 +13,15 @@ const dataObjectRowSchema = z }) .passthrough(); +const dataTableRowSchema = z.union([dataObjectRowSchema, dataTableArrayRowSchema, dataTableRecordRowSchema]); + +const dataTablePromptOptionsSchema = z + .object({ + message: z.string(), + rows: z.array(dataTableRowSchema), + }) + .passthrough(); + export type DataTableRowShape = | { kind: 'array'; @@ -30,6 +40,16 @@ export const isDataObjectRow = (row: DataTableRow): row is DataTableObject return dataObjectRowSchema.safeParse(row).success; }; +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 parseDataTableRowShape = (row: DataTableRow): DataTableRowShape => { const objectRow = dataObjectRowSchema.safeParse(row); diff --git a/packages/tui/tests/form.test.ts b/packages/tui/tests/form.test.ts index bf741ce2..a0c69779 100644 --- a/packages/tui/tests/form.test.ts +++ b/packages/tui/tests/form.test.ts @@ -821,4 +821,34 @@ describe('form builder', () => { expect(output.text()).toContain('Pick project slow'); expect(output.text()).toContain('Beta'); }); + + it('reuses previous data table responses when reverting object-option form steps', async () => { + const output = createMemoryOutput(); + + const responses = await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.enter, Key.ctrlU, Key.enter, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + form() + .datatable( + { + message: 'Pick project', + rows: [ + { Name: 'Alpha', value: 'alpha' }, + { Name: 'Beta', value: 'beta' }, + ], + }, + 'project', + ) + .confirm('Done?') + .submit(), + ); + + expect(responses.project).toBe('beta'); + expect(responses[1]).toBe(true); + }); }); diff --git a/packages/tui/tests/table.test.ts b/packages/tui/tests/table.test.ts index 3a8bbd60..21734c76 100644 --- a/packages/tui/tests/table.test.ts +++ b/packages/tui/tests/table.test.ts @@ -199,6 +199,31 @@ describe('data table prompt', () => { expect(result).toBe('ALPHA'); }); + it('starts data table selection on matching default values', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.enter]), + output, + error: output, + interactive: true, + }, + () => + datatable({ + default: 'beta', + message: 'Pick project', + rows: [ + { Name: 'Alpha', value: 'alpha' }, + { Name: 'Beta', value: 'beta' }, + ], + }), + ); + + expect(result).toBe('beta'); + expect(output.text()).toContain('Beta'); + }); + it('filters rows in explicit search mode', async () => { const output = createMemoryOutput(); From d6174d067cefda6a213e6d6cc0dc3bd7e0085f88 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Fri, 12 Jun 2026 13:56:27 +0800 Subject: [PATCH 246/675] Export public package types --- UPSTREAM.md | 1 + packages/acceptance/tests/package.test.ts | 89 ++++++++++++++++++++++ packages/tui/src/form/builder/index.ts | 63 +++++++-------- packages/tui/src/index.ts | 9 ++- packages/tui/src/output.ts | 2 +- packages/tui/src/output/notify.ts | 9 ++- packages/tui/src/output/notify/commands.ts | 4 +- packages/tui/src/output/notify/executor.ts | 4 +- 8 files changed, 142 insertions(+), 39 deletions(-) diff --git a/UPSTREAM.md b/UPSTREAM.md index 4963ca2e..02c7ffdc 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -126,6 +126,7 @@ TypeScript and OpenTUI internals. | ANSI segment parsing, ANSI-aware wrapping, truncation, Unicode width | `packages/tui/src/strings.ts`, `packages/tui/tests/strings.test.ts` | | OpenTUI adapter | `packages/tui/src/opentui.ts` | | Type contracts | `packages/tui/src/types.ts`, `packages/tui/src/contracts/*` | +| Public ESM package runtime and type-consumption surface | `packages/tui/src/index.ts`, `packages/tui/src/form/builder/index.ts`, `packages/tui/src/output/notify.ts`, `packages/tui/src/output/notify/*`, `packages/acceptance/tests/package.test.ts` | ## Remaining Exact-Parity Work diff --git a/packages/acceptance/tests/package.test.ts b/packages/acceptance/tests/package.test.ts index 02a6be2e..81108e7b 100644 --- a/packages/acceptance/tests/package.test.ts +++ b/packages/acceptance/tests/package.test.ts @@ -1,4 +1,5 @@ import { execFileSync } from 'node:child_process'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; @@ -124,4 +125,92 @@ describe('package consumption', () => { expect(JSON.parse(output) as { missing: string[] }).toEqual({ missing: [], count: expectedExports.length }); }); + + it('exports public helper types from the built root entrypoint', () => { + execFileSync('pnpm', ['--filter', '@ollin/tui', 'build'], { + cwd: workspacePath, + stdio: 'pipe', + }); + + const consumerDirectory = mkdtempSync(resolve(acceptancePath, '.types-')); + const consumerPath = resolve(consumerDirectory, 'consumer.ts'); + + try { + writeFileSync( + consumerPath, + ` + import type { + ChoiceOptions, + ConfirmPromptOptions, + FormResponses, + FormStep, + KeyboardEventLike, + KeyName, + KeyValue, + NoteType, + PromptCancelHandler, + NotificationPlatform, + SelectPromptOptions, + SuggestOptions, + TaskDefinition, + TerminalSize, + TypedValueOptions, + TypedValueReadResult, + TypedValueState + } from '@ollin/tui'; + + const choices: ChoiceOptions = ['one', { label: 'Two', value: 'two' }]; + const confirmOptions: ConfirmPromptOptions = { message: 'Continue?', default: true }; + const selectOptions: SelectPromptOptions = { message: 'Pick', options: choices }; + const suggestOptions: SuggestOptions = { message: 'Suggest', options: ['one'] }; + const formResponses: FormResponses = [] as unknown as FormResponses; + const formStep: FormStep = { condition: true, ignoreWhenReverting: false, run: () => null }; + const event: KeyboardEventLike = { name: 'return' }; + const keyName: KeyName = '\\n'; + const keyValue: KeyValue = keyName; + const noteType: NoteType = 'info'; + const notificationPlatform: NotificationPlatform = 'linux'; + const cancel: PromptCancelHandler = () => null; + const taskDefinition: TaskDefinition = { title: 'Build', task: () => 'done' }; + const terminalSize: TerminalSize = { columns: 80, rows: 24 }; + const typedOptions: TypedValueOptions = { default: 'value' }; + const typedState: TypedValueState = { cursor: 0, value: '' }; + const typedResult: TypedValueReadResult = { cancelled: false, value: 'value' }; + + void [ + confirmOptions, + selectOptions, + suggestOptions, + formResponses, + formStep, + event, + keyValue, + noteType, + notificationPlatform, + cancel, + taskDefinition, + terminalSize, + typedOptions, + typedState, + typedResult + ]; + `, + ); + + try { + execFileSync('pnpm', ['exec', 'tsc', '--ignoreConfig', '--module', 'NodeNext', '--moduleResolution', 'NodeNext', '--target', 'ES2022', '--strict', '--noEmit', consumerPath], { + cwd: acceptancePath, + stdio: 'pipe', + }); + } catch (error) { + const result = error as { stderr?: Buffer | string; stdout?: Buffer | string }; + const stderr = result.stderr?.toString() ?? ''; + const stdout = result.stdout?.toString() ?? ''; + + throw new Error([stdout, stderr].filter(Boolean).join('\n')); + } + } finally { + rmSync(consumerDirectory, { force: true, recursive: true }); + } + }); }); diff --git a/packages/tui/src/form/builder/index.ts b/packages/tui/src/form/builder/index.ts index b9ff7d38..62fe7ffe 100644 --- a/packages/tui/src/form/builder/index.ts +++ b/packages/tui/src/form/builder/index.ts @@ -2,42 +2,45 @@ import { outputBuilderMethods } from '#tui/form/builder/output'; import { promptBuilderMethods } from '#tui/form/builder/prompts'; import { statusBuilderMethods } from '#tui/form/builder/status'; import { submitFormSteps } from '#tui/form/builder/submit'; +import type { OutputBuilderMethods } from '#tui/form/builder/output'; +import type { PromptBuilderMethods } from '#tui/form/builder/prompts'; +import type { StatusBuilderMethods } from '#tui/form/builder/status'; import type { FormResponses, FormStep } from '#tui/form/types'; import type { MaybePromise } from '#tui/types'; export class FormBuilder { readonly #steps: FormStep[] = []; readonly #responses: FormResponses = [] as unknown as FormResponses; - readonly alert = outputBuilderMethods.alert; - readonly autocomplete = promptBuilderMethods.autocomplete; - readonly confirm = promptBuilderMethods.confirm; - readonly clear = outputBuilderMethods.clear; - readonly dataTable = outputBuilderMethods.dataTable; - readonly datatable = outputBuilderMethods.datatable; - readonly error = outputBuilderMethods.error; - readonly grid = outputBuilderMethods.grid; - readonly info = outputBuilderMethods.info; - readonly intro = outputBuilderMethods.intro; - readonly multisearch = promptBuilderMethods.multisearch; - readonly multiselect = promptBuilderMethods.multiselect; - readonly note = outputBuilderMethods.note; - readonly notify = outputBuilderMethods.notify; - readonly number = promptBuilderMethods.number; - readonly outro = outputBuilderMethods.outro; - readonly password = promptBuilderMethods.password; - readonly pause = promptBuilderMethods.pause; - readonly progress = statusBuilderMethods.progress; - readonly search = promptBuilderMethods.search; - readonly select = promptBuilderMethods.select; - readonly spin = statusBuilderMethods.spin; - readonly stream = statusBuilderMethods.stream; - readonly suggest = promptBuilderMethods.suggest; - readonly table = outputBuilderMethods.table; - readonly task = statusBuilderMethods.task; - readonly text = promptBuilderMethods.text; - readonly textarea = promptBuilderMethods.textarea; - readonly title = outputBuilderMethods.title; - readonly warning = outputBuilderMethods.warning; + readonly alert: OutputBuilderMethods['alert'] = outputBuilderMethods.alert; + readonly autocomplete: PromptBuilderMethods['autocomplete'] = promptBuilderMethods.autocomplete; + readonly confirm: PromptBuilderMethods['confirm'] = promptBuilderMethods.confirm; + readonly clear: OutputBuilderMethods['clear'] = outputBuilderMethods.clear; + readonly dataTable: OutputBuilderMethods['dataTable'] = outputBuilderMethods.dataTable; + readonly datatable: OutputBuilderMethods['datatable'] = outputBuilderMethods.datatable; + readonly error: OutputBuilderMethods['error'] = outputBuilderMethods.error; + readonly grid: OutputBuilderMethods['grid'] = outputBuilderMethods.grid; + readonly info: OutputBuilderMethods['info'] = outputBuilderMethods.info; + readonly intro: OutputBuilderMethods['intro'] = outputBuilderMethods.intro; + readonly multisearch: PromptBuilderMethods['multisearch'] = promptBuilderMethods.multisearch; + readonly multiselect: PromptBuilderMethods['multiselect'] = promptBuilderMethods.multiselect; + readonly note: OutputBuilderMethods['note'] = outputBuilderMethods.note; + readonly notify: OutputBuilderMethods['notify'] = outputBuilderMethods.notify; + readonly number: PromptBuilderMethods['number'] = promptBuilderMethods.number; + readonly outro: OutputBuilderMethods['outro'] = outputBuilderMethods.outro; + readonly password: PromptBuilderMethods['password'] = promptBuilderMethods.password; + readonly pause: PromptBuilderMethods['pause'] = promptBuilderMethods.pause; + readonly progress: StatusBuilderMethods['progress'] = statusBuilderMethods.progress; + readonly search: PromptBuilderMethods['search'] = promptBuilderMethods.search; + readonly select: PromptBuilderMethods['select'] = promptBuilderMethods.select; + readonly spin: StatusBuilderMethods['spin'] = statusBuilderMethods.spin; + readonly stream: StatusBuilderMethods['stream'] = statusBuilderMethods.stream; + readonly suggest: PromptBuilderMethods['suggest'] = promptBuilderMethods.suggest; + readonly table: OutputBuilderMethods['table'] = outputBuilderMethods.table; + readonly task: StatusBuilderMethods['task'] = statusBuilderMethods.task; + readonly text: PromptBuilderMethods['text'] = promptBuilderMethods.text; + readonly textarea: PromptBuilderMethods['textarea'] = promptBuilderMethods.textarea; + readonly title: OutputBuilderMethods['title'] = outputBuilderMethods.title; + readonly warning: OutputBuilderMethods['warning'] = outputBuilderMethods.warning; add(step: (responses: FormResponses, previous: unknown, name?: string) => MaybePromise, name?: string, ignoreWhenReverting = false): this { this.#steps.push({ diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index 7423c80c..55f37135 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -1,8 +1,11 @@ export { configurePrompts, createMemoryOutput, createScriptedInput, promptEnvironment, withPromptEnvironment } from '#tui/environment'; export { FormBuilder, form } from '#tui/form'; +export type { FormResponses, FormStep } from '#tui/form'; export { Key, keyFromEvent, oneOf } from '#tui/key'; +export type { KeyboardEventLike, KeyName, KeyValue } from '#tui/key'; export { renderOpenTuiTextFrame } 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, @@ -25,10 +28,11 @@ export { title, warning, } from '#tui/output'; -export type { NotificationCommand, NotificationRuntime } from '#tui/output'; +export type { NoteType, NotificationCommand, NotificationPlatform, NotificationRuntime } from '#tui/output'; export { cancelUsing, PromptValidationError } from '#tui/prompt'; export type { PromptCancelHandler } from '#tui/prompt'; export { Logger, Progress, Stream, progress, spin, stream, task } from '#tui/status'; +export type { TaskDefinition } from '#tui/status'; export { backgroundColor, clearTerminal, @@ -42,13 +46,16 @@ export { 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, diff --git a/packages/tui/src/output.ts b/packages/tui/src/output.ts index 348e1e0a..fc519789 100644 --- a/packages/tui/src/output.ts +++ b/packages/tui/src/output.ts @@ -1,7 +1,7 @@ export { alert, error, info, intro, note, outro, warning } from '#tui/output/notes'; export type { NoteType } from '#tui/output/notes'; export { commandExists, executeNotificationCommand, notificationCommand, notificationCommands, notify, notifyForPlatform } from '#tui/output/notify'; -export type { NotificationCommand, NotificationRuntime } from '#tui/output/notify'; +export type { NotificationCommand, 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/notify.ts b/packages/tui/src/output/notify.ts index 0e01440b..968c4542 100644 --- a/packages/tui/src/output/notify.ts +++ b/packages/tui/src/output/notify.ts @@ -2,18 +2,19 @@ import { platform } from 'node:process'; import { note } from '#tui/output/notes'; import { notificationCommands } from '#tui/output/notify/commands'; import { availableNotificationCommand, commandExists, executeNotificationCommand } from '#tui/output/notify/executor'; -import type { NotificationCommand } from '#tui/output/notify/commands'; +import type { NotificationCommand, NotificationPlatform } from '#tui/output/notify/commands'; import type { NotificationRuntime } from '#tui/output/notify/executor'; export type { NotificationRuntime }; export type { NotificationCommand }; +export type { NotificationPlatform }; export { commandExists, executeNotificationCommand, notificationCommands }; -export const notificationCommand = (targetPlatform: NodeJS.Platform, title: string, body = '', subtitle = '', sound = '', icon = ''): NotificationCommand | null => { +export const notificationCommand = (targetPlatform: NotificationPlatform, title: string, body = '', subtitle = '', sound = '', icon = ''): NotificationCommand | null => { return notificationCommands(targetPlatform, { body, icon, sound, subtitle, title }).at(0) ?? null; }; -export const notifyForPlatform = (targetPlatform: NodeJS.Platform, title: string, body = '', subtitle = '', sound = '', icon = '', runtime: NotificationRuntime = {}): boolean => { +export const notifyForPlatform = (targetPlatform: NotificationPlatform, title: string, body = '', subtitle = '', sound = '', icon = '', runtime: NotificationRuntime = {}): boolean => { const commands = notificationCommands(targetPlatform, { body, icon, sound, subtitle, title }); const command = availableNotificationCommand(targetPlatform, commands, runtime.commandExists ?? commandExists); @@ -27,5 +28,5 @@ export const notifyForPlatform = (targetPlatform: NodeJS.Platform, title: string }; export const notify = (title: string, body = '', subtitle = '', sound = '', icon = ''): void => { - notifyForPlatform(platform, title, body, subtitle, sound, icon); + notifyForPlatform(platform as NotificationPlatform, title, body, subtitle, sound, icon); }; diff --git a/packages/tui/src/output/notify/commands.ts b/packages/tui/src/output/notify/commands.ts index 6ca4aa90..8750dc75 100644 --- a/packages/tui/src/output/notify/commands.ts +++ b/packages/tui/src/output/notify/commands.ts @@ -6,6 +6,8 @@ export type NotificationCommand = { bin: string; }; +export type NotificationPlatform = 'aix' | 'android' | 'cygwin' | 'darwin' | 'freebsd' | 'haiku' | 'linux' | 'netbsd' | 'openbsd' | 'sunos' | 'win32'; + const macOsNotificationCommand = (options: NotificationOptions): NotificationCommand => { const script = [ 'display notification', @@ -38,7 +40,7 @@ const kdialogCommand = (options: NotificationOptions): NotificationCommand => { return { args, bin: 'kdialog' }; }; -export const notificationCommands = (targetPlatform: NodeJS.Platform, options: unknown): NotificationCommand[] => { +export const notificationCommands = (targetPlatform: NotificationPlatform, options: unknown): NotificationCommand[] => { const parsed = parseNotificationOptions(options); if (targetPlatform === 'darwin') { diff --git a/packages/tui/src/output/notify/executor.ts b/packages/tui/src/output/notify/executor.ts index bf282cc9..750b9c77 100644 --- a/packages/tui/src/output/notify/executor.ts +++ b/packages/tui/src/output/notify/executor.ts @@ -1,5 +1,5 @@ import { spawnSync } from 'node:child_process'; -import type { NotificationCommand } from '#tui/output/notify/commands'; +import type { NotificationCommand, NotificationPlatform } from '#tui/output/notify/commands'; export type NotificationRuntime = { commandExists?: (bin: string) => boolean; @@ -18,7 +18,7 @@ export const executeNotificationCommand = (command: NotificationCommand): boolea return result.status === 0; }; -export const availableNotificationCommand = (targetPlatform: NodeJS.Platform, commands: NotificationCommand[], exists: (bin: string) => boolean = commandExists): NotificationCommand | null => { +export const availableNotificationCommand = (targetPlatform: NotificationPlatform, commands: NotificationCommand[], exists: (bin: string) => boolean = commandExists): NotificationCommand | null => { if (targetPlatform !== 'linux') { return commands.at(0) ?? null; } From 5889de13ad591086ebd1dcd64bb70321f0287f6b Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Fri, 12 Jun 2026 14:00:11 +0800 Subject: [PATCH 247/675] Support async form conditions --- UPSTREAM.md | 1 + packages/acceptance/tests/package.test.ts | 3 ++ packages/tui/src/form.ts | 2 +- packages/tui/src/form/builder/conditions.ts | 6 +-- packages/tui/src/form/builder/index.ts | 9 +--- packages/tui/src/form/builder/submit.ts | 4 +- packages/tui/src/form/types.ts | 4 +- packages/tui/src/index.ts | 2 +- packages/tui/tests/form.test.ts | 51 +++++++++++++++++++++ 9 files changed, 67 insertions(+), 15 deletions(-) diff --git a/UPSTREAM.md b/UPSTREAM.md index 02c7ffdc..8451504b 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -70,6 +70,7 @@ TypeScript and OpenTUI internals. | Form builder | `packages/tui/src/form.ts`, `packages/tui/src/form/builder/*` | | 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` | | Notes and alert-style output | `packages/tui/src/output/notes.ts` | | Note multiline and typed theme rendering | `packages/tui/src/output/notes.ts`, `packages/tui/src/theme/styles.ts`, `packages/tui/tests/output.test.ts` | | Native notifications | `packages/tui/src/output/notify.ts` | diff --git a/packages/acceptance/tests/package.test.ts b/packages/acceptance/tests/package.test.ts index 81108e7b..acb469f3 100644 --- a/packages/acceptance/tests/package.test.ts +++ b/packages/acceptance/tests/package.test.ts @@ -144,6 +144,7 @@ describe('package consumption', () => { ConfirmPromptOptions, FormResponses, FormStep, + FormStepCondition, KeyboardEventLike, KeyName, KeyValue, @@ -165,6 +166,7 @@ describe('package consumption', () => { const suggestOptions: SuggestOptions = { message: 'Suggest', options: ['one'] }; const formResponses: FormResponses = [] as unknown as FormResponses; const formStep: FormStep = { condition: true, ignoreWhenReverting: false, run: () => null }; + const formStepCondition: FormStepCondition = async () => true; const event: KeyboardEventLike = { name: 'return' }; const keyName: KeyName = '\\n'; const keyValue: KeyValue = keyName; @@ -183,6 +185,7 @@ describe('package consumption', () => { suggestOptions, formResponses, formStep, + formStepCondition, event, keyValue, noteType, diff --git a/packages/tui/src/form.ts b/packages/tui/src/form.ts index d9b45536..98646ace 100644 --- a/packages/tui/src/form.ts +++ b/packages/tui/src/form.ts @@ -1,5 +1,5 @@ export { FormBuilder } from '#tui/form/builder/index'; -export type { FormResponses, FormStep } from '#tui/form/types'; +export type { FormResponses, FormStep, FormStepCondition } from '#tui/form/types'; import { FormBuilder } from '#tui/form/builder/index'; import type { MaybePromise } from '#tui/types'; diff --git a/packages/tui/src/form/builder/conditions.ts b/packages/tui/src/form/builder/conditions.ts index 297014f1..b31f679d 100644 --- a/packages/tui/src/form/builder/conditions.ts +++ b/packages/tui/src/form/builder/conditions.ts @@ -1,11 +1,11 @@ import type { FormResponses, FormStep } from '#tui/form/types'; -export const shouldRunStep = (step: FormStep, responses: FormResponses): boolean => { +export const shouldRunStep = async (step: FormStep, responses: FormResponses): Promise => { return typeof step.condition === 'function' ? step.condition(responses) : step.condition; }; -export const shouldIgnoreStepWhenReverting = (step: FormStep, responses: FormResponses): boolean => { - if (!shouldRunStep(step, responses)) { +export const shouldIgnoreStepWhenReverting = async (step: FormStep, responses: FormResponses): Promise => { + if (!(await shouldRunStep(step, responses))) { return true; } diff --git a/packages/tui/src/form/builder/index.ts b/packages/tui/src/form/builder/index.ts index 62fe7ffe..393bf3d4 100644 --- a/packages/tui/src/form/builder/index.ts +++ b/packages/tui/src/form/builder/index.ts @@ -5,7 +5,7 @@ import { submitFormSteps } from '#tui/form/builder/submit'; import type { OutputBuilderMethods } from '#tui/form/builder/output'; import type { PromptBuilderMethods } from '#tui/form/builder/prompts'; import type { StatusBuilderMethods } from '#tui/form/builder/status'; -import type { FormResponses, FormStep } from '#tui/form/types'; +import type { FormResponses, FormStep, FormStepCondition } from '#tui/form/types'; import type { MaybePromise } from '#tui/types'; export class FormBuilder { @@ -53,12 +53,7 @@ export class FormBuilder { return this; } - addIf( - condition: boolean | ((responses: FormResponses) => boolean), - step: (responses: FormResponses, previous: unknown, name?: string) => MaybePromise, - name?: string, - ignoreWhenReverting = false, - ): this { + addIf(condition: FormStepCondition, step: (responses: FormResponses, previous: unknown, name?: string) => MaybePromise, name?: string, ignoreWhenReverting = false): this { this.#steps.push({ condition, ignoreWhenReverting, diff --git a/packages/tui/src/form/builder/submit.ts b/packages/tui/src/form/builder/submit.ts index cc409381..c747e989 100644 --- a/packages/tui/src/form/builder/submit.ts +++ b/packages/tui/src/form/builder/submit.ts @@ -13,7 +13,7 @@ export const submitFormSteps = async (steps: FormStep[], responses: FormResponse break; } - if (wasReverted && index > 0 && shouldIgnoreStepWhenReverting(step, responses)) { + if (wasReverted && index > 0 && (await shouldIgnoreStepWhenReverting(step, responses))) { index -= 1; continue; } @@ -22,7 +22,7 @@ export const submitFormSteps = async (steps: FormStep[], responses: FormResponse const key = step.name ?? index; - if (!shouldRunStep(step, responses)) { + if (!(await shouldRunStep(step, responses))) { responses[key] = null; index += 1; continue; diff --git a/packages/tui/src/form/types.ts b/packages/tui/src/form/types.ts index c7c2720a..b24a460e 100644 --- a/packages/tui/src/form/types.ts +++ b/packages/tui/src/form/types.ts @@ -2,8 +2,10 @@ import type { MaybePromise } from '#tui/types'; export type FormResponses = unknown[] & Record; +export type FormStepCondition = boolean | ((responses: FormResponses) => MaybePromise); + export type FormStep = { - condition: boolean | ((responses: FormResponses) => boolean); + 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 index 55f37135..e4dba59a 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -1,6 +1,6 @@ export { configurePrompts, createMemoryOutput, createScriptedInput, promptEnvironment, withPromptEnvironment } from '#tui/environment'; export { FormBuilder, form } from '#tui/form'; -export type { FormResponses, FormStep } 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'; diff --git a/packages/tui/tests/form.test.ts b/packages/tui/tests/form.test.ts index a0c69779..60554a80 100644 --- a/packages/tui/tests/form.test.ts +++ b/packages/tui/tests/form.test.ts @@ -64,6 +64,29 @@ describe('form builder', () => { expect(responses.details).toBeNull(); }); + it('awaits asynchronous conditional steps', async () => { + const responses = await withPromptEnvironment( + { + input: createScriptedInput(['y', Key.enter]), + output: createMemoryOutput(), + error: createMemoryOutput(), + interactive: true, + }, + () => + form() + .confirm('Include details?', true, 'Yes', 'No', false, undefined, '', 'include') + .addIf( + async (values) => values.include === true, + () => 'details', + 'details', + ) + .submit(), + ); + + expect(responses.include).toBe(true); + expect(responses.details).toBe('details'); + }); + it('reverts to the previous form step and reuses the prior response', async () => { const output = createMemoryOutput(); @@ -304,6 +327,34 @@ describe('form builder', () => { expect(responses[3]).toBe(true); }); + it('re-evaluates asynchronous conditional steps after reverting prior responses', async () => { + const output = createMemoryOutput(); + + const responses = await withPromptEnvironment( + { + input: createScriptedInput(['A', 'd', 'a', Key.enter, Key.down, Key.enter, Key.ctrlU, Key.up, Key.enter, '1', Key.enter, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + form() + .text('Name') + .select('Runtime', ['TS', 'JS']) + .addIf( + async (values) => values[1] === 'TS', + () => text('Version'), + ) + .confirm('Are you sure?') + .submit(), + ); + + expect(responses[0]).toBe('Ada'); + expect(responses[1]).toBe('TS'); + expect(responses[2]).toBe('1'); + expect(responses[3]).toBe(true); + }); + it('uses declared defaults when named choice steps follow skipped conditional responses', async () => { const output = createMemoryOutput(); From 2c02cecaad03d4afe41ce1bd04838c9f25670376 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Fri, 12 Jun 2026 14:02:53 +0800 Subject: [PATCH 248/675] Defer confirm submit frames until valid --- UPSTREAM.md | 1 + packages/tui/src/prompts/select/confirm.ts | 21 ++++++++++++---- .../tui/src/prompts/select/read-confirm.ts | 24 ++++++++++--------- packages/tui/tests/choices.test.ts | 2 ++ 4 files changed, 33 insertions(+), 15 deletions(-) diff --git a/UPSTREAM.md b/UPSTREAM.md index 8451504b..8d642d42 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -39,6 +39,7 @@ TypeScript and OpenTUI internals. | Number active, submitted, cancelled, arrow, placeholder, default, and validation-safe rendering | `packages/tui/src/prompts/number.ts`, `packages/tui/src/prompts/number/input.ts`, `packages/tui/src/prompts/number/render.ts`, `packages/tui/tests/number.test.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/render-confirm.ts`, `packages/tui/tests/choices.test.ts` | +| Confirm submitted frame renders only after validation passes | `packages/tui/src/prompts/select/confirm.ts`, `packages/tui/src/prompts/select/read-confirm.ts`, `packages/tui/tests/choices.test.ts` | | Select active boxed layout and row styling | `packages/tui/src/prompts/select/render.ts`, `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/tests/choices.test.ts` | diff --git a/packages/tui/src/prompts/select/confirm.ts b/packages/tui/src/prompts/select/confirm.ts index 2f984574..c1fa1e26 100644 --- a/packages/tui/src/prompts/select/confirm.ts +++ b/packages/tui/src/prompts/select/confirm.ts @@ -1,5 +1,6 @@ import { promptUntilValid } from '#tui/prompt'; import { readConfirm } from '#tui/prompts/select/read-confirm'; +import { renderSubmittedConfirm } from '#tui/prompts/select/render-confirm'; import type { ConfirmPromptOptions } from '#tui/types'; export function confirm(options: ConfirmPromptOptions): Promise; @@ -28,9 +29,21 @@ export async function confirm( const options: ConfirmPromptOptions = typeof message === 'string' ? { message, label: message, default: defaultValue, yes, no, required, validate, hint, transform } : { ...message, default: message.default ?? true }; - return promptUntilValid(options, async () => { - const value = await readConfirm(options); + let shouldRenderSubmittedFrame = false; - return options.transform ? options.transform(value) : value; - }); + return promptUntilValid( + options, + async () => { + const answer = await readConfirm(options); + + shouldRenderSubmittedFrame = answer.submitted && !answer.cancelled; + + return options.transform ? options.transform(answer.value) : answer.value; + }, + (value) => { + if (shouldRenderSubmittedFrame) { + renderSubmittedConfirm(options, value); + } + }, + ); } diff --git a/packages/tui/src/prompts/select/read-confirm.ts b/packages/tui/src/prompts/select/read-confirm.ts index 6f2983d8..af304306 100644 --- a/packages/tui/src/prompts/select/read-confirm.ts +++ b/packages/tui/src/prompts/select/read-confirm.ts @@ -2,12 +2,18 @@ import { promptEnvironment } from '#tui/environment'; import { Key } from '#tui/key'; import { ask, cancelPrompt } from '#tui/prompt'; import { rejectPromptRevert } from '#tui/prompt/revert'; -import { renderActiveConfirm, renderCancelledConfirm, renderSubmittedConfirm } from '#tui/prompts/select/render-confirm'; +import { renderActiveConfirm, renderCancelledConfirm } from '#tui/prompts/select/render-confirm'; import type { ConfirmPromptOptions } from '#tui/types'; const toggleKeys = 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 readConfirm = async (options: ConfirmPromptOptions): Promise => { +export type ConfirmReadResult = { + cancelled: boolean; + submitted: boolean; + value: boolean; +}; + +export const readConfirm = async (options: ConfirmPromptOptions): Promise => { const environment = promptEnvironment(); if (!environment.input.readKey) { @@ -16,10 +22,10 @@ export const readConfirm = async (options: ConfirmPromptOptions): Promise { expect(result).toBe(true); expect(output.text()).toContain('Required.'); + expect(parseAnsiText(output.text())).not.toContain('│ No '); + expect(parseAnsiText(output.text())).toContain('│ Yes '); }); it('transforms confirm answers before returning', async () => { From d13bcd84dccb4b23562ec237fcaf5f7e5327dc27 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Fri, 12 Jun 2026 14:06:32 +0800 Subject: [PATCH 249/675] Defer select submit frames until valid --- UPSTREAM.md | 1 + packages/tui/src/prompts/select/index.ts | 33 ++++++++++++++----- .../tui/src/prompts/select/read-selected.ts | 30 +++++++++++------ packages/tui/tests/choices.test.ts | 26 +++++++++++++++ 4 files changed, 71 insertions(+), 19 deletions(-) diff --git a/UPSTREAM.md b/UPSTREAM.md index 8d642d42..df938554 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -45,6 +45,7 @@ TypeScript and OpenTUI internals. | Select required-option validation | `packages/tui/src/prompts/select/validators/options.ts`, `packages/tui/src/prompts/select/index.ts`, `packages/tui/tests/choices.test.ts` | | Select non-interactive default validation | `packages/tui/src/prompts/select/index.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/tests/choices.test.ts` | +| Select submitted frame renders only after validation passes | `packages/tui/src/prompts/select/index.ts`, `packages/tui/src/prompts/select/read-selected.ts`, `packages/tui/tests/choices.test.ts` | | Select cancel final frame | `packages/tui/src/prompts/select/render.ts`, `packages/tui/src/prompts/select/read-selected.ts`, `packages/tui/tests/choices.test.ts` | | Multi-select active boxed layout and row styling | `packages/tui/src/prompts/select/render.ts`, `packages/tui/tests/choices.test.ts` | | Multi-select submitted final frame | `packages/tui/src/prompts/select/render.ts`, `packages/tui/src/prompts/select/read-multiple.ts`, `packages/tui/tests/choices.test.ts` | diff --git a/packages/tui/src/prompts/select/index.ts b/packages/tui/src/prompts/select/index.ts index 9137ce45..7d737787 100644 --- a/packages/tui/src/prompts/select/index.ts +++ b/packages/tui/src/prompts/select/index.ts @@ -4,6 +4,7 @@ import { promptUntilValid, PromptValidationError } from '#tui/prompt'; import { normalizeChoices } from '#tui/concerns/choices'; import { readMultipleChoices } from '#tui/prompts/select/read-multiple'; 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 { ChoiceOptions, MultiSelectPromptOptions, SelectPromptOptions } from '#tui/types'; @@ -42,17 +43,31 @@ export async function select( const promptOptions = { ...options, required: options.required ?? true }; const choices = normalizeChoices(options.options); - return promptUntilValid(promptOptions, async () => { - const selected = await readSelectedChoice(promptOptions.message, choices, promptOptions.default, promptOptions.hint, promptOptions.scroll, promptOptions.info).catch((error: unknown) => { - if (promptOptions.default !== undefined && error instanceof PromptValidationError) { - return promptOptions.default; - } + let shouldRenderSubmittedFrame = false; + let submittedLabel = ''; - throw error; - }); + return promptUntilValid( + promptOptions, + async () => { + const selected = await readSelectedChoice(promptOptions.message, choices, promptOptions.default, promptOptions.hint, promptOptions.scroll, promptOptions.info).catch((error: unknown) => { + if (promptOptions.default !== undefined && error instanceof PromptValidationError) { + return { cancelled: false, submitted: false, submittedLabel: '', value: promptOptions.default }; + } - return promptOptions.transform ? promptOptions.transform(selected) : selected; - }); + throw error; + }); + + shouldRenderSubmittedFrame = selected.submitted && !selected.cancelled; + submittedLabel = selected.submittedLabel; + + return promptOptions.transform ? promptOptions.transform(selected.value) : selected.value; + }, + () => { + if (shouldRenderSubmittedFrame) { + renderSubmittedChoice(promptOptions.message, submittedLabel); + } + }, + ); } export function multiselect(options: MultiSelectPromptOptions): Promise; diff --git a/packages/tui/src/prompts/select/read-selected.ts b/packages/tui/src/prompts/select/read-selected.ts index d2d086b7..c4a5d11b 100644 --- a/packages/tui/src/prompts/select/read-selected.ts +++ b/packages/tui/src/prompts/select/read-selected.ts @@ -5,9 +5,16 @@ import { renderChoices } from '#tui/theme'; import { findChoice, firstEnabledIndex } from '#tui/concerns/choices'; import { moveSelectHighlight, selectNavigationAction } from '#tui/prompts/select/keys'; import { parseChoiceIndex } from '#tui/prompts/select/navigation'; -import { renderCancelledChoice, renderSelectedChoice, renderSubmittedChoice } from '#tui/prompts/select/render'; +import { renderCancelledChoice, renderSelectedChoice } from '#tui/prompts/select/render'; import type { Choice, SelectPromptOptions } from '#tui/types'; +export type SelectedChoiceReadResult = { + cancelled: boolean; + submitted: boolean; + submittedLabel: string; + value: T; +}; + const defaultChoiceIndex = (choices: Array>, defaultValue: T | undefined): number => { if (defaultValue === undefined) { return firstEnabledIndex(choices); @@ -18,7 +25,14 @@ const defaultChoiceIndex = (choices: Array>, defaultValue: T | unde return index === -1 ? firstEnabledIndex(choices) : index; }; -export const readSelectedChoice = async (message: string, choices: Array>, defaultValue?: T, hint?: string, scroll?: number, info?: SelectPromptOptions['info']): Promise => { +export const readSelectedChoice = async ( + message: string, + choices: Array>, + defaultValue?: T, + hint?: string, + scroll?: number, + info?: SelectPromptOptions['info'], +): Promise> => { const environment = promptEnvironment(); if (!environment.input.readKey) { @@ -32,7 +46,7 @@ export const readSelectedChoice = async (message: string, choices: Array(message: string, choices: Array(message: string, choices: Array(message: string, choices: Array { expect(result).toBe('FIRST'); }); + it('renders select submitted frames only after validation passes', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.enter, Key.down, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + select({ + message: 'Pick one', + options: ['first', 'second'], + validate: (value) => (value === 'second' ? null : 'Choose second.'), + }), + ); + + const rendered = parseAnsiText(output.text()); + + expect(result).toBe('second'); + expect(rendered).toContain('Choose second.'); + expect(rendered).not.toMatch(/^ │ first\s*│$/m); + expect(rendered).toMatch(/^ │ second\s*│$/m); + }); + it('trims line-mode select answers before matching choices', async () => { const output = createMemoryOutput(); From 4b41c646bae26f5bcb1dec01262fd5338f6b0a4d Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Fri, 12 Jun 2026 14:08:30 +0800 Subject: [PATCH 250/675] Defer multiselect submit frames until valid --- UPSTREAM.md | 1 + packages/tui/src/prompts/select/index.ts | 24 +++++++++++++---- .../tui/src/prompts/select/read-multiple.ts | 27 +++++++++++-------- packages/tui/tests/choices.test.ts | 21 +++++++++++++++ 4 files changed, 57 insertions(+), 16 deletions(-) diff --git a/UPSTREAM.md b/UPSTREAM.md index df938554..b0d0bfe5 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -49,6 +49,7 @@ TypeScript and OpenTUI internals. | Select cancel final frame | `packages/tui/src/prompts/select/render.ts`, `packages/tui/src/prompts/select/read-selected.ts`, `packages/tui/tests/choices.test.ts` | | Multi-select active boxed layout and row styling | `packages/tui/src/prompts/select/render.ts`, `packages/tui/tests/choices.test.ts` | | Multi-select submitted final frame | `packages/tui/src/prompts/select/render.ts`, `packages/tui/src/prompts/select/read-multiple.ts`, `packages/tui/tests/choices.test.ts` | +| Multi-select submitted frame renders only after validation passes | `packages/tui/src/prompts/select/index.ts`, `packages/tui/src/prompts/select/read-multiple.ts`, `packages/tui/tests/choices.test.ts` | | Multi-select cancel final frame | `packages/tui/src/prompts/select/render.ts`, `packages/tui/src/prompts/select/read-multiple.ts`, `packages/tui/tests/choices.test.ts` | | Suggest, autocomplete | `packages/tui/src/prompts/suggest/*` | | Suggest active, submitted, cancelled, placeholder, info, scrolling, and validation-safe rendering | `packages/tui/src/prompts/suggest/index.ts`, `packages/tui/src/prompts/suggest/read.ts`, `packages/tui/src/prompts/suggest/render.ts`, `packages/tui/tests/suggest.test.ts` | diff --git a/packages/tui/src/prompts/select/index.ts b/packages/tui/src/prompts/select/index.ts index 7d737787..15aa280b 100644 --- a/packages/tui/src/prompts/select/index.ts +++ b/packages/tui/src/prompts/select/index.ts @@ -4,7 +4,7 @@ import { promptUntilValid, PromptValidationError } from '#tui/prompt'; import { normalizeChoices } from '#tui/concerns/choices'; import { readMultipleChoices } from '#tui/prompts/select/read-multiple'; import { readSelectedChoice } from '#tui/prompts/select/read-selected'; -import { renderSubmittedChoice } from '#tui/prompts/select/render'; +import { renderSubmittedChoice, renderSubmittedChoices } from '#tui/prompts/select/render'; import { assertSelectOptions } from '#tui/prompts/select/validators/options'; import type { ChoiceOptions, MultiSelectPromptOptions, SelectPromptOptions } from '#tui/types'; @@ -103,9 +103,23 @@ export async function multiselect( const promptOptions = { ...options, default: options.default ?? [] }; const choices = normalizeChoices(options.options); - return promptUntilValid(promptOptions, async () => { - const selected = await readMultipleChoices(promptOptions.message, choices, promptOptions.default, promptOptions.hint, promptOptions.scroll, promptOptions.info); + let shouldRenderSubmittedFrame = false; + let submittedLabels: string[] = []; + + return promptUntilValid( + promptOptions, + async () => { + const selected = await readMultipleChoices(promptOptions.message, choices, promptOptions.default, promptOptions.hint, promptOptions.scroll, promptOptions.info); + + shouldRenderSubmittedFrame = selected.submitted && !selected.cancelled; + submittedLabels = selected.submittedLabels; - return promptOptions.transform ? promptOptions.transform(selected) : selected; - }); + return promptOptions.transform ? promptOptions.transform(selected.value) : selected.value; + }, + () => { + if (shouldRenderSubmittedFrame) { + renderSubmittedChoices(promptOptions.message, submittedLabels); + } + }, + ); } diff --git a/packages/tui/src/prompts/select/read-multiple.ts b/packages/tui/src/prompts/select/read-multiple.ts index 6f400c06..8e16e104 100644 --- a/packages/tui/src/prompts/select/read-multiple.ts +++ b/packages/tui/src/prompts/select/read-multiple.ts @@ -6,9 +6,16 @@ import { firstEnabledIndex } from '#tui/concerns/choices'; import { moveSelectHighlight, selectNavigationAction } from '#tui/prompts/select/keys'; import { choicesFromCommaSeparated, markedChoiceIndexes, markedChoiceValues, toggleAllEnabledChoices, toggleMarkedChoice } from '#tui/prompts/select/multiple'; import { parseChoiceIndex } from '#tui/prompts/select/navigation'; -import { renderCancelledChoices, renderMultipleChoices, renderSubmittedChoices } from '#tui/prompts/select/render'; +import { renderCancelledChoices, renderMultipleChoices } from '#tui/prompts/select/render'; import type { Choice, MultiSelectPromptOptions } from '#tui/types'; +export type MultipleChoicesReadResult = { + cancelled: boolean; + submitted: boolean; + submittedLabels: string[]; + value: T[]; +}; + const markedChoiceLabels = (choices: Array>, marked: Set): string[] => { return [...marked] .sort((left, right) => left - right) @@ -23,7 +30,7 @@ export const readMultipleChoices = async ( hint?: string, scroll?: number, info?: MultiSelectPromptOptions['info'], -): Promise => { +): Promise> => { const environment = promptEnvironment(); if (!environment.input.readKey) { @@ -31,7 +38,9 @@ export const readMultipleChoices = async ( const answer = await ask(`${message}\n${rendered}\n`, hint); - return answer.trim() === '' ? defaults : choicesFromCommaSeparated(choices, answer); + const value = answer.trim() === '' ? defaults : choicesFromCommaSeparated(choices, answer); + + return { cancelled: false, submitted: false, submittedLabels: [], value }; } let selected = firstEnabledIndex(choices); @@ -44,19 +53,17 @@ export const readMultipleChoices = async ( const key = await environment.input.readKey(); if (key === null) { - renderSubmittedChoices(message, markedChoiceLabels(choices, marked)); - - return markedChoiceValues(choices, marked); + return { cancelled: false, submitted: true, submittedLabels: markedChoiceLabels(choices, marked), value: markedChoiceValues(choices, marked) }; } if (key === Key.ctrlC) { renderCancelledChoices(message, choices, selected, marked, scroll); - return cancelPrompt(markedChoiceValues(choices, marked)); + return { cancelled: true, submitted: false, submittedLabels: markedChoiceLabels(choices, marked), value: await cancelPrompt(markedChoiceValues(choices, marked)) }; } if (key.includes(',')) { - return choicesFromCommaSeparated(choices, key); + return { cancelled: false, submitted: false, submittedLabels: [], value: choicesFromCommaSeparated(choices, key) }; } const numeric = parseChoiceIndex(key); @@ -97,9 +104,7 @@ export const readMultipleChoices = async ( } if (key === Key.enter) { - renderSubmittedChoices(message, markedChoiceLabels(choices, marked)); - - return markedChoiceValues(choices, marked); + return { cancelled: false, submitted: true, submittedLabels: markedChoiceLabels(choices, marked), value: markedChoiceValues(choices, marked) }; } } }; diff --git a/packages/tui/tests/choices.test.ts b/packages/tui/tests/choices.test.ts index 7d81351e..05b3777b 100644 --- a/packages/tui/tests/choices.test.ts +++ b/packages/tui/tests/choices.test.ts @@ -794,6 +794,27 @@ describe('choice prompts', () => { expect(output.text()).toContain('Required.'); }); + it('renders multiselect submitted frames only after validation passes', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.enter, Key.space, Key.enter]), + output, + error: output, + interactive: true, + }, + () => multiselect({ message: 'Pick many', options: ['first', 'second'], required: true }), + ); + + const rendered = parseAnsiText(output.text()); + + expect(result).toEqual(['first']); + expect(rendered).toContain('Required.'); + expect(rendered).not.toMatch(/^ │ None\s*│$/m); + expect(rendered).toMatch(/^ │ first\s*│$/m); + }); + it('toggles all multiselect choices with ctrl-a', async () => { const output = createMemoryOutput(); From 20bcb98d90298a330fc4ac200c6082b6738dc0b1 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Fri, 12 Jun 2026 14:11:10 +0800 Subject: [PATCH 251/675] Defer search submit frames until valid --- UPSTREAM.md | 2 + packages/tui/src/prompts/search/index.ts | 51 +++++++++++++++---- packages/tui/src/prompts/search/read-multi.ts | 15 +++--- .../src/prompts/search/read-multi/result.ts | 7 +++ .../tui/src/prompts/search/read-single.ts | 19 +++---- .../src/prompts/search/read-single/result.ts | 7 +++ packages/tui/tests/search.test.ts | 23 +++++++++ 7 files changed, 94 insertions(+), 30 deletions(-) diff --git a/UPSTREAM.md b/UPSTREAM.md index b0d0bfe5..d7109864 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -61,9 +61,11 @@ TypeScript and OpenTUI internals. | Search and multisearch empty-result rendering | `packages/tui/src/prompts/search/render.ts`, `packages/tui/tests/search.test.ts` | | Search highlighted-result row styling | `packages/tui/src/prompts/search/render.ts`, `packages/tui/src/theme/styles.ts`, `packages/tui/tests/search.test.ts` | | Search submitted final frame | `packages/tui/src/prompts/search/render.ts`, `packages/tui/src/prompts/search/read-single.ts`, `packages/tui/tests/search.test.ts` | +| Search submitted frame renders only after validation passes | `packages/tui/src/prompts/search/index.ts`, `packages/tui/src/prompts/search/read-single.ts`, `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/tests/search.test.ts` | | Multi-search active boxed layout and selected-count footer | `packages/tui/src/prompts/search/render.ts`, `packages/tui/tests/search.test.ts` | | Multi-search submitted final frame | `packages/tui/src/prompts/search/render.ts`, `packages/tui/src/prompts/search/read-multi.ts`, `packages/tui/tests/search.test.ts` | +| Multi-search submitted frame renders only after validation passes | `packages/tui/src/prompts/search/index.ts`, `packages/tui/src/prompts/search/read-multi.ts`, `packages/tui/src/prompts/search/read-multi/result.ts`, `packages/tui/tests/search.test.ts` | | Multi-search cancel final frame | `packages/tui/src/prompts/search/render.ts`, `packages/tui/src/prompts/search/read-multi.ts`, `packages/tui/tests/search.test.ts` | | Pause | `packages/tui/src/prompts/pause.ts` | | Pause themed key-driven frame | `packages/tui/src/prompts/pause.ts`, `packages/tui/src/prompts/pause/render.ts`, `packages/tui/tests/pause.test.ts` | diff --git a/packages/tui/src/prompts/search/index.ts b/packages/tui/src/prompts/search/index.ts index 1c63e0f9..815f8c26 100644 --- a/packages/tui/src/prompts/search/index.ts +++ b/packages/tui/src/prompts/search/index.ts @@ -1,6 +1,7 @@ import { promptUntilValid, PromptValidationError } from '#tui/prompt'; import { readMultiSearchChoices } from '#tui/prompts/search/read-multi'; import { readSearchChoice } from '#tui/prompts/search/read-single'; +import { renderSubmittedSearchChoice, renderSubmittedSearchChoices } from '#tui/prompts/search/render'; import { assertSearchOptions } from '#tui/prompts/search/validators/options'; import type { ChoiceOptions, MultiSearchPromptOptions, SearchPromptOptions } from '#tui/types'; @@ -36,15 +37,29 @@ export async function search( assertSearchOptions(options); - return promptUntilValid(options, async (attempt) => { - const selected = await readSearchChoice(options, attempt); + let shouldRenderSubmittedFrame = false; + let submittedLabel = ''; - if (selected === undefined) { - throw new PromptValidationError('Please select a valid option.'); - } + return promptUntilValid( + options, + async (attempt) => { + const selected = await readSearchChoice(options, attempt); - return options.transform ? options.transform(selected) : selected; - }); + if (selected.value === undefined) { + throw new PromptValidationError('Please select a valid option.'); + } + + shouldRenderSubmittedFrame = selected.submitted && !selected.cancelled; + submittedLabel = selected.submittedLabel; + + return options.transform ? options.transform(selected.value) : selected.value; + }, + () => { + if (shouldRenderSubmittedFrame) { + renderSubmittedSearchChoice(options.message, submittedLabel); + } + }, + ); } export function multisearch(options: MultiSearchPromptOptions): Promise; @@ -79,9 +94,23 @@ export async function multisearch( const promptOptions = { ...options, default: options.default ?? [] }; - return promptUntilValid(promptOptions, async () => { - const selected = await readMultiSearchChoices(promptOptions); + let shouldRenderSubmittedFrame = false; + let submittedLabels: string[] = []; + + return promptUntilValid( + promptOptions, + async () => { + const selected = await readMultiSearchChoices(promptOptions); + + shouldRenderSubmittedFrame = selected.submitted && !selected.cancelled; + submittedLabels = selected.submittedLabels; - return promptOptions.transform ? promptOptions.transform(selected) : selected; - }); + return promptOptions.transform ? promptOptions.transform(selected.value) : selected.value; + }, + () => { + if (shouldRenderSubmittedFrame) { + renderSubmittedSearchChoices(promptOptions.message, submittedLabels); + } + }, + ); } diff --git a/packages/tui/src/prompts/search/read-multi.ts b/packages/tui/src/prompts/search/read-multi.ts index b2fab610..d8aad9a9 100644 --- a/packages/tui/src/prompts/search/read-multi.ts +++ b/packages/tui/src/prompts/search/read-multi.ts @@ -4,16 +4,17 @@ import { cancelPrompt } from '#tui/prompt'; import { applyTypedKey } from '#tui/typed-value'; import { resolveSearchChoices } from '#tui/prompts/search/choices'; import { moveSearchHighlight, searchNavigationAction } from '#tui/prompts/search/keys'; -import { renderCancelledSearch, renderSearchChoices, renderSubmittedSearchChoices } from '#tui/prompts/search/render'; +import { renderCancelledSearch, renderSearchChoices } from '#tui/prompts/search/render'; import { lineMultiSearchValues, selectedSearchValues, toggleHighlightedSearchChoice } from '#tui/prompts/search/read-multi/result'; +import type { MultiSearchChoicesReadResult } from '#tui/prompts/search/read-multi/result'; import { createInitialSearchSelection, displayedSearchChoices, markedSearchChoiceIndexes, toggleSearchChoices } from '#tui/prompts/search/selection'; import type { MultiSearchPromptOptions } from '#tui/types'; -export const readMultiSearchChoices = async (options: MultiSearchPromptOptions): Promise => { +export const readMultiSearchChoices = async (options: MultiSearchPromptOptions): Promise> => { const environment = promptEnvironment(); if (!environment.input.readKey) { - return lineMultiSearchValues(options); + return { cancelled: false, submitted: false, submittedLabels: [], value: await lineMultiSearchValues(options) }; } let state = { cursor: 0, value: '' }; @@ -38,15 +39,13 @@ export const readMultiSearchChoices = async (options: MultiSearchPromptOption const key = await environment.input.readKey(); if (key === null || key === Key.enter) { - renderSubmittedSearchChoices(options.message, [...selected.values()]); - - return selectedSearchValues(selected); + return { cancelled: false, submitted: true, submittedLabels: [...selected.values()], value: selectedSearchValues(selected) }; } if (key === Key.ctrlC) { renderCancelledSearch(options.message, state.value, options.placeholder); - return cancelPrompt(selectedSearchValues(selected)); + return { cancelled: true, submitted: false, submittedLabels: [...selected.values()], value: await cancelPrompt(selectedSearchValues(selected)) }; } const action = searchNavigationAction(key); @@ -84,7 +83,7 @@ export const readMultiSearchChoices = async (options: MultiSearchPromptOption if (next.cancelled) { renderCancelledSearch(options.message, state.value, options.placeholder); - return cancelPrompt(options.default ?? []); + return { cancelled: true, submitted: false, submittedLabels: [], value: await cancelPrompt(options.default ?? []) }; } state = { cursor: next.cursor, value: next.value }; diff --git a/packages/tui/src/prompts/search/read-multi/result.ts b/packages/tui/src/prompts/search/read-multi/result.ts index 603e76b4..4382e9c3 100644 --- a/packages/tui/src/prompts/search/read-multi/result.ts +++ b/packages/tui/src/prompts/search/read-multi/result.ts @@ -4,6 +4,13 @@ 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; + submitted: boolean; + submittedLabels: string[]; + value: T[]; +}; + export const selectedSearchValues = (selected: SearchSelection): T[] => [...selected.keys()]; export const lineMultiSearchValues = async (options: MultiSearchPromptOptions): Promise => { diff --git a/packages/tui/src/prompts/search/read-single.ts b/packages/tui/src/prompts/search/read-single.ts index 714616b0..0c6f665c 100644 --- a/packages/tui/src/prompts/search/read-single.ts +++ b/packages/tui/src/prompts/search/read-single.ts @@ -4,15 +4,16 @@ import { cancelPrompt } from '#tui/prompt'; import { applyTypedKey } from '#tui/typed-value'; import { resolveSearchChoices } from '#tui/prompts/search/choices'; import { clearsSearchHighlight, moveSearchHighlight, searchNavigationAction } from '#tui/prompts/search/keys'; -import { renderCancelledSearch, renderSearchChoices, renderSubmittedSearchChoice } from '#tui/prompts/search/render'; +import { renderCancelledSearch, renderSearchChoices } from '#tui/prompts/search/render'; import { cancelledSearchValue, lineSearchValue, selectedSearchValue } from '#tui/prompts/search/read-single/result'; +import type { SearchChoiceReadResult } from '#tui/prompts/search/read-single/result'; import type { SearchPromptOptions } from '#tui/types'; -export const readSearchChoice = async (options: SearchPromptOptions, attempt = 0): Promise => { +export const readSearchChoice = async (options: SearchPromptOptions, attempt = 0): Promise> => { const environment = promptEnvironment(); if (!environment.input.readKey) { - return lineSearchValue(options); + return { cancelled: false, submitted: false, submittedLabel: '', value: await lineSearchValue(options) }; } let state = { cursor: 0, value: '' }; @@ -27,13 +28,13 @@ export const readSearchChoice = async (options: SearchPromptOptions, attem const key = await environment.input.readKey(); if (key === null) { - return options.default; + return { cancelled: false, submitted: false, submittedLabel: '', value: options.default }; } if (key === Key.ctrlC) { renderCancelledSearch(options.message, state.value, options.placeholder); - return cancelPrompt(cancelledSearchValue(choices, highlighted, options.default)); + return { cancelled: true, submitted: false, submittedLabel: '', value: await cancelPrompt(cancelledSearchValue(choices, highlighted, options.default)) }; } const action = searchNavigationAction(key, { controlNavigation: true, lineControls: true }); @@ -57,11 +58,7 @@ export const readSearchChoice = async (options: SearchPromptOptions, attem const choice = choices[highlighted]; const value = selectedSearchValue(choices, highlighted); - if (choice && value !== undefined) { - renderSubmittedSearchChoice(options.message, choice.label); - } - - return value; + return { cancelled: false, submitted: choice !== undefined && value !== undefined, submittedLabel: choice?.label ?? '', value }; } choices = await resolveSearchChoices(options.options, state.value); @@ -76,7 +73,7 @@ export const readSearchChoice = async (options: SearchPromptOptions, attem if (next.cancelled) { renderCancelledSearch(options.message, state.value, options.placeholder); - return cancelPrompt(options.default); + return { cancelled: true, submitted: false, submittedLabel: '', value: await cancelPrompt(options.default) }; } state = { cursor: next.cursor, value: next.value }; diff --git a/packages/tui/src/prompts/search/read-single/result.ts b/packages/tui/src/prompts/search/read-single/result.ts index e89d4407..9e4e015d 100644 --- a/packages/tui/src/prompts/search/read-single/result.ts +++ b/packages/tui/src/prompts/search/read-single/result.ts @@ -2,6 +2,13 @@ import { ask } from '#tui/prompt'; import { resolveLineSearchChoice } from '#tui/prompts/search/line-mode'; import type { Choice, SearchPromptOptions } from '#tui/types'; +export type SearchChoiceReadResult = { + cancelled: boolean; + submitted: boolean; + submittedLabel: string; + value: T | undefined; +}; + export const lineSearchValue = async (options: SearchPromptOptions): Promise => { const query = (await ask(options.message, options.hint)).trim(); diff --git a/packages/tui/tests/search.test.ts b/packages/tui/tests/search.test.ts index 0f279f87..0732c525 100644 --- a/packages/tui/tests/search.test.ts +++ b/packages/tui/tests/search.test.ts @@ -53,6 +53,8 @@ describe('search prompt', () => { expect(result).toBe('green'); expect(output.text()).toContain('Please choose green.'); + expect(parseAnsiText(output.text())).not.toMatch(/^ │ Red\s*│$/m); + expect(parseAnsiText(output.text())).toMatch(/^ │ Green\s*│$/m); }); it('renders the typed query while navigating results', async () => { @@ -600,6 +602,27 @@ describe('multisearch prompt', () => { ).rejects.toThrow(PromptValidationError); }); + it('renders multisearch submitted frames only after validation passes', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.enter, Key.down, Key.space, Key.enter]), + output, + error: output, + interactive: true, + }, + () => multisearch({ message: 'Favorite colors?', options: colors, required: true }), + ); + + const rendered = parseAnsiText(output.text()); + + expect(result).toEqual(['red']); + expect(rendered).toContain('Required.'); + expect(rendered).not.toMatch(/^ │ None\s*│$/m); + expect(rendered).toMatch(/^ │ Red\s*│$/m); + }); + it('renders multisearch info for the highlighted result', async () => { const output = createMemoryOutput(); From dd074b123552cc1ce2d117bc4a0159fd843017ab Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Fri, 12 Jun 2026 14:14:00 +0800 Subject: [PATCH 252/675] Defer data table submit frames until valid --- UPSTREAM.md | 1 + packages/tui/src/output/data-table.ts | 22 +++++++++++++--- packages/tui/src/output/data-table/read.ts | 28 ++++++++++++++------ packages/tui/src/output/data-table/types.ts | 8 ++++++ packages/tui/tests/table.test.ts | 29 +++++++++++++++++++++ 5 files changed, 76 insertions(+), 12 deletions(-) diff --git a/UPSTREAM.md b/UPSTREAM.md index d7109864..7bf58c81 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -87,6 +87,7 @@ TypeScript and OpenTUI internals. | Data table no-results search rendering | `packages/tui/src/output/data-table/render.ts`, `packages/tui/tests/table.test.ts` | | Data table viewing-info rendering | `packages/tui/src/output/data-table/render.ts`, `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/read.ts`, `packages/tui/tests/table.test.ts` | +| Data table submitted frame renders only after validation passes | `packages/tui/src/output/data-table.ts`, `packages/tui/src/output/data-table/read.ts`, `packages/tui/src/output/data-table/types.ts`, `packages/tui/tests/table.test.ts` | | Data table themed cancellation warning | `packages/tui/src/output/data-table/render.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/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/tests/table.test.ts` | diff --git a/packages/tui/src/output/data-table.ts b/packages/tui/src/output/data-table.ts index 6900e483..1663d415 100644 --- a/packages/tui/src/output/data-table.ts +++ b/packages/tui/src/output/data-table.ts @@ -1,6 +1,8 @@ import { promptUntilValid } from '#tui/prompt'; 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 { DataTablePromptOptions, DataTableRow } from '#tui/types'; export function datatable(options: DataTablePromptOptions): Promise; @@ -44,9 +46,21 @@ export async function datatable( const headers = options.headers ?? deriveDataTableHeaders(options.rows); - return promptUntilValid(options, async () => { - const selected = await readDataTableSelection(options, headers); + let submittedSelection: DataTableSelectionReadResult | null = null; - return options.transform ? options.transform(selected) : selected; - }); + return promptUntilValid( + options, + async () => { + const selected = await readDataTableSelection(options, headers); + + submittedSelection = selected.submitted && !selected.cancelled ? selected : null; + + return options.transform ? options.transform(selected.value) : selected.value; + }, + () => { + if (submittedSelection) { + renderSubmittedDataTableFrame(options.message, headers, submittedSelection.rows, submittedSelection.selected); + } + }, + ); } diff --git a/packages/tui/src/output/data-table/read.ts b/packages/tui/src/output/data-table/read.ts index 5bfa291c..98fc966b 100644 --- a/packages/tui/src/output/data-table/read.ts +++ b/packages/tui/src/output/data-table/read.ts @@ -3,11 +3,11 @@ import { Key } from '#tui/key'; import { cancelPrompt, PromptValidationError } from '#tui/prompt'; import { dataTableNavigationAction, startsDataTableSearch } from '#tui/output/data-table/keys'; import { moveDataTableSelection } from '#tui/output/data-table/navigation'; -import { renderCancelledDataTableFrame, renderDataTableFrame, renderSubmittedDataTableFrame } from '#tui/output/data-table/render'; +import { renderCancelledDataTableFrame, renderDataTableFrame } from '#tui/output/data-table/render'; import { dataTableRowValue, visibleDataTableRows } from '#tui/output/data-table/rows'; import { applyDataTableSearchKey, initialDataTableSearchState, startDataTableSearch } from '#tui/output/data-table/search'; import type { DataTableSearchState } from '#tui/output/data-table/search'; -import type { VisibleDataTableRow } from '#tui/output/data-table/types'; +import type { DataTableSelectionReadResult, VisibleDataTableRow } from '#tui/output/data-table/types'; import type { DataTablePromptOptions } from '#tui/types'; const invalidRow = (): PromptValidationError => new PromptValidationError('Please select a valid row.'); @@ -32,7 +32,15 @@ const initialDataTableSelection = (rows: Array>, defau return Math.max(0, selected); }; -export const readDataTableSelection = async (options: DataTablePromptOptions, headers: string[]): Promise => { +const dataTableSelectionResult = (rows: Array>, selected: number, submitted: boolean, cancelled = false): DataTableSelectionReadResult => ({ + cancelled, + rows, + selected, + submitted, + value: selectedDataTableValue(rows, selected), +}); + +export const readDataTableSelection = async (options: DataTablePromptOptions, headers: string[]): Promise> => { const environment = promptEnvironment(); let search: DataTableSearchState = initialDataTableSearchState(); @@ -55,7 +63,7 @@ export const readDataTableSelection = async (options: DataTablePromptOptions< }; if (!environment.input.readKey) { - return selectedDataTableValue(visibleRows(), selected); + return dataTableSelectionResult(visibleRows(), selected, false); } render(); @@ -72,7 +80,13 @@ export const readDataTableSelection = async (options: DataTablePromptOptions< if (key === Key.ctrlC) { renderCancelledDataTableFrame(options.message, headers, rows, selected); - return cancelPrompt(selectedDataTableValue(rows, selected)); + return { + cancelled: true, + rows, + selected, + submitted: false, + value: await cancelPrompt(selectedDataTableValue(rows, selected)), + }; } const nextSearch = applyDataTableSearchKey(search, key); @@ -100,9 +114,7 @@ export const readDataTableSelection = async (options: DataTablePromptOptions< } if (key === Key.enter) { - renderSubmittedDataTableFrame(options.message, headers, rows, selected); - - return selectedDataTableValue(rows, selected); + return dataTableSelectionResult(rows, selected, true); } } }; diff --git a/packages/tui/src/output/data-table/types.ts b/packages/tui/src/output/data-table/types.ts index dcb8f491..6e263bed 100644 --- a/packages/tui/src/output/data-table/types.ts +++ b/packages/tui/src/output/data-table/types.ts @@ -4,3 +4,11 @@ export type VisibleDataTableRow = { index: number; row: DataTableRow; }; + +export type DataTableSelectionReadResult = { + cancelled: boolean; + rows: Array>; + selected: number; + submitted: boolean; + value: T | number; +}; diff --git a/packages/tui/tests/table.test.ts b/packages/tui/tests/table.test.ts index 21734c76..90352b33 100644 --- a/packages/tui/tests/table.test.ts +++ b/packages/tui/tests/table.test.ts @@ -199,6 +199,35 @@ describe('data table prompt', () => { expect(result).toBe('ALPHA'); }); + it('renders data table submitted frames only after validation passes', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.enter, Key.down, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + datatable({ + message: 'Pick project', + rows: [ + { Name: 'Alpha', value: 'alpha' }, + { Name: 'Beta', value: 'beta' }, + ], + validate: (value) => (value === 'beta' ? null : 'Choose beta.'), + }), + ); + + const rendered = parseAnsiText(output.text()); + + expect(result).toBe('beta'); + expect(rendered).toContain('Choose beta.'); + expect(rendered).not.toMatch(/^Alpha$/m); + expect(rendered).toMatch(/^Beta$/m); + }); + it('starts data table selection on matching default values', async () => { const output = createMemoryOutput(); From d90a551cbd88afdd49707ec99f6b88539abfdaf2 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Fri, 12 Jun 2026 14:16:05 +0800 Subject: [PATCH 253/675] Reuse label data table form defaults --- UPSTREAM.md | 1 + packages/tui/src/form/builder/output.ts | 17 +++++++++++- packages/tui/tests/form.test.ts | 35 +++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 1 deletion(-) diff --git a/UPSTREAM.md b/UPSTREAM.md index 7bf58c81..28264981 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -108,6 +108,7 @@ TypeScript and OpenTUI internals. | Form builder search and multisearch previous-response defaults | `packages/tui/src/form/builder/prompts/choices.ts`, `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/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/tests/form.test.ts` | +| Form builder label-first data table previous-response defaults | `packages/tui/src/form/builder/output.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/output/validators/data-table.ts`, `packages/tui/tests/form.test.ts` | | Form builder object-option table output method surface | `packages/tui/src/form/builder/output.ts`, `packages/tui/src/output/validators/table.ts`, `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/output.ts`, `packages/tui/tests/form.test.ts` | diff --git a/packages/tui/src/form/builder/output.ts b/packages/tui/src/form/builder/output.ts index 78a3112b..1099fc56 100644 --- a/packages/tui/src/form/builder/output.ts +++ b/packages/tui/src/form/builder/output.ts @@ -110,7 +110,22 @@ function datatableFormStep( return this.add((_, previous) => datatable({ ...optionsOrHeaders, default: previousValue(previous, optionsOrHeaders.default) }), dataTableStepName(rowsOrName)); } - return this.add(() => datatable(optionsOrHeaders, rowsOrName as Array> | null, scroll, label, hint, required, validate, transform, filter), name); + return this.add( + (_, previous) => + datatable({ + default: previousValue(previous, undefined), + filter, + headers: optionsOrHeaders, + hint, + message: label, + required, + rows: (rowsOrName as Array> | null) ?? [], + scroll, + transform, + validate, + }), + name, + ); } export const outputBuilderMethods: OutputBuilderMethods & ThisType = { diff --git a/packages/tui/tests/form.test.ts b/packages/tui/tests/form.test.ts index 60554a80..e2a97573 100644 --- a/packages/tui/tests/form.test.ts +++ b/packages/tui/tests/form.test.ts @@ -902,4 +902,39 @@ describe('form builder', () => { expect(responses.project).toBe('beta'); expect(responses[1]).toBe(true); }); + + it('reuses previous data table responses when reverting label-first form steps', async () => { + const output = createMemoryOutput(); + + const responses = await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.enter, Key.ctrlU, Key.enter, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + form() + .datatable( + ['Name'], + [ + { Name: 'Alpha', value: 'alpha' }, + { Name: 'Beta', value: 'beta' }, + ], + 10, + 'Pick project', + '', + false, + undefined, + undefined, + undefined, + 'project', + ) + .confirm('Done?') + .submit(), + ); + + expect(responses.project).toBe('beta'); + expect(responses[1]).toBe(true); + }); }); From 8dbdbeb772713737ca8987a541b9ba5a0d5a36a7 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Fri, 12 Jun 2026 14:50:50 +0800 Subject: [PATCH 254/675] Pass form choice info options --- UPSTREAM.md | 1 + .../tui/src/form/builder/prompts/choices.ts | 10 ++++++ .../form/builder/prompts/contracts/choices.ts | 4 +++ packages/tui/tests/form.test.ts | 35 +++++++++++++++++++ 4 files changed, 50 insertions(+) diff --git a/UPSTREAM.md b/UPSTREAM.md index 28264981..9998a98b 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -99,6 +99,7 @@ TypeScript and OpenTUI internals. | 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/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/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 | `packages/tui/src/form/builder/prompts/basic.ts`, `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 | `packages/tui/src/form/builder/prompts/choices.ts`, `packages/tui/src/form/builder/prompts/contracts/choices.ts`, `packages/tui/src/form/builder/prompts/validators/select.ts`, `packages/tui/tests/form.test.ts` | diff --git a/packages/tui/src/form/builder/prompts/choices.ts b/packages/tui/src/form/builder/prompts/choices.ts index e859c296..a8fa07df 100644 --- a/packages/tui/src/form/builder/prompts/choices.ts +++ b/packages/tui/src/form/builder/prompts/choices.ts @@ -152,6 +152,7 @@ function multiselectFormStep( hint?: string, name?: string, transform?: MultiSelectPromptOptions['transform'], + info?: MultiSelectPromptOptions['info'], ): FormBuilder; function multiselectFormStep( @@ -165,6 +166,7 @@ function multiselectFormStep( hint = 'Use the space bar to select options.', name?: string, transform: MultiSelectPromptOptions['transform'] = undefined, + info: MultiSelectPromptOptions['info'] = '', ): FormBuilder { if (!isSelectPromptLabel(optionsOrLabel)) { return this.add((_, previous) => multiselect({ ...optionsOrLabel, default: previousArray(previous, optionsOrLabel.default ?? []) }), optionsOrName as string | undefined); @@ -181,6 +183,7 @@ function multiselectFormStep( validate, hint, transform, + info, }), name, ); @@ -198,6 +201,7 @@ function selectFormStep( required?: boolean | string, name?: string, transform?: SelectPromptOptions['transform'], + info?: SelectPromptOptions['info'], ): FormBuilder; function selectFormStep( @@ -211,6 +215,7 @@ function selectFormStep( required: boolean | string = true, name?: string, transform: SelectPromptOptions['transform'] = undefined, + info: SelectPromptOptions['info'] = '', ): FormBuilder { if (!isSelectPromptLabel(optionsOrLabel)) { return this.add((_, previous) => select({ ...optionsOrLabel, default: previousValue(previous, optionsOrLabel.default) }), optionsOrName as string | undefined); @@ -227,6 +232,7 @@ function selectFormStep( hint, required, transform, + info, }), name, ); @@ -245,6 +251,7 @@ export const choicePromptBuilderMethods: ChoicePromptBuilderMethods & ThisType['transform'] = undefined, + info: MultiSearchPromptOptions['info'] = '', ) { if (!isSearchPromptLabel(optionsOrLabel)) { return this.add((_, previous) => multisearch({ ...optionsOrLabel, default: previousArray(previous, optionsOrLabel.default ?? []) }), options as string | undefined); @@ -260,6 +267,7 @@ export const choicePromptBuilderMethods: ChoicePromptBuilderMethods & ThisType multisearch({ ...promptOptions, default: previousArray(previous, promptOptions.default ?? []) }), name); @@ -278,6 +286,7 @@ export const choicePromptBuilderMethods: ChoicePromptBuilderMethods & ThisType['required'] = true, name?: string, transform: SearchPromptOptions['transform'] = undefined, + info: SearchPromptOptions['info'] = '', ) { if (!isSearchPromptLabel(optionsOrLabel)) { return this.add((_, previous) => search({ ...optionsOrLabel, default: previousValue(previous, optionsOrLabel.default) }), options as string | undefined); @@ -293,6 +302,7 @@ export const choicePromptBuilderMethods: ChoicePromptBuilderMethods & ThisType search({ ...promptOptions, default: previousValue(previous, promptOptions.default) }), name); diff --git a/packages/tui/src/form/builder/prompts/contracts/choices.ts b/packages/tui/src/form/builder/prompts/contracts/choices.ts index 97ca2ea5..18b9e20e 100644 --- a/packages/tui/src/form/builder/prompts/contracts/choices.ts +++ b/packages/tui/src/form/builder/prompts/contracts/choices.ts @@ -41,6 +41,7 @@ export type ChoicePromptBuilderMethods = { hint?: string, name?: string, transform?: MultiSearchPromptOptions['transform'], + info?: MultiSearchPromptOptions['info'], ): FormBuilder; multiselect(this: FormBuilder, options: MultiSelectPromptOptions, name?: string): FormBuilder; multiselect( @@ -54,6 +55,7 @@ export type ChoicePromptBuilderMethods = { hint?: string, name?: string, transform?: (value: T[]) => MaybePromise, + info?: MultiSelectPromptOptions['info'], ): FormBuilder; pause(this: FormBuilder, message?: string, name?: string): FormBuilder; search(this: FormBuilder, options: SearchPromptOptions, name?: string): FormBuilder; @@ -68,6 +70,7 @@ export type ChoicePromptBuilderMethods = { required?: SearchPromptOptions['required'], name?: string, transform?: SearchPromptOptions['transform'], + info?: SearchPromptOptions['info'], ): FormBuilder; select(this: FormBuilder, options: SelectPromptOptions, name?: string): FormBuilder; select( @@ -81,6 +84,7 @@ export type ChoicePromptBuilderMethods = { required?: boolean | string, name?: string, transform?: (value: T) => MaybePromise, + info?: SelectPromptOptions['info'], ): FormBuilder; suggest(this: FormBuilder, options: SuggestOptions, name?: string): FormBuilder; suggest( diff --git a/packages/tui/tests/form.test.ts b/packages/tui/tests/form.test.ts index e2a97573..4e7c4b1e 100644 --- a/packages/tui/tests/form.test.ts +++ b/packages/tui/tests/form.test.ts @@ -560,6 +560,41 @@ describe('form builder', () => { expect(responses.many).toEqual(['red']); }); + it('passes label-first choice info through form steps', async () => { + const output = createMemoryOutput(); + + const colorOptions = (value: string): Record => { + const options = { red: 'Red', green: 'Green', blue: 'Blue' }; + + return Object.fromEntries(Object.entries(options).filter(([, label]) => label.toLowerCase().includes(value.toLowerCase()))); + }; + + const responses = await withPromptEnvironment( + { + input: createScriptedInput([Key.down, Key.enter, Key.down, Key.space, Key.enter, 'g', Key.down, Key.enter, 'r', Key.down, Key.space, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + form() + .select('Choice', ['first', 'second'], undefined, 5, undefined, '', true, 'choice', undefined, (value) => `About choice ${value ?? 'none'}`) + .multiselect('Choices', ['first', 'second'], [], 5, false, undefined, 'Use the space bar to select options.', 'choices', undefined, (value) => `About choices ${value ?? 'none'}`) + .search('Searched color', colorOptions, '', 5, undefined, '', true, 'searched', undefined, (value) => `About search ${value ?? 'none'}`) + .multisearch('Many colors', colorOptions, '', 5, false, undefined, 'Use the space bar to select options.', 'many', undefined, (value) => `About multisearch ${value ?? 'none'}`) + .submit(), + ); + + expect(responses.choice).toBe('second'); + expect(responses.choices).toEqual(['second']); + expect(responses.searched).toBe('green'); + expect(responses.many).toEqual(['red']); + expect(output.text()).toContain('About choice second'); + expect(output.text()).toContain('About choices second'); + expect(output.text()).toContain('About search green'); + expect(output.text()).toContain('About multisearch red'); + }); + it('reuses previous search responses when reverting object-option form steps', async () => { const output = createMemoryOutput(); From 06bdfa70aef56b6922b8e9ffa6d64af8b614a1a8 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Fri, 12 Jun 2026 15:32:55 +0800 Subject: [PATCH 255/675] Support suggest info overloads --- UPSTREAM.md | 2 ++ .../tui/src/form/builder/prompts/choices.ts | 6 ++++ .../form/builder/prompts/contracts/choices.ts | 2 ++ packages/tui/src/prompts/suggest/index.ts | 10 ++++-- packages/tui/src/prompts/suggest/options.ts | 3 +- .../src/prompts/suggest/read-autocomplete.ts | 10 +++--- .../prompts/suggest/render-autocomplete.ts | 7 ++-- packages/tui/tests/form.test.ts | 23 +++++++++++++ packages/tui/tests/suggest.test.ts | 32 +++++++++++++++++++ 9 files changed, 85 insertions(+), 10 deletions(-) diff --git a/UPSTREAM.md b/UPSTREAM.md index 9998a98b..39e45778 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -53,6 +53,7 @@ TypeScript and OpenTUI internals. | Multi-select cancel final frame | `packages/tui/src/prompts/select/render.ts`, `packages/tui/src/prompts/select/read-multiple.ts`, `packages/tui/tests/choices.test.ts` | | Suggest, autocomplete | `packages/tui/src/prompts/suggest/*` | | Suggest active, submitted, cancelled, placeholder, info, scrolling, and validation-safe rendering | `packages/tui/src/prompts/suggest/index.ts`, `packages/tui/src/prompts/suggest/read.ts`, `packages/tui/src/prompts/suggest/render.ts`, `packages/tui/tests/suggest.test.ts` | +| Suggest and autocomplete label-first info rendering | `packages/tui/src/prompts/suggest/index.ts`, `packages/tui/src/prompts/suggest/options.ts`, `packages/tui/src/prompts/suggest/read-autocomplete.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/prompts/suggest/index.ts`, `packages/tui/src/prompts/suggest/read-autocomplete.ts`, `packages/tui/src/prompts/suggest/render-autocomplete.ts`, `packages/tui/tests/suggest.test.ts` | | Search, multisearch | `packages/tui/src/prompts/search/*` | @@ -105,6 +106,7 @@ TypeScript and OpenTUI internals. | Form builder choice prompt object-option method surface | `packages/tui/src/form/builder/prompts/choices.ts`, `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/contracts/choices.ts`, `packages/tui/tests/form.test.ts` | | Form builder suggest and autocomplete object-option method surface | `packages/tui/src/form/builder/prompts/choices.ts`, `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/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/contracts/basic.ts`, `packages/tui/src/prompts/number.ts`, `packages/tui/tests/form.test.ts`, `packages/tui/tests/number.test.ts` | | Form builder search and multisearch previous-response defaults | `packages/tui/src/form/builder/prompts/choices.ts`, `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/validators/previous.ts`, `packages/tui/tests/form.test.ts` | diff --git a/packages/tui/src/form/builder/prompts/choices.ts b/packages/tui/src/form/builder/prompts/choices.ts index a8fa07df..83d65dff 100644 --- a/packages/tui/src/form/builder/prompts/choices.ts +++ b/packages/tui/src/form/builder/prompts/choices.ts @@ -20,6 +20,7 @@ function autocompleteFormStep( hint?: string, name?: string, transform?: TextPromptOptions['transform'], + info?: SuggestOptions['info'], ): FormBuilder; function autocompleteFormStep( @@ -33,6 +34,7 @@ function autocompleteFormStep( hint = '', name?: string, transform: TextPromptOptions['transform'] = undefined, + info: SuggestOptions['info'] = '', ): FormBuilder { if (!isSuggestPromptLabel(optionsOrLabel)) { return this.add((_, previous) => autocomplete({ ...optionsOrLabel, default: previousString(previous, optionsOrLabel.default ?? '') }), options as string | undefined); @@ -50,6 +52,7 @@ function autocompleteFormStep( validate, hint, transform, + info, }), name, ); @@ -68,6 +71,7 @@ function suggestFormStep( hint?: string, name?: string, transform?: TextPromptOptions['transform'], + info?: SuggestOptions['info'], ): FormBuilder; function suggestFormStep( @@ -82,6 +86,7 @@ function suggestFormStep( hint = '', name?: string, transform: TextPromptOptions['transform'] = undefined, + info: SuggestOptions['info'] = '', ): FormBuilder { if (!isSuggestPromptLabel(optionsOrLabel)) { return this.add((_, previous) => suggest({ ...optionsOrLabel, default: previousString(previous, optionsOrLabel.default ?? '') }), options as string | undefined); @@ -100,6 +105,7 @@ function suggestFormStep( validate, hint, transform, + info, }), name, ); diff --git a/packages/tui/src/form/builder/prompts/contracts/choices.ts b/packages/tui/src/form/builder/prompts/contracts/choices.ts index 18b9e20e..a4020818 100644 --- a/packages/tui/src/form/builder/prompts/contracts/choices.ts +++ b/packages/tui/src/form/builder/prompts/contracts/choices.ts @@ -15,6 +15,7 @@ export type ChoicePromptBuilderMethods = { hint?: string, name?: string, transform?: TextPromptOptions['transform'], + info?: SuggestOptions['info'], ): FormBuilder; confirm(this: FormBuilder, options: ConfirmPromptOptions, name?: string): FormBuilder; confirm( @@ -99,5 +100,6 @@ export type ChoicePromptBuilderMethods = { hint?: string, name?: string, transform?: TextPromptOptions['transform'], + info?: SuggestOptions['info'], ): FormBuilder; }; diff --git a/packages/tui/src/prompts/suggest/index.ts b/packages/tui/src/prompts/suggest/index.ts index ce741ed6..09aabc0d 100644 --- a/packages/tui/src/prompts/suggest/index.ts +++ b/packages/tui/src/prompts/suggest/index.ts @@ -21,6 +21,7 @@ export function suggest( validate?: TextPromptOptions['validate'], hint?: string, transform?: TextPromptOptions['transform'], + info?: SuggestOptions['info'], ): Promise; export async function suggest( @@ -33,8 +34,9 @@ export async function suggest( 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); + const options = suggestOptions(message, source, placeholder, defaultValue, scroll, required, validate, hint, transform, info); let shouldRenderSubmittedFrame = false; @@ -68,6 +70,7 @@ export function autocomplete( validate?: TextPromptOptions['validate'], hint?: string, transform?: TextPromptOptions['transform'], + info?: SuggestOptions['info'], ): Promise; export async function autocomplete( @@ -79,9 +82,12 @@ export async function autocomplete( validate: TextPromptOptions['validate'] = undefined, hint = '', transform: TextPromptOptions['transform'] = undefined, + info: SuggestOptions['info'] = '', ): Promise { const options = - typeof message === 'string' ? suggestOptions({ message, label: message, options: source, placeholder, default: defaultValue, required, validate, hint, transform }) : suggestOptions(message); + typeof message === 'string' + ? suggestOptions({ message, label: message, options: source, placeholder, default: defaultValue, required, validate, hint, transform, info }) + : suggestOptions(message); let shouldRenderSubmittedFrame = false; diff --git a/packages/tui/src/prompts/suggest/options.ts b/packages/tui/src/prompts/suggest/options.ts index 0bbe45f4..e5434ce7 100644 --- a/packages/tui/src/prompts/suggest/options.ts +++ b/packages/tui/src/prompts/suggest/options.ts @@ -16,9 +16,10 @@ export const suggestOptions = ( validate: TextPromptOptions['validate'] = undefined, hint = '', transform: TextPromptOptions['transform'] = undefined, + info: SuggestOptions['info'] = '', ): SuggestOptions => { if (typeof message === 'string') { - return { message, label: message, options, placeholder, default: defaultValue, scroll, required, validate, hint, transform }; + 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 index 074e513c..8d9fdab5 100644 --- a/packages/tui/src/prompts/suggest/read-autocomplete.ts +++ b/packages/tui/src/prompts/suggest/read-autocomplete.ts @@ -33,7 +33,7 @@ export const readAutocompleteValue = async (options: SuggestOptions): Promise { +export const renderAutocomplete = (message: string, state: TypedValueState, matches: string[], highlighted: number, hint = '', placeholder = '', info?: SuggestOptions['info']): void => { const value = state.value.length === 0 ? dim(placeholder) : autocompleteDisplayValue(state, matches[highlighted], placeholder); + const details = [hint, resolveInfo(info, matches[highlighted] ?? null)].filter((part) => part.length > 0).join(' · '); - promptEnvironment().output.write(`${renderBox({ body: value, borderStyle: cyan, info: hint, title: cyan(message) })}\n`); + promptEnvironment().output.write(`${renderBox({ body: value, borderStyle: cyan, info: details, title: cyan(message) })}\n`); }; export const renderSubmittedAutocomplete = (message: string, value: string): void => { diff --git a/packages/tui/tests/form.test.ts b/packages/tui/tests/form.test.ts index 4e7c4b1e..22f3ea9a 100644 --- a/packages/tui/tests/form.test.ts +++ b/packages/tui/tests/form.test.ts @@ -492,6 +492,29 @@ describe('form builder', () => { expect(output.text()).toContain('Type a color'); }); + it('passes suggest and autocomplete info from form steps', async () => { + const output = createMemoryOutput(); + + const responses = await withPromptEnvironment( + { + input: createScriptedInput(['b', Key.down, Key.enter, 'g', Key.tab, Key.enter]), + output, + error: output, + interactive: true, + }, + () => + form() + .suggest('Suggested color', ['Red', 'Green', 'Blue'], '', '', 5, false, undefined, '', 'suggested', undefined, (value) => `About suggested ${value ?? 'none'}`) + .autocomplete('Auto color', ['Red', 'Green', 'Blue'], '', '', false, undefined, '', 'auto', undefined, (value) => `About auto ${value ?? 'none'}`) + .submit(), + ); + + expect(responses.suggested).toBe('Blue'); + expect(responses.auto).toBe('Green'); + expect(output.text()).toContain('About suggested Blue'); + expect(output.text()).toContain('About auto Green'); + }); + it('runs object-option suggest and autocomplete form steps', async () => { const output = createMemoryOutput(); diff --git a/packages/tui/tests/suggest.test.ts b/packages/tui/tests/suggest.test.ts index d5b26b95..aeead0c1 100644 --- a/packages/tui/tests/suggest.test.ts +++ b/packages/tui/tests/suggest.test.ts @@ -111,6 +111,22 @@ describe('suggest prompt', () => { expect(output.text()).toContain('About Blue'); }); + it('supports label-first suggest helpers with info', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment( + { + input: createScriptedInput(['b', Key.down, Key.enter]), + output, + error: output, + interactive: true, + }, + () => suggest('Favorite color?', ['Red', 'Green', 'Blue'], '', '', 5, false, undefined, '', undefined, (value) => `About ${value ?? 'none'}`), + ); + + expect(output.text()).toContain('About Blue'); + }); + it('renders cancelled suggest frames with the current value', async () => { const output = createMemoryOutput(); @@ -168,6 +184,22 @@ describe('autocomplete prompt', () => { expect(output.text()).not.toContain('›'); }); + it('supports label-first autocomplete helpers with info', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment( + { + input: createScriptedInput(['b', Key.enter]), + output, + error: output, + interactive: true, + }, + () => autocomplete('Favorite color?', ['Red', 'Green', 'Blue'], '', '', false, undefined, '', undefined, (value) => `About ${value ?? 'none'}`), + ); + + expect(output.text()).toContain('About Blue'); + }); + it('accepts ghost completion with tab', async () => { const output = createMemoryOutput(); From 697fbdf8e3c348ccf17aa1ff0efb6fd4c901071d Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Fri, 12 Jun 2026 16:16:58 +0800 Subject: [PATCH 256/675] Add docs package --- packages/docs/examples/basic-prompts.ts | 42 ++++ packages/docs/examples/choice-prompts.ts | 51 ++++ packages/docs/examples/forms.ts | 28 +++ packages/docs/examples/output.ts | 31 +++ packages/docs/examples/search-prompts.ts | 28 +++ packages/docs/examples/status.ts | 25 ++ packages/docs/examples/testing.ts | 15 ++ packages/docs/package.json | 27 +++ packages/docs/src/.vitepress/config.ts | 61 +++++ .../theme/components/TerminalOutput.vue | 229 ++++++++++++++++++ packages/docs/src/.vitepress/theme/index.ts | 12 + packages/docs/src/.vitepress/theme/styles.css | 55 +++++ packages/docs/src/.vitepress/vue.d.ts | 9 + packages/docs/src/guide/autocomplete.md | 51 ++++ packages/docs/src/guide/available-prompts.md | 54 +++++ packages/docs/src/guide/clearing-terminal.md | 51 ++++ packages/docs/src/guide/confirm.md | 50 ++++ packages/docs/src/guide/fallbacks.md | 61 +++++ packages/docs/src/guide/forms.md | 83 +++++++ .../docs/src/guide/informational-messages.md | 50 ++++ packages/docs/src/guide/installation.md | 73 ++++++ packages/docs/src/guide/introduction.md | 72 ++++++ packages/docs/src/guide/multisearch.md | 63 +++++ packages/docs/src/guide/multiselect.md | 62 +++++ packages/docs/src/guide/number.md | 64 +++++ packages/docs/src/guide/password.md | 56 +++++ packages/docs/src/guide/pause.md | 49 ++++ packages/docs/src/guide/progress.md | 62 +++++ packages/docs/src/guide/search.md | 61 +++++ packages/docs/src/guide/select.md | 72 ++++++ packages/docs/src/guide/spin.md | 55 +++++ packages/docs/src/guide/stream.md | 55 +++++ packages/docs/src/guide/suggest.md | 54 +++++ packages/docs/src/guide/tables.md | 87 +++++++ packages/docs/src/guide/task.md | 54 +++++ .../docs/src/guide/terminal-considerations.md | 56 +++++ packages/docs/src/guide/terminal-title.md | 48 ++++ packages/docs/src/guide/testing.md | 68 ++++++ packages/docs/src/guide/text.md | 62 +++++ packages/docs/src/guide/textarea.md | 54 +++++ packages/docs/src/guide/transforms.md | 61 +++++ packages/docs/src/index.md | 47 ++++ packages/docs/tests/docs.test.ts | 82 +++++++ packages/docs/tsconfig.json | 13 + packages/docs/typedoc.json | 12 + packages/docs/vitest.config.ts | 24 ++ 46 files changed, 2479 insertions(+) create mode 100644 packages/docs/examples/basic-prompts.ts create mode 100644 packages/docs/examples/choice-prompts.ts create mode 100644 packages/docs/examples/forms.ts create mode 100644 packages/docs/examples/output.ts create mode 100644 packages/docs/examples/search-prompts.ts create mode 100644 packages/docs/examples/status.ts create mode 100644 packages/docs/examples/testing.ts create mode 100644 packages/docs/package.json create mode 100644 packages/docs/src/.vitepress/config.ts create mode 100644 packages/docs/src/.vitepress/theme/components/TerminalOutput.vue create mode 100644 packages/docs/src/.vitepress/theme/index.ts create mode 100644 packages/docs/src/.vitepress/theme/styles.css create mode 100644 packages/docs/src/.vitepress/vue.d.ts create mode 100644 packages/docs/src/guide/autocomplete.md create mode 100644 packages/docs/src/guide/available-prompts.md create mode 100644 packages/docs/src/guide/clearing-terminal.md create mode 100644 packages/docs/src/guide/confirm.md create mode 100644 packages/docs/src/guide/fallbacks.md create mode 100644 packages/docs/src/guide/forms.md create mode 100644 packages/docs/src/guide/informational-messages.md create mode 100644 packages/docs/src/guide/installation.md create mode 100644 packages/docs/src/guide/introduction.md create mode 100644 packages/docs/src/guide/multisearch.md create mode 100644 packages/docs/src/guide/multiselect.md create mode 100644 packages/docs/src/guide/number.md create mode 100644 packages/docs/src/guide/password.md create mode 100644 packages/docs/src/guide/pause.md create mode 100644 packages/docs/src/guide/progress.md create mode 100644 packages/docs/src/guide/search.md create mode 100644 packages/docs/src/guide/select.md create mode 100644 packages/docs/src/guide/spin.md create mode 100644 packages/docs/src/guide/stream.md create mode 100644 packages/docs/src/guide/suggest.md create mode 100644 packages/docs/src/guide/tables.md create mode 100644 packages/docs/src/guide/task.md create mode 100644 packages/docs/src/guide/terminal-considerations.md create mode 100644 packages/docs/src/guide/terminal-title.md create mode 100644 packages/docs/src/guide/testing.md create mode 100644 packages/docs/src/guide/text.md create mode 100644 packages/docs/src/guide/textarea.md create mode 100644 packages/docs/src/guide/transforms.md create mode 100644 packages/docs/src/index.md create mode 100644 packages/docs/tests/docs.test.ts create mode 100644 packages/docs/tsconfig.json create mode 100644 packages/docs/typedoc.json create mode 100644 packages/docs/vitest.config.ts diff --git a/packages/docs/examples/basic-prompts.ts b/packages/docs/examples/basic-prompts.ts new file mode 100644 index 00000000..9a659750 --- /dev/null +++ b/packages/docs/examples/basic-prompts.ts @@ -0,0 +1,42 @@ +import { number, password, text, textarea } from '@ollin/tui'; + +export async function basicPromptExamples(): Promise { + const name = await text({ + message: 'What is your name?', + placeholder: 'Ada Lovelace', + required: 'A name is required.', + validate: (value) => (value.length < 2 ? 'Enter at least two characters.' : null), + }); + + const biography = await textarea({ + message: 'Tell us about your project.', + placeholder: 'This project helps...', + rows: 5, + required: true, + }); + + const copies = await number({ + message: 'How many copies?', + default: 1, + integer: true, + min: 1, + max: 100, + validate: (value) => (Number(value) > 100 ? 'Choose 100 or fewer copies.' : null), + }); + + const token = await password({ + message: 'Enter your API token.', + hint: 'Input is masked while typing.', + required: true, + }); + + void [name, biography, copies, token]; +} + +export async function labelFirstPromptExamples(): Promise { + const name = await text('What is your name?', 'Ada Lovelace', '', true); + + const copies = await number('How many copies?', '5', 1, true); + + void [name, copies]; +} diff --git a/packages/docs/examples/choice-prompts.ts b/packages/docs/examples/choice-prompts.ts new file mode 100644 index 00000000..f8a01cec --- /dev/null +++ b/packages/docs/examples/choice-prompts.ts @@ -0,0 +1,51 @@ +import { autocomplete, confirm, multiselect, select, suggest } from '@ollin/tui'; + +const roles = { + member: 'Member', + contributor: 'Contributor', + owner: 'Owner', +}; + +export async function choicePromptExamples(): Promise { + const accepted = await confirm({ + message: 'Do you accept the terms?', + default: false, + yes: 'I accept', + no: 'I decline', + required: 'Accept the terms to continue.', + }); + + const role = await select({ + message: 'What role should the user have?', + options: roles, + default: 'member', + info: (value) => (value === 'owner' ? 'Full access to the project.' : null), + }); + + const permissions = await multiselect({ + message: 'What permissions should be assigned?', + options: { + read: 'Read', + create: 'Create', + update: 'Update', + delete: 'Delete', + }, + default: ['read'], + required: 'Select at least one permission.', + validate: (values) => (values.includes('read') ? null : 'Every user needs read access.'), + }); + + const author = await suggest({ + message: 'Author name', + options: (query) => ['Ada', 'Grace', 'Lin'].filter((name) => name.toLowerCase().includes(query.toLowerCase())), + placeholder: 'Ada', + }); + + const runtime = await autocomplete({ + message: 'Runtime', + options: ['Node.js', 'Bun', 'Deno'], + default: 'Node.js', + }); + + void [accepted, role, permissions, author, runtime]; +} diff --git a/packages/docs/examples/forms.ts b/packages/docs/examples/forms.ts new file mode 100644 index 00000000..fb0ecab4 --- /dev/null +++ b/packages/docs/examples/forms.ts @@ -0,0 +1,28 @@ +import { form } from '@ollin/tui'; + +export async function formExample(): Promise { + const responses = await form() + .intro('Create an account') + .text({ message: 'Name', required: true }, 'name') + .confirm({ message: 'Enable notifications?', default: true }, 'notifications') + .addIf( + (responses) => responses.notifications === true, + async () => 'email', + 'notificationChannel', + ) + .outro('Account configured') + .submit(); + + void responses; +} + +export async function callbackFormExample(): Promise { + const responses = await form(async (builder) => + builder + .text({ message: 'Project name', required: true }, 'project') + .select({ message: 'Visibility', options: ['Private', 'Public'], default: 'Private' }, 'visibility') + .submit(), + ); + + void responses; +} diff --git a/packages/docs/examples/output.ts b/packages/docs/examples/output.ts new file mode 100644 index 00000000..d1421886 --- /dev/null +++ b/packages/docs/examples/output.ts @@ -0,0 +1,31 @@ +import { alert, clear, dataTable, error, grid, info, intro, note, outro, table, title, warning } from '@ollin/tui'; + +export function outputExamples(): void { + intro('Deployment'); + info('Preparing release artefacts.'); + warning('Two optional checks were skipped.'); + error('A required check failed.'); + alert('Manual review required.'); + note('Use output helpers for durable terminal messages.', 'info'); + + table( + ['Name', 'Runtime'], + [ + ['Prompts', 'TypeScript'], + ['Renderer', 'OpenTUI'], + ], + ); + + dataTable({ + headers: ['Name', 'Role'], + rows: [ + ['Ada', 'Admin'], + ['Grace', 'Operator'], + ], + }); + + grid(['text', 'select', 'search', 'task']); + title('Ollin TUI'); + clear(); + outro('Done'); +} diff --git a/packages/docs/examples/search-prompts.ts b/packages/docs/examples/search-prompts.ts new file mode 100644 index 00000000..bcdc20de --- /dev/null +++ b/packages/docs/examples/search-prompts.ts @@ -0,0 +1,28 @@ +import { multisearch, search } from '@ollin/tui'; + +const users = new Map([ + [1, 'Lin Chen'], + [2, 'Ada Lovelace'], + [3, 'Grace Hopper'], +]); + +const matchingUsers = (query: string): Record => Object.fromEntries([...users].filter(([, name]) => name.toLowerCase().includes(query.toLowerCase()))); + +export async function searchPromptExamples(): Promise { + const userId = await search({ + message: 'Search for the user receiving the email.', + placeholder: 'Ada', + options: matchingUsers, + required: true, + info: (value) => `User id ${value}`, + }); + + const userIds = await multisearch({ + message: 'Search for users receiving the email.', + placeholder: 'Ada', + options: matchingUsers, + required: 'Select at least one user.', + }); + + void [userId, userIds]; +} diff --git a/packages/docs/examples/status.ts b/packages/docs/examples/status.ts new file mode 100644 index 00000000..4b49da2a --- /dev/null +++ b/packages/docs/examples/status.ts @@ -0,0 +1,25 @@ +import { progress, spin, stream, task } from '@ollin/tui'; + +export async function statusExamples(): Promise { + await spin('Installing dependencies', async () => 'installed'); + + await progress('Processing files', ['one.ts', 'two.ts'], async (file) => String(file).toUpperCase()); + + await task('Publishing', async (logger) => { + logger.info('Uploading assets'); + logger.success('Assets uploaded'); + + return 'published'; + }); + + await stream(['Preparing release\n', 'Release ready\n']); +} + +export function manualProgressExample(): void { + const bar = progress(3, 'Manual steps'); + + bar.start(); + bar.advance(); + bar.advance(); + bar.finish(); +} diff --git a/packages/docs/examples/testing.ts b/packages/docs/examples/testing.ts new file mode 100644 index 00000000..9c1fb8c4 --- /dev/null +++ b/packages/docs/examples/testing.ts @@ -0,0 +1,15 @@ +import { Key, createMemoryOutput, createScriptedInput, text, withPromptEnvironment } from '@ollin/tui'; + +export async function testingExample(): Promise { + const output = createMemoryOutput(); + const input = createScriptedInput(['Ada', Key.enter]); + + const name = await withPromptEnvironment({ input, output }, () => + text({ + message: 'Name', + required: true, + }), + ); + + return output.text() + name; +} diff --git a/packages/docs/package.json b/packages/docs/package.json new file mode 100644 index 00000000..4d2e59d2 --- /dev/null +++ b/packages/docs/package.json @@ -0,0 +1,27 @@ +{ + "name": "docs", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "api": "typedoc --options typedoc.json && cp src/api/README.md src/api/index.md", + "build": "pnpm api && vitepress build src", + "dev": "pnpm api && vitepress dev src --host 127.0.0.1", + "preview": "vitepress preview src --host 127.0.0.1", + "test": "vitest run --config vitest.config.ts", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@ollin/tui": "workspace:*", + "@xterm/xterm": "^6.0.0" + }, + "devDependencies": { + "@ollin/tsconfig": "workspace:*", + "@types/node": "^25.6.2", + "typedoc": "^0.28.19", + "typedoc-plugin-markdown": "^4.12.0", + "typescript": "^6.0.3", + "vitepress": "^1.6.4", + "vitest": "^4.1.5" + } +} diff --git a/packages/docs/src/.vitepress/config.ts b/packages/docs/src/.vitepress/config.ts new file mode 100644 index 00000000..a2439b39 --- /dev/null +++ b/packages/docs/src/.vitepress/config.ts @@ -0,0 +1,61 @@ +import { defineConfig } from 'vitepress'; + +export const guideSections = [ + { text: 'Introduction', link: '/guide/introduction' }, + { text: 'Installation', link: '/guide/installation' }, + { text: 'Available Prompts', link: '/guide/available-prompts' }, + { text: 'Text', link: '/guide/text' }, + { text: 'Textarea', link: '/guide/textarea' }, + { text: 'Number', link: '/guide/number' }, + { text: 'Password', link: '/guide/password' }, + { text: 'Confirm', link: '/guide/confirm' }, + { text: 'Select', link: '/guide/select' }, + { text: 'Multi-select', link: '/guide/multiselect' }, + { text: 'Suggest', link: '/guide/suggest' }, + { text: 'Search', link: '/guide/search' }, + { text: 'Multi-search', link: '/guide/multisearch' }, + { text: 'Pause', link: '/guide/pause' }, + { text: 'Autocomplete', link: '/guide/autocomplete' }, + { text: 'Transforming Input Before Validation', link: '/guide/transforms' }, + { text: 'Forms', link: '/guide/forms' }, + { text: 'Informational Messages', link: '/guide/informational-messages' }, + { text: 'Tables', link: '/guide/tables' }, + { text: 'Spin', link: '/guide/spin' }, + { text: 'Progress Bar', link: '/guide/progress' }, + { text: 'Task', link: '/guide/task' }, + { text: 'Stream', link: '/guide/stream' }, + { text: 'Terminal Title', link: '/guide/terminal-title' }, + { text: 'Clearing the Terminal', link: '/guide/clearing-terminal' }, + { text: 'Terminal Considerations', link: '/guide/terminal-considerations' }, + { text: 'Custom Environments', link: '/guide/fallbacks' }, + { text: 'Testing', link: '/guide/testing' }, +] as const; + +export default defineConfig({ + title: '@ollin/tui', + description: 'Documentation for the Ollin TUI prompt toolkit.', + cleanUrls: true, + lastUpdated: true, + themeConfig: { + nav: [ + { text: 'Guide', link: '/guide/introduction' }, + { text: 'API', link: '/api/' }, + ], + search: { + provider: 'local', + }, + sidebar: [ + { + text: 'Guide', + items: [...guideSections], + }, + { + text: 'Reference', + items: [{ text: 'API Reference', link: '/api/' }], + }, + ], + footer: { + message: 'Documentation for the @ollin/tui TypeScript package.', + }, + }, +}); diff --git a/packages/docs/src/.vitepress/theme/components/TerminalOutput.vue b/packages/docs/src/.vitepress/theme/components/TerminalOutput.vue new file mode 100644 index 00000000..c5a26ca9 --- /dev/null +++ b/packages/docs/src/.vitepress/theme/components/TerminalOutput.vue @@ -0,0 +1,229 @@ + + + diff --git a/packages/docs/src/.vitepress/theme/index.ts b/packages/docs/src/.vitepress/theme/index.ts new file mode 100644 index 00000000..a5f805fd --- /dev/null +++ b/packages/docs/src/.vitepress/theme/index.ts @@ -0,0 +1,12 @@ +import DefaultTheme from 'vitepress/theme'; +import TerminalOutput from './components/TerminalOutput.vue'; +import '@xterm/xterm/css/xterm.css'; +import './styles.css'; +import type { Theme } from 'vitepress'; + +export default { + extends: DefaultTheme, + enhanceApp({ app }) { + app.component('TerminalOutput', TerminalOutput); + }, +} satisfies Theme; diff --git a/packages/docs/src/.vitepress/theme/styles.css b/packages/docs/src/.vitepress/theme/styles.css new file mode 100644 index 00000000..cebc25bf --- /dev/null +++ b/packages/docs/src/.vitepress/theme/styles.css @@ -0,0 +1,55 @@ +.terminal-output { + margin: 16px 0; + overflow: hidden; + border: 1px solid var(--vp-c-divider); + border-radius: 8px; + background: #0c0f14; + box-shadow: inset 0 1px 0 rgb(255 255 255 / 4%); +} + +.terminal-output__bar { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-bottom: 1px solid rgb(255 255 255 / 8%); + background: #111827; +} + +.terminal-output__dot { + width: 10px; + height: 10px; + border-radius: 999px; +} + +.terminal-output__dot--danger { + background: #ef4444; +} + +.terminal-output__dot--warning { + background: #facc15; +} + +.terminal-output__dot--success { + background: #22c55e; +} + +.terminal-output__title { + margin-left: 4px; + color: #9ca3af; + font-size: 12px; + line-height: 1; +} + +.terminal-output__viewport { + min-height: 64px; + padding: 12px; +} + +.terminal-output .xterm { + padding: 0; +} + +.terminal-output .xterm-viewport { + overflow: hidden !important; +} diff --git a/packages/docs/src/.vitepress/vue.d.ts b/packages/docs/src/.vitepress/vue.d.ts new file mode 100644 index 00000000..7ff84371 --- /dev/null +++ b/packages/docs/src/.vitepress/vue.d.ts @@ -0,0 +1,9 @@ +declare module '*.vue' { + import type { DefineComponent } from 'vue'; + + const component: DefineComponent; + + export default component; +} + +declare module '*.css'; diff --git a/packages/docs/src/guide/autocomplete.md b/packages/docs/src/guide/autocomplete.md new file mode 100644 index 00000000..54b475df --- /dev/null +++ b/packages/docs/src/guide/autocomplete.md @@ -0,0 +1,51 @@ +# Autocomplete + +The `autocomplete` helper completes the current input from known options. + +```ts +import { autocomplete } from '@ollin/tui'; + +const runtime = await autocomplete({ + message: 'Runtime', + options: ['Node.js', 'Bun', 'Deno'], + default: 'Node.js', +}); +``` + +Use `autocomplete` when users should enter one value and suggestions should guide them toward a known set. + +Use `suggest` instead when arbitrary values are common and suggestions are only hints. + +## Complete Usage + +```ts +import { autocomplete } from '@ollin/tui'; + +const runtime = await autocomplete({ + message: 'Runtime', + options: ['Node.js', 'Bun', 'Deno'], + placeholder: 'Node.js', + default: 'Node.js', + required: true, + info: (value) => (value === 'Bun' ? 'Fast JavaScript runtime.' : null), + validate: (value) => (value === 'Deno' ? 'Deno is not enabled in this project.' : null), +}); + +const packageManager = await autocomplete('Package manager', ['pnpm', 'npm', 'yarn'], 'pnpm'); + +void [runtime, packageManager]; +``` + +## Consumer Call And Output + +```ts +import { autocomplete } from '@ollin/tui'; + +const runtime = await autocomplete({ + message: 'Runtime', + options: ['Node.js', 'Bun', 'Deno'], + default: 'Node.js', +}); +``` + + diff --git a/packages/docs/src/guide/available-prompts.md b/packages/docs/src/guide/available-prompts.md new file mode 100644 index 00000000..5929217b --- /dev/null +++ b/packages/docs/src/guide/available-prompts.md @@ -0,0 +1,54 @@ +# Available Prompts + +`@ollin/tui` provides prompt helpers for text input, choice selection, search flows, confirmation, pausing, and autocomplete. + +| Helper | Purpose | +| --- | --- | +| `text` | Read a single-line string. | +| `textarea` | Read multi-line text. | +| `number` | Read numeric input with validation helpers. | +| `password` | Read masked text input. | +| `confirm` | Ask for a boolean decision. | +| `select` | Choose one item from a list. | +| `multiselect` | Choose multiple items from a list. | +| `suggest` | Offer suggestions while still allowing free input. | +| `search` | Resolve one selected item from filtered options. | +| `multisearch` | Resolve multiple selected items from filtered options. | +| `pause` | Wait for the user to continue. | +| `autocomplete` | Complete an input from a known option list. | + +Object options use `message` as the visible prompt text. Label-first overloads are available for many helpers when a terse call site is useful. + +## Complete Prompt Tour + +```ts +import { autocomplete, confirm, multiselect, multisearch, number, password, pause, search, select, suggest, text, textarea } from '@ollin/tui'; + +const name = await text({ message: 'Name', required: true }); +const description = await textarea({ message: 'Description', rows: 4 }); +const copies = await number({ message: 'Copies', default: 1, integer: true, min: 1 }); +const secret = await password({ message: 'Token', required: true }); +const accepted = await confirm({ message: 'Continue?', default: true }); +const role = await select({ message: 'Role', options: ['Member', 'Owner'], default: 'Member' }); +const permissions = await multiselect({ message: 'Permissions', options: ['Read', 'Write', 'Delete'], default: ['Read'] }); +const author = await suggest({ message: 'Author', options: ['Ada', 'Grace', 'Lin'] }); +const runtime = await autocomplete({ message: 'Runtime', options: ['Node.js', 'Bun', 'Deno'] }); +const user = await search({ message: 'User', options: ['Ada Lovelace', 'Grace Hopper'], required: true }); +const reviewers = await multisearch({ message: 'Reviewers', options: ['Ada Lovelace', 'Grace Hopper'], required: true }); + +await pause('Review your selections, then press enter.'); + +void [name, description, copies, secret, accepted, role, permissions, author, runtime, user, reviewers]; +``` + +## Consumer Call And Output + +```ts +import { confirm, select, text } from '@ollin/tui'; + +const name = await text({ message: 'Name', required: true }); +const template = await select({ message: 'Template', options: ['cli', 'library', 'docs'] }); +const install = await confirm({ message: 'Install dependencies?', default: true }); +``` + + diff --git a/packages/docs/src/guide/clearing-terminal.md b/packages/docs/src/guide/clearing-terminal.md new file mode 100644 index 00000000..9ca67804 --- /dev/null +++ b/packages/docs/src/guide/clearing-terminal.md @@ -0,0 +1,51 @@ +# Clearing the Terminal + +Use `clear` to clear the configured prompt output. + +```ts +import { clear } from '@ollin/tui'; + +clear(); +``` + +The package also exports lower-level helpers such as `clearTerminal`, `eraseLine`, and `erasePreviousLines` for direct terminal behaviour. + +```ts +import { clearTerminal } from '@ollin/tui'; + +clearTerminal(); +``` + +## When To Use It + +Use `clear` sparingly for full-screen command flows where previous output would distract from the current step. For status renderers, prefer `spin`, `progress`, `task`, or `stream`; they handle their own redraw cleanup. + +## Complete Usage + +```ts +import { clear, clearTerminal, eraseLine, erasePreviousLines, form } from '@ollin/tui'; + +clear(); +clearTerminal(); +eraseLine(); +erasePreviousLines(2); + +const responses = await form().clear('clearStep').text({ message: 'Continue with command?', default: 'yes' }, 'continue').submit(); + +void responses; +``` + +## Behaviour + +`clear` and `clearTerminal` write terminal control sequences. `eraseLine` clears the current line, and `erasePreviousLines` removes a known number of previously rendered lines. + +## Consumer Call And Output + +```ts +import { clear, info } from '@ollin/tui'; + +clear(); +info('Fresh screen ready.'); +``` + + diff --git a/packages/docs/src/guide/confirm.md b/packages/docs/src/guide/confirm.md new file mode 100644 index 00000000..1ecf8512 --- /dev/null +++ b/packages/docs/src/guide/confirm.md @@ -0,0 +1,50 @@ +# Confirm + +The `confirm` helper asks for a boolean response. + +```ts +import { confirm } from '@ollin/tui'; + +const accepted = await confirm({ + message: 'Do you accept the terms?', + default: false, + yes: 'I accept', + no: 'I decline', + required: 'Accept the terms to continue.', +}); +``` + +`confirm` returns `true` or `false`. When `required` is enabled, a negative answer is rejected with the default or custom message. + +## Complete Usage + +```ts +import { confirm } from '@ollin/tui'; + +const accepted = await confirm({ + message: 'Do you accept the terms?', + default: false, + yes: 'I accept', + no: 'I decline', + hint: 'Required before the command can continue.', + required: 'Accept the terms to continue.', + transform: (value) => value, +}); + +const overwrite = await confirm('Overwrite existing files?', false, 'Overwrite', 'Cancel'); + +void [accepted, overwrite]; +``` + +## Consumer Call And Output + +```ts +import { confirm } from '@ollin/tui'; + +const publish = await confirm({ + message: 'Publish release?', + default: false, +}); +``` + + diff --git a/packages/docs/src/guide/fallbacks.md b/packages/docs/src/guide/fallbacks.md new file mode 100644 index 00000000..ff932c2e --- /dev/null +++ b/packages/docs/src/guide/fallbacks.md @@ -0,0 +1,61 @@ +# Custom Environments + +`@ollin/tui` uses `PromptEnvironment` as the boundary for input, output, and interactivity. + +Use `withPromptEnvironment` when a command needs custom streams or deterministic input. + +```ts +import { createMemoryOutput, createScriptedInput, text, withPromptEnvironment } from '@ollin/tui'; + +const output = createMemoryOutput(); +const input = createScriptedInput(['Ada']); + +const name = await withPromptEnvironment({ input, output }, () => + text({ + message: 'Name', + required: true, + }), +); +``` + +Keep environment-specific behaviour near the command entrypoint so prompt code can stay portable. + +## Complete Usage + +```ts +import { createMemoryOutput, createScriptedInput, select, text, withPromptEnvironment } from '@ollin/tui'; + +const output = createMemoryOutput(); +const input = createScriptedInput(['Ada', 'Member']); + +const result = await withPromptEnvironment({ input, interactive: false, output }, async () => { + const name = await text({ + message: 'Name', + default: 'Ada', + required: true, + }); + + const role = await select({ + message: 'Role', + options: ['Member', 'Owner'], + default: 'Member', + }); + + return { name, role }; +}); + +void [result, output.text()]; +``` + +## Consumer Call And Output + +```ts +import { createMemoryOutput, createScriptedInput, text, withPromptEnvironment } from '@ollin/tui'; + +const output = createMemoryOutput(); +const input = createScriptedInput(['Ada']); + +const name = await withPromptEnvironment({ input, output }, () => text({ message: 'Name' })); +``` + + diff --git a/packages/docs/src/guide/forms.md b/packages/docs/src/guide/forms.md new file mode 100644 index 00000000..174dbc6e --- /dev/null +++ b/packages/docs/src/guide/forms.md @@ -0,0 +1,83 @@ +# Forms + +The `form` helper composes prompt, output, and status steps into one submission flow. + +```ts +import { form } from '@ollin/tui'; + +const responses = await form() + .intro('Create an account') + .text({ message: 'Name', required: true }, 'name') + .confirm({ message: 'Enable notifications?', default: true }, 'notifications') + .outro('Account configured') + .submit(); +``` + +Named steps are written into the response object. The builder also supports conditional steps with `addIf`. + +```ts +import { form } from '@ollin/tui'; + +await form() + .confirm({ message: 'Enable notifications?', default: true }, 'notifications') + .addIf( + (responses) => responses.notifications === true, + async () => 'email', + 'notificationChannel', + ) + .submit(); +``` + +You can also pass an async callback to `form` and submit from inside the callback. + +## Complete Usage + +```ts +import { form } from '@ollin/tui'; + +const responses = await form(async (builder) => + builder + .intro('Create a project') + .text({ message: 'Project name', required: true }, 'project') + .select( + { + message: 'Visibility', + options: ['Private', 'Public'], + default: 'Private', + }, + 'visibility', + ) + .multiselect( + { + message: 'Features', + options: ['docs', 'tests', 'release'], + default: ['docs'], + required: true, + }, + 'features', + ) + .addIf( + (values) => Array.isArray(values.features) && values.features.includes('release'), + async () => 'manual-approval', + 'releasePolicy', + ) + .table(['Field', 'Value'], [['Status', 'Configured']], 'summary') + .outro('Project configured') + .submit(), +); + +void responses; +``` + +## Consumer Call And Output + +```ts +import { form } from '@ollin/tui'; + +const responses = await form() + .text({ message: 'Project name', required: true }, 'project') + .select({ message: 'Visibility', options: ['Private', 'Public'], default: 'Private' }, 'visibility') + .submit(); +``` + + diff --git a/packages/docs/src/guide/informational-messages.md b/packages/docs/src/guide/informational-messages.md new file mode 100644 index 00000000..3e73bf2d --- /dev/null +++ b/packages/docs/src/guide/informational-messages.md @@ -0,0 +1,50 @@ +# Informational Messages + +Use informational helpers for durable terminal messages that are not prompts. + +```ts +import { alert, error, info, intro, note, outro, warning } from '@ollin/tui'; + +intro('Deployment'); +info('Preparing release artefacts.'); +warning('Two optional checks were skipped.'); +error('A required check failed.'); +alert('Manual review required.'); +note('Use output helpers for durable terminal messages.', 'info'); +outro('Done'); +``` + +These helpers write to the configured prompt output. In tests, configure a memory output and assert against the captured text. + +## Complete Usage + +```ts +import { alert, error, form, info, intro, note, outro, warning } from '@ollin/tui'; + +intro('Deployment'); +info('Preparing release artefacts.'); +warning('Two optional checks were skipped.'); +error('A required check failed.'); +alert('Manual review required.'); +note('Use notes for longer terminal messages.', 'info'); +outro('Done'); + +await form() + .info('Fetching workspace metadata') + .warning('Using local defaults') + .note('Review the generated files before publishing.', 'info') + .submit(); +``` + +## Consumer Call And Output + +```ts +import { info, intro, outro, warning } from '@ollin/tui'; + +intro('Release'); +info('Preparing release artefacts.'); +warning('Using local defaults.'); +outro('Ready'); +``` + + diff --git a/packages/docs/src/guide/installation.md b/packages/docs/src/guide/installation.md new file mode 100644 index 00000000..664f391f --- /dev/null +++ b/packages/docs/src/guide/installation.md @@ -0,0 +1,73 @@ +# Installation + +Install the package from the workspace or package registry that contains `@ollin/tui`. + +```sh +pnpm add @ollin/tui +``` + +Then import helpers from the public package entrypoint. + +```ts +import { text } from '@ollin/tui'; + +const name = await text({ + message: 'What is your name?', + required: true, +}); +``` + +The package is ESM-first. The workspace acceptance tests verify that the built root entrypoint can be consumed through ESM package resolution. + +## Local Workspace + +Inside this repository, `@ollin/tui` is a workspace package. The docs package depends on it with `workspace:*`, so examples compile against the package source. + +## Common Imports + +Import only the helpers your command needs. + +```ts +import { + confirm, + createMemoryOutput, + createScriptedInput, + form, + multiselect, + progress, + search, + select, + spin, + table, + task, + text, + withPromptEnvironment, +} from '@ollin/tui'; + +const project = await text({ message: 'Project name', required: true }); +const runtime = await select({ message: 'Runtime', options: ['Node.js', 'Bun', 'Deno'] }); +const features = await multiselect({ message: 'Features', options: ['docs', 'tests', 'release'], required: true }); + +if (await confirm({ message: 'Create project?', default: true })) { + await spin('Scaffolding project', async () => project); + await progress('Enabling features', features, async (feature) => String(feature)); + await task('Writing summary', async (logger) => logger.success(`Created ${project} for ${runtime}`)); +} + +table(['Project', 'Runtime'], [[project, runtime]]); + +const output = createMemoryOutput(); +const input = createScriptedInput(['Ada']); + +await withPromptEnvironment({ input, output }, () => search({ message: 'Owner', options: ['Ada', 'Grace'] })); +``` + +## Consumer Call And Output + +```ts +import { text } from '@ollin/tui'; + +const project = await text({ message: 'Project name', required: true }); +``` + + diff --git a/packages/docs/src/guide/introduction.md b/packages/docs/src/guide/introduction.md new file mode 100644 index 00000000..7d3b12f0 --- /dev/null +++ b/packages/docs/src/guide/introduction.md @@ -0,0 +1,72 @@ +# 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 documentation follows the public package surface. Use the guide for task-oriented examples and the API reference for generated type and overload details. + +## Complete CLI Flow + +This is a complete workflow using prompts, forms, output, and status helpers from `@ollin/tui`. + +```ts +import { form, progress, table, task } from '@ollin/tui'; + +const responses = await form() + .intro('Create a release') + .text({ message: 'Release name', required: true }, 'name') + .select( + { + message: 'Channel', + options: ['stable', 'beta', 'nightly'], + default: 'stable', + }, + 'channel', + ) + .multiselect( + { + message: 'Checks to run', + options: ['typecheck', 'build', 'smoke'], + default: ['typecheck', 'build'], + required: true, + }, + 'checks', + ) + .confirm({ message: 'Publish after checks pass?', default: false }, 'publish') + .submit(); + +table(['Field', 'Value'], [ + ['Name', String(responses.name)], + ['Channel', String(responses.channel)], + ['Publish', String(responses.publish)], +]); + +await progress('Running checks', responses.checks as string[], async (check) => String(check).toUpperCase()); + +await task('Finalising release', async (logger) => { + logger.info('Writing release metadata'); + logger.success('Release workflow complete'); +}); +``` + +The same helpers can be used one at a time, chained in `form()`, or wrapped with a custom `PromptEnvironment` for tests and non-standard runtimes. + +## Consumer Call And Output + +```ts +import { confirm, text } from '@ollin/tui'; + +const release = await text({ message: 'Release name', required: true }); +const publish = await confirm({ message: 'Publish now?', default: false }); +``` + + diff --git a/packages/docs/src/guide/multisearch.md b/packages/docs/src/guide/multisearch.md new file mode 100644 index 00000000..c2e796ec --- /dev/null +++ b/packages/docs/src/guide/multisearch.md @@ -0,0 +1,63 @@ +# Multi-search + +The `multisearch` helper combines search input with multiple selected results. + +```ts +import { multisearch } from '@ollin/tui'; + +const userIds = await multisearch({ + message: 'Search for users receiving the email.', + placeholder: 'Ada', + options: (query) => ({ + 1: 'Lin Chen', + 2: 'Ada Lovelace', + 3: 'Grace Hopper', + }), + required: 'Select at least one user.', +}); +``` + +Use this helper when the option set is too large for `multiselect` and should be narrowed by typed input. + +## Complete Usage + +```ts +import { multisearch } from '@ollin/tui'; + +const users = { + 1: 'Lin Chen', + 2: 'Ada Lovelace', + 3: 'Grace Hopper', +}; + +const reviewers = await multisearch({ + message: 'Search for reviewers.', + placeholder: 'Ada', + options: (query) => Object.fromEntries(Object.entries(users).filter(([, name]) => name.toLowerCase().includes(query.toLowerCase()))), + scroll: 10, + required: 'Select at least one reviewer.', + validate: (values) => (values.length > 3 ? 'Select no more than three reviewers.' : null), +}); + +const labels = await multisearch({ + message: 'Labels', + options: ['bug', 'docs', 'feature'], + default: ['docs'], +}); + +void [reviewers, labels]; +``` + +## Consumer Call And Output + +```ts +import { multisearch } from '@ollin/tui'; + +const reviewers = await multisearch({ + message: 'Reviewers', + options: ['Ada Lovelace', 'Grace Hopper', 'Lin Chen'], + required: true, +}); +``` + + diff --git a/packages/docs/src/guide/multiselect.md b/packages/docs/src/guide/multiselect.md new file mode 100644 index 00000000..6801cc26 --- /dev/null +++ b/packages/docs/src/guide/multiselect.md @@ -0,0 +1,62 @@ +# Multi-select + +The `multiselect` helper asks the user to choose multiple values. + +```ts +import { multiselect } from '@ollin/tui'; + +const permissions = await multiselect({ + message: 'What permissions should be assigned?', + options: { + read: 'Read', + create: 'Create', + update: 'Update', + delete: 'Delete', + }, + default: ['read'], + required: 'Select at least one permission.', + validate: (values) => (values.includes('read') ? null : 'Every user needs read access.'), +}); +``` + +By default, multiple-selection prompts can return an empty array. Use `required` to require at least one selected item. + +## Complete Usage + +```ts +import { multiselect } from '@ollin/tui'; + +const permissions = await multiselect({ + message: 'What permissions should be assigned?', + options: { + read: 'Read', + create: 'Create', + update: 'Update', + delete: 'Delete', + }, + default: ['read'], + scroll: 5, + required: 'Select at least one permission.', + info: (value) => (value === 'delete' ? 'Allows permanent deletion.' : null), + validate: (values) => (values.includes('read') ? null : 'Every user needs read access.'), +}); + +const checks = await multiselect('Checks to run', ['typecheck', 'build', 'smoke'], ['typecheck']); + +void [permissions, checks]; +``` + +## Consumer Call And Output + +```ts +import { multiselect } from '@ollin/tui'; + +const checks = await multiselect({ + message: 'Checks to run', + options: ['typecheck', 'build', 'smoke'], + default: ['typecheck', 'build'], + required: true, +}); +``` + + diff --git a/packages/docs/src/guide/number.md b/packages/docs/src/guide/number.md new file mode 100644 index 00000000..b197d9a4 --- /dev/null +++ b/packages/docs/src/guide/number.md @@ -0,0 +1,64 @@ +# Number + +The `number` helper reads numeric input and supports numeric constraints. + +```ts +import { number } from '@ollin/tui'; + +const copies = await number({ + message: 'How many copies?', + default: 1, + integer: true, + min: 1, + max: 100, +}); +``` + +## Validation + +Use `min`, `max`, and `integer` for common numeric rules. Use `validate` when the rule depends on application state. + +```ts +import { number } from '@ollin/tui'; + +const amount = await number({ + message: 'How many seats?', + validate: (value) => (Number(value) < 1 ? 'Choose at least one seat.' : null), +}); +``` + +## Complete Usage + +```ts +import { number } from '@ollin/tui'; + +const seats = await number({ + message: 'How many seats?', + placeholder: '5', + default: 1, + integer: true, + min: 1, + max: 100, + hint: 'Seat count controls billing.', + validate: (value) => (Number(value) % 1 === 0 ? null : 'Use a whole number.'), +}); + +const retries = await number('Retry attempts', '3', 3, true); + +void [seats, retries]; +``` + +## Consumer Call And Output + +```ts +import { number } from '@ollin/tui'; + +const seats = await number({ + message: 'Seats', + default: 3, + min: 1, + integer: true, +}); +``` + + diff --git a/packages/docs/src/guide/password.md b/packages/docs/src/guide/password.md new file mode 100644 index 00000000..4cb8bf94 --- /dev/null +++ b/packages/docs/src/guide/password.md @@ -0,0 +1,56 @@ +# Password + +The `password` helper reads masked text input for sensitive values. + +```ts +import { password } from '@ollin/tui'; + +const token = await password({ + message: 'Enter your API token.', + hint: 'Input is masked while typing.', + required: true, +}); +``` + +`password` shares the text prompt validation model. Use a typed callback for length, format, or application-specific checks. + +```ts +import { password } from '@ollin/tui'; + +const secret = await password({ + message: 'Secret', + validate: (value) => (value.length < 8 ? 'Use at least eight characters.' : null), +}); +``` + +## Complete Usage + +```ts +import { password } from '@ollin/tui'; + +const token = await password({ + message: 'API token', + placeholder: 'token', + hint: 'The token is not echoed to the terminal.', + required: 'An API token is required.', + validate: (value) => (value.length < 16 ? 'Tokens must be at least 16 characters.' : null), + transform: (value) => value.trim(), +}); + +const confirmation = await password('Confirm API token', 'token', true); + +void [token, confirmation]; +``` + +## Consumer Call And Output + +```ts +import { password } from '@ollin/tui'; + +const token = await password({ + message: 'API token', + required: true, +}); +``` + + diff --git a/packages/docs/src/guide/pause.md b/packages/docs/src/guide/pause.md new file mode 100644 index 00000000..53d602e4 --- /dev/null +++ b/packages/docs/src/guide/pause.md @@ -0,0 +1,49 @@ +# Pause + +The `pause` helper waits for the user to continue. + +```ts +import { pause } from '@ollin/tui'; + +await pause('Press enter to continue.'); +``` + +Use `pause` between groups of prompts or before a destructive step that should be reviewed by the user. + +`pause` is also available on `form()`. + +```ts +import { form } from '@ollin/tui'; + +await form().pause('Review the summary before continuing.').submit(); +``` + +## Return Value + +`pause` resolves to `true` after a user continues in an interactive environment. In a non-interactive environment it resolves to `false`, so command code can skip interactive-only review screens. + +## Complete Usage + +```ts +import { form, pause } from '@ollin/tui'; + +const continued = await pause('Press enter to continue.'); + +const responses = await form() + .text({ message: 'Release name', required: true }, 'name') + .pause('Review the release name before continuing.') + .confirm({ message: 'Publish?', default: false }, 'publish') + .submit(); + +void [continued, responses]; +``` + +## Consumer Call And Output + +```ts +import { pause } from '@ollin/tui'; + +await pause('Review the summary, then press enter.'); +``` + + diff --git a/packages/docs/src/guide/progress.md b/packages/docs/src/guide/progress.md new file mode 100644 index 00000000..43341b65 --- /dev/null +++ b/packages/docs/src/guide/progress.md @@ -0,0 +1,62 @@ +# Progress Bar + +The `progress` helper supports iterable work and manual progress control. + +```ts +import { progress } from '@ollin/tui'; + +await progress('Processing files', ['one.ts', 'two.ts'], async (file) => file.toUpperCase()); +``` + +For manual control, create a `Progress` instance and call `start`, `advance`, and `finish`. + +```ts +import { progress } from '@ollin/tui'; + +const bar = progress(3, 'Manual steps'); + +bar.start(); +bar.advance(); +bar.finish(); +``` + +## Complete Usage + +```ts +import { form, progress } from '@ollin/tui'; + +const files = await progress('Processing files', ['one.ts', 'two.ts'], async (file, bar) => { + bar.hint(`Current file: ${String(file)}`); + + return String(file).toUpperCase(); +}); + +const bar = progress(3, 'Manual steps'); + +bar.start(); +bar.advance(); +bar.label('Almost done'); +bar.advance(2); +bar.finish(); + +const responses = await form().progress('Form progress', ['docs', 'api'], async (step) => String(step), '', 'steps').submit(); + +void [files, responses]; +``` + +## Consumer Call And Output + +```ts +import { progress } from '@ollin/tui'; + +await progress('Building packages', ['tui', 'docs'], async (pkg) => String(pkg)); +``` + + diff --git a/packages/docs/src/guide/search.md b/packages/docs/src/guide/search.md new file mode 100644 index 00000000..9360c246 --- /dev/null +++ b/packages/docs/src/guide/search.md @@ -0,0 +1,61 @@ +# Search + +The `search` helper filters options as the user types and returns one selected value. + +```ts +import { search } from '@ollin/tui'; + +const userId = await search({ + message: 'Search for the user receiving the email.', + placeholder: 'Ada', + options: (query) => ({ + 1: 'Ada Lovelace', + 2: 'Ada Lovelace', + 3: 'Grace Hopper', + }), + required: true, + info: (value) => `User id ${value}`, +}); +``` + +When `options` returns an object, the selected key is returned. When it returns an array, the selected value is returned. + +## Complete Usage + +```ts +import { search } from '@ollin/tui'; + +const users = new Map([ + [1, 'Lin Chen'], + [2, 'Ada Lovelace'], + [3, 'Grace Hopper'], +]); + +const userId = await search({ + message: 'Search for the user receiving the email.', + placeholder: 'Ada', + options: (query) => Object.fromEntries([...users].filter(([, name]) => name.toLowerCase().includes(query.toLowerCase()))), + scroll: 10, + required: true, + info: (value) => `User id ${value}`, + validate: (value) => (Number(value) === 1 ? 'Lin is unavailable.' : null), +}); + +const userName = await search('Pick a reviewer', ['Ada Lovelace', 'Grace Hopper']); + +void [userId, userName]; +``` + +## Consumer Call And Output + +```ts +import { search } from '@ollin/tui'; + +const reviewer = await search({ + message: 'Reviewer', + options: ['Ada Lovelace', 'Grace Hopper', 'Lin Chen'], + required: true, +}); +``` + + diff --git a/packages/docs/src/guide/select.md b/packages/docs/src/guide/select.md new file mode 100644 index 00000000..204da382 --- /dev/null +++ b/packages/docs/src/guide/select.md @@ -0,0 +1,72 @@ +# Select + +The `select` helper asks the user to choose one item. + +```ts +import { select } from '@ollin/tui'; + +const role = await select({ + message: 'What role should the user have?', + options: { + member: 'Member', + contributor: 'Contributor', + owner: 'Owner', + }, + default: 'member', + info: (value) => (value === 'owner' ? 'Full access to the project.' : null), +}); +``` + +Associative options return the selected key. Array options return the selected value. + +## Validation + +`select` always returns one value. Use `validate` if a visible option may be unavailable. + +```ts +import { select } from '@ollin/tui'; + +const role = await select({ + message: 'Role', + options: ['Member', 'Owner'], + validate: (value) => (value === 'Owner' ? 'An owner already exists.' : null), +}); +``` + +## Complete Usage + +```ts +import { select } from '@ollin/tui'; + +const role = await select({ + message: 'What role should the user have?', + options: { + member: 'Member', + contributor: 'Contributor', + owner: 'Owner', + }, + default: 'member', + scroll: 5, + hint: 'The role can be changed later.', + info: (value) => (value === 'owner' ? 'Full access to all resources.' : null), + validate: (value) => (value === 'owner' ? 'Owner access requires a separate approval.' : null), +}); + +const channel = await select('Release channel', ['stable', 'beta', 'nightly'], 'stable'); + +void [role, channel]; +``` + +## Consumer Call And Output + +```ts +import { select } from '@ollin/tui'; + +const channel = await select({ + message: 'Release channel', + options: ['stable', 'beta', 'nightly'], + default: 'stable', +}); +``` + + diff --git a/packages/docs/src/guide/spin.md b/packages/docs/src/guide/spin.md new file mode 100644 index 00000000..786805b0 --- /dev/null +++ b/packages/docs/src/guide/spin.md @@ -0,0 +1,55 @@ +# Spin + +The `spin` helper runs async work while rendering a spinner. + +```ts +import { spin } from '@ollin/tui'; + +const result = await spin('Installing dependencies', async () => 'installed'); +``` + +The callback result is returned after the spinner is cleared and terminal cursor state is restored. + +## When To Use It + +Use `spin` for one async operation where users only need to know that work is in progress. Use `progress` when the work has countable steps, and use `task` when the work should emit log lines. + +## Complete Usage + +```ts +import { form, spin } from '@ollin/tui'; + +const result = await spin('Installing dependencies', async () => { + return 'installed'; +}); + +const responses = await form() + .spin('Loading workspace', async () => 'loaded', 'workspace') + .text({ message: 'Next command', default: 'pnpm build' }, 'command') + .submit(); + +void [result, responses]; +``` + +## Behaviour + +The callback return value is preserved, so `spin` can wrap existing application functions without changing their result type. If the callback throws, the error is rethrown after terminal cleanup. + +## Consumer Call And Output + +```ts +import { spin } from '@ollin/tui'; + +const result = await spin('Installing dependencies', async () => 'installed'); +``` + + diff --git a/packages/docs/src/guide/stream.md b/packages/docs/src/guide/stream.md new file mode 100644 index 00000000..c354f11e --- /dev/null +++ b/packages/docs/src/guide/stream.md @@ -0,0 +1,55 @@ +# Stream + +The `stream` helper renders streaming text from an iterable or async iterable. + +```ts +import { stream } from '@ollin/tui'; + +await stream(['Preparing release\n', 'Release ready\n']); +``` + +Calling `stream()` without a source returns a `Stream` instance for manual writes. + +```ts +import { stream } from '@ollin/tui'; + +const output = stream(); + +output.write('Preparing release\n'); +output.close(); +``` + +## Complete Usage + +```ts +import { form, stream } from '@ollin/tui'; + +await stream(['Preparing release\n', 'Release ready\n']); + +const output = stream(); + +output.write('Writing files\n'); +output.append('Done\n'); +output.close(); + +const responses = await form().stream(['Line one\n', 'Line two\n'], 'streamedOutput').submit(); + +void responses; +``` + +## Consumer Call And Output + +```ts +import { stream } from '@ollin/tui'; + +await stream(['Preparing release\n', 'Release ready\n']); +``` + + diff --git a/packages/docs/src/guide/suggest.md b/packages/docs/src/guide/suggest.md new file mode 100644 index 00000000..f8a5800f --- /dev/null +++ b/packages/docs/src/guide/suggest.md @@ -0,0 +1,54 @@ +# Suggest + +The `suggest` helper offers completions while still allowing any typed value. + +```ts +import { suggest } from '@ollin/tui'; + +const author = await suggest({ + message: 'Author name', + options: (query) => ['Ada', 'Grace', 'Lin'].filter((name) => name.toLowerCase().includes(query.toLowerCase())), + placeholder: 'Ada', +}); +``` + +`options` can be an array or a callback that receives the current query. + +Use `required`, `validate`, and `transform` the same way you would on `text`. + +## Complete Usage + +```ts +import { suggest } from '@ollin/tui'; + +const names = ['Ada', 'Grace', 'Lin', 'Katherine']; + +const author = await suggest({ + message: 'Author name', + options: (query) => names.filter((name) => name.toLowerCase().includes(query.toLowerCase())), + placeholder: 'Ada', + default: 'Ada', + scroll: 5, + required: true, + info: (value) => (value === 'Ada' ? 'Default maintainer.' : null), + validate: (value) => (value.length < 2 ? 'Enter at least two characters.' : null), +}); + +const reviewer = await suggest('Reviewer', names, 'Grace'); + +void [author, reviewer]; +``` + +## Consumer Call And Output + +```ts +import { suggest } from '@ollin/tui'; + +const maintainer = await suggest({ + message: 'Maintainer', + options: ['Ada', 'Grace', 'Lin'], + default: 'Ada', +}); +``` + + diff --git a/packages/docs/src/guide/tables.md b/packages/docs/src/guide/tables.md new file mode 100644 index 00000000..3d4ea08d --- /dev/null +++ b/packages/docs/src/guide/tables.md @@ -0,0 +1,87 @@ +# Tables + +Use `table` or `dataTable` to render tabular output. + +```ts +import { dataTable, table } from '@ollin/tui'; + +table(['Name', 'Runtime'], [ + ['Prompts', 'TypeScript'], + ['Renderer', 'OpenTUI'], +]); + +dataTable({ + headers: ['Name', 'Role'], + rows: [ + ['Ada', 'Admin'], + ['Grace', 'Operator'], + ], +}); +``` + +`dataTable` is a display alias for `table`. For an interactive selectable table, use `datatable`. + +```ts +import { datatable } from '@ollin/tui'; + +const selected = await datatable({ + message: 'Select a user', + headers: ['Name', 'Role'], + rows: [ + ['Ada', 'Admin'], + ['Grace', 'Operator'], + ], + required: true, +}); +``` + +## Complete Usage + +```ts +import { dataTable, datatable, form, grid, table } from '@ollin/tui'; + +table({ + headers: ['Package', 'Purpose'], + rows: [ + { Package: '@ollin/tui', Purpose: 'Runtime prompts' }, + { Package: 'docs', Purpose: 'Documentation site' }, + ], +}); + +dataTable(['Helper', 'Kind'], [ + ['text', 'prompt'], + ['task', 'status'], +]); + +grid(['text', 'select', 'search', 'form', 'task', 'stream'], 60); + +const selected = await datatable({ + message: 'Select a package', + headers: ['Package', 'Purpose'], + rows: [ + ['@ollin/tui', 'Runtime prompts'], + ['docs', 'Documentation site'], + ], + required: true, +}); + +const responses = await form() + .table(['Step', 'State'], [['Docs', 'Ready']], 'table') + .datatable({ message: 'Choose action', rows: [['Build'], ['Preview']], required: true }, 'action') + .submit(); + +void [selected, responses]; +``` + +## Consumer Call And Output + +```ts +import { table } from '@ollin/tui'; + +table(['Package', 'Purpose'], [ + ['@ollin/tui', 'Prompts'], + ['docs', 'Documentation'], +]); +``` + + diff --git a/packages/docs/src/guide/task.md b/packages/docs/src/guide/task.md new file mode 100644 index 00000000..4d53f390 --- /dev/null +++ b/packages/docs/src/guide/task.md @@ -0,0 +1,54 @@ +# Task + +The `task` helper runs work with structured task logging. + +```ts +import { task } from '@ollin/tui'; + +const result = await task('Publishing', async (logger) => { + logger.info('Uploading assets'); + logger.success('Assets uploaded'); + + return 'published'; +}); +``` + +Use `logger.info`, `logger.line`, `logger.success`, `logger.warning`, and `logger.error` to emit task output while the task runs. + +## Complete Usage + +```ts +import { form, task } from '@ollin/tui'; + +const result = await task('Publishing', async (logger) => { + logger.info('Uploading assets'); + logger.line('Writing manifest'); + logger.success('Assets uploaded'); + + return 'published'; +}); + +const responses = await form() + .task('Checking package', async (logger) => { + logger.info('Reading package.json'); + logger.success('Package looks valid'); + + return true; + }, 10, true, 'metadata', 'packageCheck') + .submit(); + +void [result, responses]; +``` + +## Consumer Call And Output + +```ts +import { task } from '@ollin/tui'; + +await task('Publishing package', async (logger) => { + logger.info('Uploading files'); + logger.success('Published'); +}); +``` + + diff --git a/packages/docs/src/guide/terminal-considerations.md b/packages/docs/src/guide/terminal-considerations.md new file mode 100644 index 00000000..5bf6576a --- /dev/null +++ b/packages/docs/src/guide/terminal-considerations.md @@ -0,0 +1,56 @@ +# Terminal Considerations + +`@ollin/tui` represents terminal IO through `PromptEnvironment`. The default environment reads from the process input and writes to process output. Tests and non-standard runtimes can replace those streams. + +```ts +import { promptEnvironment, terminalSize } from '@ollin/tui'; + +const environment = promptEnvironment(); +const size = terminalSize(); + +void [environment, size]; +``` + +Terminal helpers restore cursor state around status renderers such as `spin`, `progress`, `task`, and `stream`. + +## Prompt Environment + +`PromptEnvironment` is the main portability boundary. It allows application code to use the same `text`, `select`, `task`, and output helpers in process terminals, scripted tests, or custom runtimes. + +## Complete Usage + +```ts +import { createMemoryOutput, createScriptedInput, promptEnvironment, terminalSize, text, withPromptEnvironment } from '@ollin/tui'; + +const current = promptEnvironment(); +const size = terminalSize(); + +const output = createMemoryOutput(); +const input = createScriptedInput(['Ada']); + +const name = await withPromptEnvironment({ input, interactive: false, output }, () => + text({ + message: 'Name', + default: 'Ada', + }), +); + +void [current, size, name, output.text()]; +``` + +## Practical Guidance + +Keep process-specific assumptions near your application entrypoint. Library code should accept dependencies or run under `withPromptEnvironment` when it needs deterministic input/output behaviour. + +## Consumer Call And Output + +```ts +import { createMemoryOutput, createScriptedInput, text, withPromptEnvironment } from '@ollin/tui'; + +const output = createMemoryOutput(); +const input = createScriptedInput(['Ada']); + +const name = await withPromptEnvironment({ input, output }, () => text({ message: 'Name' })); +``` + + diff --git a/packages/docs/src/guide/terminal-title.md b/packages/docs/src/guide/terminal-title.md new file mode 100644 index 00000000..3753952f --- /dev/null +++ b/packages/docs/src/guide/terminal-title.md @@ -0,0 +1,48 @@ +# Terminal Title + +Use `title` to set the terminal title through the configured prompt output. + +```ts +import { title } from '@ollin/tui'; + +title('Ollin TUI'); +``` + +The lower-level terminal helper `setTerminalTitle` is also exported for direct terminal control. + +```ts +import { setTerminalTitle } from '@ollin/tui'; + +setTerminalTitle('Ollin TUI'); +``` + +## When To Use It + +Use `title` when you want the same output boundary as the rest of the prompt helpers. Use `setTerminalTitle` when lower-level terminal control is more appropriate. + +## Complete Usage + +```ts +import { form, setTerminalTitle, title } from '@ollin/tui'; + +title('Ollin TUI'); +setTerminalTitle('Ollin TUI: Build'); + +const responses = await form().title('Ollin TUI: Form').text({ message: 'Command', default: 'build' }, 'command').submit(); + +void responses; +``` + +## Behaviour + +Terminal title support depends on the terminal emulator. Terminals that ignore the title escape sequence do not affect the rest of the command. + +## Consumer Call And Output + +```ts +import { title } from '@ollin/tui'; + +title('Ollin TUI: Release'); +``` + + diff --git a/packages/docs/src/guide/testing.md b/packages/docs/src/guide/testing.md new file mode 100644 index 00000000..76a86ed2 --- /dev/null +++ b/packages/docs/src/guide/testing.md @@ -0,0 +1,68 @@ +# Testing + +Use prompt environment helpers to exercise prompt flows without process input. + +```ts +import { Key, createMemoryOutput, createScriptedInput, text, withPromptEnvironment } from '@ollin/tui'; + +const output = createMemoryOutput(); +const input = createScriptedInput(['Ada', Key.enter]); + +const name = await withPromptEnvironment({ input, output }, () => + text({ + message: 'Name', + required: true, + }), +); + +const rendered = output.text(); +``` + +This uses the same exported helpers as application code. The scripted input supplies keys and lines, while memory output captures rendered terminal text. + +## Complete Usage + +```ts +import { Key, confirm, createMemoryOutput, createScriptedInput, select, text, withPromptEnvironment } from '@ollin/tui'; + +const output = createMemoryOutput(); +const input = createScriptedInput(['Ada', Key.enter, Key.down, Key.enter, 'y']); + +const result = await withPromptEnvironment({ input, output }, async () => { + const name = await text({ + message: 'Name', + required: true, + }); + + const role = await select({ + message: 'Role', + options: ['Member', 'Owner'], + default: 'Member', + }); + + const active = await confirm({ + message: 'Active?', + default: true, + }); + + return { active, name, role }; +}); + +const rendered = output.text(); + +void [result, rendered]; +``` + +## Consumer Call And Output + +```ts +import { createMemoryOutput, createScriptedInput, text, withPromptEnvironment } from '@ollin/tui'; + +const output = createMemoryOutput(); +const input = createScriptedInput(['Ada']); + +const name = await withPromptEnvironment({ input, output }, () => text({ message: 'Name' })); +const rendered = output.text(); +``` + + diff --git a/packages/docs/src/guide/text.md b/packages/docs/src/guide/text.md new file mode 100644 index 00000000..7eee9dc7 --- /dev/null +++ b/packages/docs/src/guide/text.md @@ -0,0 +1,62 @@ +# Text + +The `text` helper reads a single-line string. + +```ts +import { text } from '@ollin/tui'; + +const name = await text({ + message: 'What is your name?', + placeholder: 'Ada Lovelace', + required: 'A name is required.', + validate: (value) => (value.length < 2 ? 'Enter at least two characters.' : null), +}); +``` + +## Options + +`text` accepts `message`, `placeholder`, `default`, `required`, `hint`, `validate`, and `transform`. + +The `validate` callback returns a string error message or `null`. Use callbacks when a prompt needs validation. + +## Label-First Form + +```ts +import { text } from '@ollin/tui'; + +const name = await text('What is your name?', 'Ada Lovelace', '', true); +``` + +## Complete Usage + +```ts +import { text } from '@ollin/tui'; + +const project = await text({ + message: 'Project name', + placeholder: 'acme-console', + default: 'ollin-console', + hint: 'Used in generated filenames.', + required: 'A project name is required.', + transform: (value) => value.trim().toLowerCase(), + validate: (value) => (/^[a-z0-9-]+$/u.test(value) ? null : 'Use lowercase letters, numbers, and dashes.'), +}); + +const owner = await text('Owner name', 'Ada Lovelace', '', true); + +void [project, owner]; +``` + +## Consumer Call And Output + +```ts +import { text } from '@ollin/tui'; + +const project = await text({ + message: 'Project name', + default: 'ollin-console', + required: true, +}); +``` + + diff --git a/packages/docs/src/guide/textarea.md b/packages/docs/src/guide/textarea.md new file mode 100644 index 00000000..a25b73f5 --- /dev/null +++ b/packages/docs/src/guide/textarea.md @@ -0,0 +1,54 @@ +# Textarea + +The `textarea` helper reads multi-line input. + +```ts +import { textarea } from '@ollin/tui'; + +const biography = await textarea({ + message: 'Tell us about your project.', + placeholder: 'This project helps...', + rows: 5, + required: true, +}); +``` + +## Options + +`textarea` supports the text prompt options plus `rows`. + +Use `validate` for custom checks and `transform` to normalise the submitted string before validation. + +## Complete Usage + +```ts +import { textarea } from '@ollin/tui'; + +const summary = await textarea({ + message: 'Release notes', + placeholder: 'Added a new prompt flow...', + rows: 6, + hint: 'Markdown is accepted.', + required: 'Release notes are required.', + transform: (value) => value.trim(), + validate: (value) => (value.length < 20 ? 'Write at least 20 characters.' : null), +}); + +const supportMessage = await textarea('Support message', 'Describe the issue', '', true); + +void [summary, supportMessage]; +``` + +## Consumer Call And Output + +```ts +import { textarea } from '@ollin/tui'; + +const notes = await textarea({ + message: 'Release notes', + rows: 4, + required: true, +}); +``` + + diff --git a/packages/docs/src/guide/transforms.md b/packages/docs/src/guide/transforms.md new file mode 100644 index 00000000..f6a477c9 --- /dev/null +++ b/packages/docs/src/guide/transforms.md @@ -0,0 +1,61 @@ +# Transforming Input Before Validation + +Prompt options can include `transform`. The transform runs on the submitted value before validation completes. + +```ts +import { text } from '@ollin/tui'; + +const slug = await text({ + message: 'Project slug', + transform: (value) => value.trim().toLowerCase().replaceAll(' ', '-'), + validate: (value) => (/^[a-z0-9-]+$/u.test(value) ? null : 'Use letters, numbers, and dashes.'), +}); +``` + +Transforms can be synchronous or asynchronous. Use them for normalisation that belongs to the prompt boundary. + +Use callbacks for `validate` and `transform`. + +## Complete Usage + +```ts +import { confirm, multiselect, number, text } from '@ollin/tui'; + +const slug = await text({ + message: 'Project slug', + transform: (value) => value.trim().toLowerCase().replaceAll(' ', '-'), + validate: (value) => (/^[a-z0-9-]+$/u.test(value) ? null : 'Use letters, numbers, and dashes.'), +}); + +const seats = await number({ + message: 'Seats', + transform: (value) => Number(value), + validate: (value) => (Number(value) >= 1 ? null : 'Choose at least one seat.'), +}); + +const permissions = await multiselect({ + message: 'Permissions', + options: ['Read', 'Write', 'Delete'], + transform: (values) => values.map((value) => value.toLowerCase()), +}); + +const publish = await confirm({ + message: 'Publish?', + transform: (value) => Boolean(value), +}); + +void [slug, seats, permissions, publish]; +``` + +## Consumer Call And Output + +```ts +import { text } from '@ollin/tui'; + +const slug = await text({ + message: 'Project slug', + transform: (value) => value.trim().toLowerCase().replaceAll(' ', '-'), +}); +``` + + diff --git a/packages/docs/src/index.md b/packages/docs/src/index.md new file mode 100644 index 00000000..2c5f555c --- /dev/null +++ b/packages/docs/src/index.md @@ -0,0 +1,47 @@ +# @ollin/tui + +Prompt and terminal helpers for command-line applications. + +Start with the [guide](/guide/introduction), or open the generated [API reference](/api/). + +These docs cover the public API and common command-line workflows. + +## Build a Prompted Command + +```ts +import { form, table, task } from '@ollin/tui'; + +const answers = await form() + .text({ message: 'Package name', required: true }, 'name') + .select({ message: 'Template', options: ['library', 'cli', 'docs'], default: 'cli' }, 'template') + .confirm({ message: 'Install dependencies?', default: true }, 'install') + .submit(); + +await task('Creating package', async (logger) => { + logger.info(`Template: ${answers.template}`); + logger.success('Package configured'); +}); + +table(['Field', 'Value'], [ + ['Package', String(answers.name)], + ['Template', String(answers.template)], + ['Install', String(answers.install)], +]); +``` + +## What To Read First + +- Use [Available Prompts](/guide/available-prompts) for the helper map. +- Use [Forms](/guide/forms) when a command needs multiple steps. +- Use [Testing](/guide/testing) when you need deterministic prompt input. +- Use [API Reference](/api/) for generated types and overloads. + +## Consumer Call And Output + +```ts +import { text } from '@ollin/tui'; + +const name = await text({ message: 'Package name', default: 'cli-tools' }); +``` + + diff --git a/packages/docs/tests/docs.test.ts b/packages/docs/tests/docs.test.ts new file mode 100644 index 00000000..ae622bcf --- /dev/null +++ b/packages/docs/tests/docs.test.ts @@ -0,0 +1,82 @@ +import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; +import { extname, join, relative } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { guideSections } from '../src/.vitepress/config'; + +const packagePath = new URL('..', import.meta.url); +const sourcePath = new URL('../src/', import.meta.url); +const examplesPath = new URL('../examples/', import.meta.url); +const blockedTokens = ['TODO', 'lorem', 'fake', 'mocked', 'stubbed']; + +const expectedExports = [ + 'text', + 'textarea', + 'number', + 'password', + 'confirm', + 'select', + 'multiselect', + 'suggest', + 'autocomplete', + 'search', + 'multisearch', + 'pause', + 'form', + 'table', + 'dataTable', + 'spin', + 'progress', + 'task', + 'stream', +] as const; + +const walk = (directory: string): string[] => + readdirSync(directory).flatMap((entry) => { + const path = join(directory, entry); + + return statSync(path).isDirectory() ? walk(path) : [path]; + }); + +const markdownFiles = (): string[] => walk(sourcePath.pathname).filter((path) => extname(path) === '.md'); +const exampleFiles = (): string[] => walk(examplesPath.pathname).filter((path) => extname(path) === '.ts'); + +describe('docs structure', () => { + it('has a markdown page for every guide section', () => { + for (const section of guideSections) { + const path = join(sourcePath.pathname, `${section.link.replace(/^\//u, '')}.md`); + + expect(existsSync(path), `${section.text} is missing at ${path}`).toBe(true); + } + }); + + it('documents every key public helper', () => { + const content = markdownFiles() + .map((path) => readFileSync(path, 'utf8')) + .join('\n'); + + for (const exportName of expectedExports) { + expect(content, `${exportName} is not documented`).toContain(`\`${exportName}\``); + } + }); + + it('keeps docs and examples free of placeholder language', () => { + const files = [...markdownFiles(), ...exampleFiles()]; + + for (const path of files) { + const content = readFileSync(path, 'utf8'); + + for (const token of blockedTokens) { + expect(content, `${relative(packagePath.pathname, path)} contains ${token}`).not.toContain(token); + } + } + }); + + it('keeps guide pages useful without leaving for example source files', () => { + for (const path of markdownFiles().filter((file) => file.includes('/guide/'))) { + const content = readFileSync(path, 'utf8'); + + expect(content, `${relative(packagePath.pathname, path)} needs inline TypeScript usage`).toContain('```ts'); + expect(content, `${relative(packagePath.pathname, path)} should not send readers to source fixtures`).not.toMatch(/\.\.\/\.\.\/examples\//u); + } + }); +}); diff --git a/packages/docs/tsconfig.json b/packages/docs/tsconfig.json new file mode 100644 index 00000000..2bdb1481 --- /dev/null +++ b/packages/docs/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "@ollin/tsconfig/base.json", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@ollin/tui": ["../tui/src/index.ts"], + "#tui/*": ["../tui/src/*"], + "#tui/prompts/*": ["../tui/src/prompts/*"] + }, + "tsBuildInfoFile": "../artefacts/.cache/tsbuild/docs.tsbuildinfo" + }, + "include": ["examples/**/*.ts", "src/.vitepress/**/*.ts", "tests/**/*.ts", "vitest.config.ts", "../tui/src/**/*.ts"] +} diff --git a/packages/docs/typedoc.json b/packages/docs/typedoc.json new file mode 100644 index 00000000..c163a56f --- /dev/null +++ b/packages/docs/typedoc.json @@ -0,0 +1,12 @@ +{ + "entryPoints": ["../tui/src/index.ts"], + "excludeInternal": true, + "excludePrivate": true, + "excludeProtected": true, + "hideBreadcrumbs": true, + "hidePageHeader": true, + "out": "src/api", + "plugin": ["typedoc-plugin-markdown"], + "readme": "none", + "tsconfig": "../tui/tsconfig.json" +} diff --git a/packages/docs/vitest.config.ts b/packages/docs/vitest.config.ts new file mode 100644 index 00000000..3ec5a5de --- /dev/null +++ b/packages/docs/vitest.config.ts @@ -0,0 +1,24 @@ +import { defineConfig } from 'vitest/config'; +import { fileURLToPath } from 'node:url'; + +const tuiSourcePath = fileURLToPath(new URL('../tui/src/index.ts', import.meta.url)); +const internalSourcePath = fileURLToPath(new URL('../tui/src/$1', import.meta.url)); + +export default defineConfig({ + resolve: { + alias: [ + { + find: '@ollin/tui', + replacement: tuiSourcePath, + }, + { + find: /^#tui\/(.+)$/u, + replacement: internalSourcePath, + }, + ], + }, + test: { + environment: 'node', + globals: false, + }, +}); From e5b6522442b938e75aec624d144d1425a9a06f36 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Fri, 12 Jun 2026 16:28:51 +0800 Subject: [PATCH 257/675] Preserve headerless option tables --- UPSTREAM.md | 1 + packages/tui/src/output/validators/table.ts | 14 +++++++++++++- packages/tui/tests/table.test.ts | 18 ++++++++++++++++++ 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/UPSTREAM.md b/UPSTREAM.md index 39e45778..5871a86b 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -94,6 +94,7 @@ TypeScript and OpenTUI internals. | Data table scrollbar rendering | `packages/tui/src/output/data-table/scrollbar.ts`, `packages/tui/src/output/data-table/render.ts`, `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/rows.ts`, `packages/tui/tests/table.test.ts` | | Data table row-shape validation and normalization | `packages/tui/src/output/validators/data-table.ts`, `packages/tui/src/output/data-table/rows.ts`, `packages/tui/tests/table.test.ts` | +| Static table row-shape validation and header inference | `packages/tui/src/output/validators/table.ts`, `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/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/render.ts`, `packages/tui/tests/table.test.ts` | | Boxed balanced grid rendering | `packages/tui/src/output/grid.ts`, `packages/tui/tests/output.test.ts` | diff --git a/packages/tui/src/output/validators/table.ts b/packages/tui/src/output/validators/table.ts index bf313c02..69fc6c68 100644 --- a/packages/tui/src/output/validators/table.ts +++ b/packages/tui/src/output/validators/table.ts @@ -14,6 +14,18 @@ const tableOptionsSchema = z }) .passthrough(); +const inferredTableHeaders = (rows: TableOptions['rows']): string[] => { + const firstRow = rows[0]; + + const arrayRow = z.array(tableCellSchema).safeParse(firstRow); + + if (arrayRow.success || firstRow === undefined) { + return []; + } + + return Object.keys(firstRow); +}; + export const isTableOptions = (value: unknown): value is TableOptions => { return tableOptionsSchema.safeParse(value).success; }; @@ -28,7 +40,7 @@ export const parseTableOptions = (headersOrOptions: unknown = [], rows: unknown const tableOptions = tableOptionsSchema.safeParse(headersOrOptions); if (tableOptions.success) { - const headers = tableOptions.data.headers ?? Object.keys(tableOptions.data.rows[0] ?? {}); + const headers = tableOptions.data.headers ?? inferredTableHeaders(tableOptions.data.rows); return { headers, rows: tableOptions.data.rows }; } diff --git a/packages/tui/tests/table.test.ts b/packages/tui/tests/table.test.ts index 90352b33..e03a11c6 100644 --- a/packages/tui/tests/table.test.ts +++ b/packages/tui/tests/table.test.ts @@ -107,6 +107,24 @@ describe('table output', () => { expect(output.text()).toContain('| Prompts | TypeScript |'); }); + it('renders option-object tables with array rows as headerless tables unless headers are explicit', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + table({ + rows: [ + ['Ollin', 'OpenTUI'], + ['Prompts', 'TypeScript'], + ], + }); + }); + + expect(output.text()).not.toContain('| 0 | 1 |'); + expect(output.text()).not.toContain('| ------- | ---------- |'); + expect(output.text()).toContain('| Ollin | OpenTUI |'); + expect(output.text()).toContain('| Prompts | TypeScript |'); + }); + it('rejects invalid table runtime shapes through the validator layer', async () => { const output = createMemoryOutput(); From 9f2545cfe2f6290557872e919bd43206ae9bbd21 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Fri, 12 Jun 2026 16:45:18 +0800 Subject: [PATCH 258/675] Reuse reverted password form values --- UPSTREAM.md | 1 + packages/tui/src/form/builder/prompts/basic.ts | 15 ++++++++++++++- packages/tui/tests/form.test.ts | 17 +++++++++++++++++ 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/UPSTREAM.md b/UPSTREAM.md index 5871a86b..886a8eed 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -109,6 +109,7 @@ TypeScript and OpenTUI internals. | Form builder suggest and autocomplete object-option method surface | `packages/tui/src/form/builder/prompts/choices.ts`, `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/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/contracts/basic.ts`, `packages/tui/src/prompts/number.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/tests/form.test.ts` | | Form builder search and multisearch previous-response defaults | `packages/tui/src/form/builder/prompts/choices.ts`, `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/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/tests/form.test.ts` | diff --git a/packages/tui/src/form/builder/prompts/basic.ts b/packages/tui/src/form/builder/prompts/basic.ts index 6323bfec..476750a9 100644 --- a/packages/tui/src/form/builder/prompts/basic.ts +++ b/packages/tui/src/form/builder/prompts/basic.ts @@ -68,7 +68,20 @@ function passwordFormStep( return this.add((_, previous) => password({ ...optionsOrLabel, default: previousString(previous, optionsOrLabel.default ?? '') }), placeholder); } - return this.add(() => password(optionsOrLabel, placeholder, required, validate, hint, transform), name); + return this.add( + (_, previous) => + password({ + message: optionsOrLabel, + label: optionsOrLabel, + placeholder, + default: previousString(previous, ''), + required, + validate, + hint, + transform, + }), + name, + ); } function textFormStep(this: FormBuilder, options: TextPromptOptions, name?: string): FormBuilder; diff --git a/packages/tui/tests/form.test.ts b/packages/tui/tests/form.test.ts index 22f3ea9a..93ebddb7 100644 --- a/packages/tui/tests/form.test.ts +++ b/packages/tui/tests/form.test.ts @@ -695,6 +695,23 @@ describe('form builder', () => { expect(responses.body).toBe('L1\nL2'); }); + it('reuses password previous responses when reverting label-first form steps', async () => { + const output = createMemoryOutput(); + + const responses = await withPromptEnvironment( + { + input: createScriptedInput(['s', 'e', 'c', 'r', 'e', 't', Key.enter, Key.ctrlU, Key.enter, 'D', Key.enter]), + output, + error: output, + interactive: true, + }, + () => form().password('Secret').text('Done').submit(), + ); + + expect(responses[0]).toBe('secret'); + expect(responses[1]).toBe('D'); + }); + it('runs task, pause, and stream form steps', async () => { const output = createMemoryOutput(); From c5fcf1330204bc5c7a38b226b3e35c03da5eb958 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Fri, 12 Jun 2026 16:49:40 +0800 Subject: [PATCH 259/675] Modernize docs components --- packages/docs/components.json | 19 +++ packages/docs/package.json | 13 +- packages/docs/src/.vitepress/config.ts | 21 ++++ .../theme/components/TerminalOutput.vue | 52 ++++++-- .../theme/components/ui/badge/Badge.vue | 24 ++++ .../theme/components/ui/badge/index.ts | 3 + .../theme/components/ui/badge/variants.ts | 22 ++++ .../theme/components/ui/card/Card.vue | 17 +++ .../theme/components/ui/card/CardAction.vue | 14 +++ .../theme/components/ui/card/CardContent.vue | 14 +++ .../components/ui/card/CardDescription.vue | 14 +++ .../theme/components/ui/card/CardFooter.vue | 14 +++ .../theme/components/ui/card/CardHeader.vue | 22 ++++ .../theme/components/ui/card/CardTitle.vue | 14 +++ .../theme/components/ui/card/index.ts | 7 ++ .../components/ui/separator/Separator.vue | 27 +++++ .../theme/components/ui/separator/index.ts | 1 + packages/docs/src/.vitepress/theme/index.ts | 4 +- .../docs/src/.vitepress/theme/lib/utils.ts | 5 + packages/docs/src/.vitepress/theme/styles.css | 112 +++++++++++------- packages/docs/tests/docs.test.ts | 21 +++- packages/docs/tsconfig.json | 7 ++ packages/docs/vitest.config.ts | 37 +++++- 23 files changed, 424 insertions(+), 60 deletions(-) create mode 100644 packages/docs/components.json create mode 100644 packages/docs/src/.vitepress/theme/components/ui/badge/Badge.vue create mode 100644 packages/docs/src/.vitepress/theme/components/ui/badge/index.ts create mode 100644 packages/docs/src/.vitepress/theme/components/ui/badge/variants.ts create mode 100644 packages/docs/src/.vitepress/theme/components/ui/card/Card.vue create mode 100644 packages/docs/src/.vitepress/theme/components/ui/card/CardAction.vue create mode 100644 packages/docs/src/.vitepress/theme/components/ui/card/CardContent.vue create mode 100644 packages/docs/src/.vitepress/theme/components/ui/card/CardDescription.vue create mode 100644 packages/docs/src/.vitepress/theme/components/ui/card/CardFooter.vue create mode 100644 packages/docs/src/.vitepress/theme/components/ui/card/CardHeader.vue create mode 100644 packages/docs/src/.vitepress/theme/components/ui/card/CardTitle.vue create mode 100644 packages/docs/src/.vitepress/theme/components/ui/card/index.ts create mode 100644 packages/docs/src/.vitepress/theme/components/ui/separator/Separator.vue create mode 100644 packages/docs/src/.vitepress/theme/components/ui/separator/index.ts create mode 100644 packages/docs/src/.vitepress/theme/lib/utils.ts diff --git a/packages/docs/components.json b/packages/docs/components.json new file mode 100644 index 00000000..7309152d --- /dev/null +++ b/packages/docs/components.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://shadcn-vue.com/schema.json", + "style": "new-york", + "typescript": true, + "tailwind": { + "config": "", + "css": "src/.vitepress/theme/styles.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@docs-components", + "composables": "@docs-composables", + "lib": "@docs-lib", + "ui": "@docs-ui", + "utils": "@docs-lib/utils" + } +} diff --git a/packages/docs/package.json b/packages/docs/package.json index 4d2e59d2..b7f305ab 100644 --- a/packages/docs/package.json +++ b/packages/docs/package.json @@ -13,15 +13,24 @@ }, "dependencies": { "@ollin/tui": "workspace:*", - "@xterm/xterm": "^6.0.0" + "@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" + "vitest": "^4.1.5", + "vue": "^3.5.38" } } diff --git a/packages/docs/src/.vitepress/config.ts b/packages/docs/src/.vitepress/config.ts index a2439b39..11b84a7b 100644 --- a/packages/docs/src/.vitepress/config.ts +++ b/packages/docs/src/.vitepress/config.ts @@ -1,5 +1,14 @@ +import { fileURLToPath } from 'node:url'; +import tailwindcss from '@tailwindcss/vite'; import { defineConfig } from 'vitepress'; +const sourcePath = fileURLToPath(new URL('..', 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 tailwindPlugin = tailwindcss() as never; + export const guideSections = [ { text: 'Introduction', link: '/guide/introduction' }, { text: 'Installation', link: '/guide/installation' }, @@ -36,6 +45,18 @@ export default defineConfig({ description: 'Documentation for the Ollin TUI prompt toolkit.', cleanUrls: true, lastUpdated: true, + vite: { + plugins: [tailwindPlugin], + resolve: { + alias: [ + { find: '@docs', replacement: sourcePath }, + { find: '@docs-components', replacement: themeComponentsPath }, + { find: '@docs-lib', replacement: themeLibPath }, + { find: '@docs-theme', replacement: themePath }, + { find: '@docs-ui', replacement: themeUiPath }, + ], + }, + }, themeConfig: { nav: [ { text: 'Guide', link: '/guide/introduction' }, diff --git a/packages/docs/src/.vitepress/theme/components/TerminalOutput.vue b/packages/docs/src/.vitepress/theme/components/TerminalOutput.vue index c5a26ca9..1c3a486a 100644 --- a/packages/docs/src/.vitepress/theme/components/TerminalOutput.vue +++ b/packages/docs/src/.vitepress/theme/components/TerminalOutput.vue @@ -1,4 +1,10 @@ diff --git a/packages/docs/src/.vitepress/theme/components/ui/badge/Badge.vue b/packages/docs/src/.vitepress/theme/components/ui/badge/Badge.vue new file mode 100644 index 00000000..f054e0ee --- /dev/null +++ b/packages/docs/src/.vitepress/theme/components/ui/badge/Badge.vue @@ -0,0 +1,24 @@ + + + diff --git a/packages/docs/src/.vitepress/theme/components/ui/badge/index.ts b/packages/docs/src/.vitepress/theme/components/ui/badge/index.ts new file mode 100644 index 00000000..426671ea --- /dev/null +++ b/packages/docs/src/.vitepress/theme/components/ui/badge/index.ts @@ -0,0 +1,3 @@ +export { default as Badge } from '@docs-ui/badge/Badge.vue'; +export { badgeVariants } from '@docs-ui/badge/variants'; +export type { BadgeVariants } from '@docs-ui/badge/variants'; diff --git a/packages/docs/src/.vitepress/theme/components/ui/badge/variants.ts b/packages/docs/src/.vitepress/theme/components/ui/badge/variants.ts new file mode 100644 index 00000000..7775842b --- /dev/null +++ b/packages/docs/src/.vitepress/theme/components/ui/badge/variants.ts @@ -0,0 +1,22 @@ +import { cva } from 'class-variance-authority'; +import type { VariantProps } from 'class-variance-authority'; + +export const badgeVariants = cva( + 'inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden', + { + variants: { + variant: { + default: 'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90', + secondary: 'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90', + destructive: + 'border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60', + outline: 'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground', + }, + }, + defaultVariants: { + variant: 'default', + }, + }, +); + +export type BadgeVariants = VariantProps; diff --git a/packages/docs/src/.vitepress/theme/components/ui/card/Card.vue b/packages/docs/src/.vitepress/theme/components/ui/card/Card.vue new file mode 100644 index 00000000..eca9cfb5 --- /dev/null +++ b/packages/docs/src/.vitepress/theme/components/ui/card/Card.vue @@ -0,0 +1,17 @@ + + + diff --git a/packages/docs/src/.vitepress/theme/components/ui/card/CardAction.vue b/packages/docs/src/.vitepress/theme/components/ui/card/CardAction.vue new file mode 100644 index 00000000..d6b1a8d5 --- /dev/null +++ b/packages/docs/src/.vitepress/theme/components/ui/card/CardAction.vue @@ -0,0 +1,14 @@ + + + diff --git a/packages/docs/src/.vitepress/theme/components/ui/card/CardContent.vue b/packages/docs/src/.vitepress/theme/components/ui/card/CardContent.vue new file mode 100644 index 00000000..bb20eaf9 --- /dev/null +++ b/packages/docs/src/.vitepress/theme/components/ui/card/CardContent.vue @@ -0,0 +1,14 @@ + + + diff --git a/packages/docs/src/.vitepress/theme/components/ui/card/CardDescription.vue b/packages/docs/src/.vitepress/theme/components/ui/card/CardDescription.vue new file mode 100644 index 00000000..cd41c75a --- /dev/null +++ b/packages/docs/src/.vitepress/theme/components/ui/card/CardDescription.vue @@ -0,0 +1,14 @@ + + + diff --git a/packages/docs/src/.vitepress/theme/components/ui/card/CardFooter.vue b/packages/docs/src/.vitepress/theme/components/ui/card/CardFooter.vue new file mode 100644 index 00000000..2becef40 --- /dev/null +++ b/packages/docs/src/.vitepress/theme/components/ui/card/CardFooter.vue @@ -0,0 +1,14 @@ + + + diff --git a/packages/docs/src/.vitepress/theme/components/ui/card/CardHeader.vue b/packages/docs/src/.vitepress/theme/components/ui/card/CardHeader.vue new file mode 100644 index 00000000..6808c766 --- /dev/null +++ b/packages/docs/src/.vitepress/theme/components/ui/card/CardHeader.vue @@ -0,0 +1,22 @@ + + + diff --git a/packages/docs/src/.vitepress/theme/components/ui/card/CardTitle.vue b/packages/docs/src/.vitepress/theme/components/ui/card/CardTitle.vue new file mode 100644 index 00000000..c179ac78 --- /dev/null +++ b/packages/docs/src/.vitepress/theme/components/ui/card/CardTitle.vue @@ -0,0 +1,14 @@ + + + diff --git a/packages/docs/src/.vitepress/theme/components/ui/card/index.ts b/packages/docs/src/.vitepress/theme/components/ui/card/index.ts new file mode 100644 index 00000000..5a31d356 --- /dev/null +++ b/packages/docs/src/.vitepress/theme/components/ui/card/index.ts @@ -0,0 +1,7 @@ +export { default as Card } from '@docs-ui/card/Card.vue'; +export { default as CardAction } from '@docs-ui/card/CardAction.vue'; +export { default as CardContent } from '@docs-ui/card/CardContent.vue'; +export { default as CardDescription } from '@docs-ui/card/CardDescription.vue'; +export { default as CardFooter } from '@docs-ui/card/CardFooter.vue'; +export { default as CardHeader } from '@docs-ui/card/CardHeader.vue'; +export { default as CardTitle } from '@docs-ui/card/CardTitle.vue'; diff --git a/packages/docs/src/.vitepress/theme/components/ui/separator/Separator.vue b/packages/docs/src/.vitepress/theme/components/ui/separator/Separator.vue new file mode 100644 index 00000000..bda62020 --- /dev/null +++ b/packages/docs/src/.vitepress/theme/components/ui/separator/Separator.vue @@ -0,0 +1,27 @@ + + + diff --git a/packages/docs/src/.vitepress/theme/components/ui/separator/index.ts b/packages/docs/src/.vitepress/theme/components/ui/separator/index.ts new file mode 100644 index 00000000..b98245dd --- /dev/null +++ b/packages/docs/src/.vitepress/theme/components/ui/separator/index.ts @@ -0,0 +1 @@ +export { default as Separator } from '@docs-ui/separator/Separator.vue'; diff --git a/packages/docs/src/.vitepress/theme/index.ts b/packages/docs/src/.vitepress/theme/index.ts index a5f805fd..a70de897 100644 --- a/packages/docs/src/.vitepress/theme/index.ts +++ b/packages/docs/src/.vitepress/theme/index.ts @@ -1,7 +1,7 @@ import DefaultTheme from 'vitepress/theme'; -import TerminalOutput from './components/TerminalOutput.vue'; +import TerminalOutput from '@docs-components/TerminalOutput.vue'; import '@xterm/xterm/css/xterm.css'; -import './styles.css'; +import '@docs-theme/styles.css'; import type { Theme } from 'vitepress'; export default { diff --git a/packages/docs/src/.vitepress/theme/lib/utils.ts b/packages/docs/src/.vitepress/theme/lib/utils.ts new file mode 100644 index 00000000..b802bab4 --- /dev/null +++ b/packages/docs/src/.vitepress/theme/lib/utils.ts @@ -0,0 +1,5 @@ +import { clsx } from 'clsx'; +import { twMerge } from 'tailwind-merge'; +import type { ClassValue } from 'clsx'; + +export const cn = (...inputs: ClassValue[]): string => twMerge(clsx(inputs)); diff --git a/packages/docs/src/.vitepress/theme/styles.css b/packages/docs/src/.vitepress/theme/styles.css index cebc25bf..274702a4 100644 --- a/packages/docs/src/.vitepress/theme/styles.css +++ b/packages/docs/src/.vitepress/theme/styles.css @@ -1,55 +1,85 @@ -.terminal-output { - margin: 16px 0; - overflow: hidden; - border: 1px solid var(--vp-c-divider); - border-radius: 8px; - background: #0c0f14; - box-shadow: inset 0 1px 0 rgb(255 255 255 / 4%); -} - -.terminal-output__bar { - display: flex; - align-items: center; - gap: 8px; - padding: 8px 12px; - border-bottom: 1px solid rgb(255 255 255 / 8%); - background: #111827; -} +@import "tailwindcss"; -.terminal-output__dot { - width: 10px; - height: 10px; - border-radius: 999px; -} +@source "../../**/*.{md,ts,vue}"; -.terminal-output__dot--danger { - background: #ef4444; -} - -.terminal-output__dot--warning { - background: #facc15; -} +@custom-variant dark (&:is(.dark *)); -.terminal-output__dot--success { - background: #22c55e; +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --radius-sm: calc(var(--radius) * 0.6); + --radius-md: calc(var(--radius) * 0.8); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) * 1.4); } -.terminal-output__title { - margin-left: 4px; - color: #9ca3af; - font-size: 12px; - line-height: 1; +:root { + --radius: 0.5rem; + --background: var(--vp-c-bg); + --foreground: var(--vp-c-text-1); + --card: var(--vp-c-bg-soft); + --card-foreground: var(--vp-c-text-1); + --popover: var(--vp-c-bg-elv); + --popover-foreground: var(--vp-c-text-1); + --primary: var(--vp-c-brand-1); + --primary-foreground: #ffffff; + --secondary: var(--vp-c-bg-alt); + --secondary-foreground: var(--vp-c-text-1); + --muted: var(--vp-c-bg-soft); + --muted-foreground: var(--vp-c-text-2); + --accent: var(--vp-c-default-soft); + --accent-foreground: var(--vp-c-text-1); + --destructive: #ef4444; + --border: var(--vp-c-divider); + --input: var(--vp-c-divider); + --ring: var(--vp-c-brand-2); + --terminal-background: #0c0f14; + --terminal-danger: #ef4444; + --terminal-success: #22c55e; + --terminal-warning: #facc15; } -.terminal-output__viewport { - min-height: 64px; - padding: 12px; +.dark { + --background: var(--vp-c-bg); + --foreground: var(--vp-c-text-1); + --card: var(--vp-c-bg-soft); + --card-foreground: var(--vp-c-text-1); + --popover: var(--vp-c-bg-elv); + --popover-foreground: var(--vp-c-text-1); + --primary: var(--vp-c-brand-1); + --primary-foreground: #ffffff; + --secondary: var(--vp-c-bg-alt); + --secondary-foreground: var(--vp-c-text-1); + --muted: var(--vp-c-bg-soft); + --muted-foreground: var(--vp-c-text-2); + --accent: var(--vp-c-default-soft); + --accent-foreground: var(--vp-c-text-1); + --destructive: #f87171; + --border: var(--vp-c-divider); + --input: var(--vp-c-divider); + --ring: var(--vp-c-brand-2); } -.terminal-output .xterm { +.xterm { padding: 0; } -.terminal-output .xterm-viewport { +.xterm-viewport { overflow: hidden !important; } diff --git a/packages/docs/tests/docs.test.ts b/packages/docs/tests/docs.test.ts index ae622bcf..e00bcd7b 100644 --- a/packages/docs/tests/docs.test.ts +++ b/packages/docs/tests/docs.test.ts @@ -1,12 +1,14 @@ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; import { extname, join, relative } from 'node:path'; import { describe, expect, it } from 'vitest'; -import { guideSections } from '../src/.vitepress/config'; +import { guideSections } from '@docs-config'; const packagePath = new URL('..', import.meta.url); const sourcePath = new URL('../src/', import.meta.url); const examplesPath = new URL('../examples/', import.meta.url); +const vitepressPath = new URL('../src/.vitepress/', import.meta.url); const blockedTokens = ['TODO', 'lorem', 'fake', 'mocked', 'stubbed']; +const importSpecifierPattern = /\b(?:import|export)\b(?:[\s\S]*?\bfrom\s*)?['"]([^'"]+)['"]|import\(\s*['"]([^'"]+)['"]\s*\)/gu; const expectedExports = [ 'text', @@ -40,6 +42,11 @@ const walk = (directory: string): string[] => const markdownFiles = (): string[] => walk(sourcePath.pathname).filter((path) => extname(path) === '.md'); const exampleFiles = (): string[] => walk(examplesPath.pathname).filter((path) => extname(path) === '.ts'); +const docsCodeFiles = (): string[] => + [...walk(vitepressPath.pathname), ...walk(new URL('../tests/', import.meta.url).pathname)] + .filter((path) => ['.ts', '.vue'].includes(extname(path))) + .filter((path) => !path.includes('/.vitepress/cache/') && !path.includes('/.vitepress/dist/')); + describe('docs structure', () => { it('has a markdown page for every guide section', () => { for (const section of guideSections) { @@ -79,4 +86,16 @@ describe('docs structure', () => { expect(content, `${relative(packagePath.pathname, path)} should not send readers to source fixtures`).not.toMatch(/\.\.\/\.\.\/examples\//u); } }); + + it('uses aliases instead of relative imports in docs code', () => { + for (const path of docsCodeFiles()) { + const content = readFileSync(path, 'utf8'); + + for (const match of content.matchAll(importSpecifierPattern)) { + const specifier = match[1] ?? match[2] ?? ''; + + expect(specifier, `${relative(packagePath.pathname, path)} imports ${specifier}`).not.toMatch(/^\.\.?(?:\/|$)/u); + } + } + }); }); diff --git a/packages/docs/tsconfig.json b/packages/docs/tsconfig.json index 2bdb1481..07841806 100644 --- a/packages/docs/tsconfig.json +++ b/packages/docs/tsconfig.json @@ -3,6 +3,13 @@ "compilerOptions": { "baseUrl": ".", "paths": { + "@docs/*": ["src/*"], + "@docs-components/*": ["src/.vitepress/theme/components/*"], + "@docs-config": ["src/.vitepress/config.ts"], + "@docs-lib/*": ["src/.vitepress/theme/lib/*"], + "@docs-tests/*": ["tests/*"], + "@docs-theme/*": ["src/.vitepress/theme/*"], + "@docs-ui/*": ["src/.vitepress/theme/components/ui/*"], "@ollin/tui": ["../tui/src/index.ts"], "#tui/*": ["../tui/src/*"], "#tui/prompts/*": ["../tui/src/prompts/*"] diff --git a/packages/docs/vitest.config.ts b/packages/docs/vitest.config.ts index 3ec5a5de..6a122a6e 100644 --- a/packages/docs/vitest.config.ts +++ b/packages/docs/vitest.config.ts @@ -1,12 +1,47 @@ -import { defineConfig } from 'vitest/config'; import { fileURLToPath } from 'node:url'; +import { defineConfig } from 'vitest/config'; const tuiSourcePath = fileURLToPath(new URL('../tui/src/index.ts', import.meta.url)); const internalSourcePath = fileURLToPath(new URL('../tui/src/$1', import.meta.url)); +const docsSourcePath = fileURLToPath(new URL('src', import.meta.url)); +const docsComponentsPath = fileURLToPath(new URL('src/.vitepress/theme/components', import.meta.url)); +const docsConfigPath = fileURLToPath(new URL('src/.vitepress/config.ts', import.meta.url)); +const docsLibPath = fileURLToPath(new URL('src/.vitepress/theme/lib', import.meta.url)); +const docsTestsPath = fileURLToPath(new URL('tests', import.meta.url)); +const docsThemePath = fileURLToPath(new URL('src/.vitepress/theme', import.meta.url)); +const docsUiPath = fileURLToPath(new URL('src/.vitepress/theme/components/ui', import.meta.url)); export default defineConfig({ 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, From 50789c45bd371995ec2fab32e159cfea4ab7c7ab Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Fri, 12 Jun 2026 17:00:34 +0800 Subject: [PATCH 260/675] Update package docs fonts and select defaults --- packages/docs/package.json | 2 ++ .../theme/components/TerminalOutput.vue | 2 +- packages/docs/src/.vitepress/theme/index.ts | 2 ++ packages/docs/src/.vitepress/theme/styles.css | 7 +++++++ .../tui/src/prompts/select/read-selected.ts | 8 ++++++++ packages/tui/tests/choices.test.ts | 20 +++++++++++++++++++ 6 files changed, 40 insertions(+), 1 deletion(-) diff --git a/packages/docs/package.json b/packages/docs/package.json index b7f305ab..db4bd017 100644 --- a/packages/docs/package.json +++ b/packages/docs/package.json @@ -12,6 +12,8 @@ "typecheck": "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", diff --git a/packages/docs/src/.vitepress/theme/components/TerminalOutput.vue b/packages/docs/src/.vitepress/theme/components/TerminalOutput.vue index 1c3a486a..ec1558b1 100644 --- a/packages/docs/src/.vitepress/theme/components/TerminalOutput.vue +++ b/packages/docs/src/.vitepress/theme/components/TerminalOutput.vue @@ -178,7 +178,7 @@ onMounted(async () => { convertEol: true, cursorBlink: false, disableStdin: true, - fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace', + fontFamily: '"Geist Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace', fontSize: 13, lineHeight: 1.45, overviewRulerWidth: 0, diff --git a/packages/docs/src/.vitepress/theme/index.ts b/packages/docs/src/.vitepress/theme/index.ts index a70de897..b22c4693 100644 --- a/packages/docs/src/.vitepress/theme/index.ts +++ b/packages/docs/src/.vitepress/theme/index.ts @@ -1,5 +1,7 @@ import DefaultTheme from 'vitepress/theme'; import TerminalOutput from '@docs-components/TerminalOutput.vue'; +import '@fontsource/geist-sans/latin.css'; +import '@fontsource/geist-mono/latin.css'; import '@xterm/xterm/css/xterm.css'; import '@docs-theme/styles.css'; import type { Theme } from 'vitepress'; diff --git a/packages/docs/src/.vitepress/theme/styles.css b/packages/docs/src/.vitepress/theme/styles.css index 274702a4..2dd853f2 100644 --- a/packages/docs/src/.vitepress/theme/styles.css +++ b/packages/docs/src/.vitepress/theme/styles.css @@ -23,6 +23,8 @@ --color-border: var(--border); --color-input: var(--input); --color-ring: var(--ring); + --font-sans: var(--docs-font-sans); + --font-mono: var(--docs-font-mono); --radius-sm: calc(var(--radius) * 0.6); --radius-md: calc(var(--radius) * 0.8); --radius-lg: var(--radius); @@ -30,6 +32,10 @@ } :root { + --docs-font-sans: "Geist Sans", var(--vp-font-family-base); + --docs-font-mono: "Geist Mono", var(--vp-font-family-mono); + --vp-font-family-base: "Geist Sans", Inter, ui-sans-serif, system-ui, sans-serif; + --vp-font-family-mono: "Geist Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; --radius: 0.5rem; --background: var(--vp-c-bg); --foreground: var(--vp-c-text-1); @@ -77,6 +83,7 @@ } .xterm { + font-family: var(--docs-font-mono); padding: 0; } diff --git a/packages/tui/src/prompts/select/read-selected.ts b/packages/tui/src/prompts/select/read-selected.ts index c4a5d11b..66f67657 100644 --- a/packages/tui/src/prompts/select/read-selected.ts +++ b/packages/tui/src/prompts/select/read-selected.ts @@ -40,6 +40,14 @@ export const readSelectedChoice = async ( const answer = await ask(`${message}\n${rendered}\n`, hint); + if (answer.trim() === '' && defaultValue !== undefined) { + const choice = choices.find((candidate) => !candidate.disabled && Object.is(candidate.value, defaultValue)); + + if (choice) { + return { cancelled: false, submitted: false, submittedLabel: choice.label, value: choice.value }; + } + } + const choice = findChoice(choices, answer); if (!choice || choice.disabled) { diff --git a/packages/tui/tests/choices.test.ts b/packages/tui/tests/choices.test.ts index 05b3777b..0370f950 100644 --- a/packages/tui/tests/choices.test.ts +++ b/packages/tui/tests/choices.test.ts @@ -588,6 +588,26 @@ describe('choice prompts', () => { expect(result).toBe('second'); }); + it('returns select defaults for empty line-mode answers', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: { + async readLine(): Promise { + return ''; + }, + }, + output, + error: output, + interactive: true, + }, + () => select({ message: 'Pick one', options: ['first', 'second'], default: 'second' }), + ); + + expect(result).toBe('second'); + }); + it('rejects partial numeric line-mode select answers and retries', async () => { const output = createMemoryOutput(); const answers = ['1abc', '2']; From d8e14738709391fa62a8a9741b4864cbc3208b92 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Fri, 12 Jun 2026 17:30:35 +0800 Subject: [PATCH 261/675] Retry invalid line select defaults --- UPSTREAM.md | 1 + packages/tui/src/prompts/select/index.ts | 10 +---- .../tui/src/prompts/select/read-selected.ts | 8 ++++ packages/tui/tests/choices.test.ts | 38 +++++++++++++++++++ 4 files changed, 49 insertions(+), 8 deletions(-) diff --git a/UPSTREAM.md b/UPSTREAM.md index 886a8eed..1844c51c 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -44,6 +44,7 @@ TypeScript and OpenTUI internals. | 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/tests/choices.test.ts` | | Select non-interactive default validation | `packages/tui/src/prompts/select/index.ts`, `packages/tui/tests/choices.test.ts` | +| Select line-mode and exhausted-input default fallback with validation retry | `packages/tui/src/prompts/select/index.ts`, `packages/tui/src/prompts/select/read-selected.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/tests/choices.test.ts` | | Select submitted frame renders only after validation passes | `packages/tui/src/prompts/select/index.ts`, `packages/tui/src/prompts/select/read-selected.ts`, `packages/tui/tests/choices.test.ts` | | Select cancel final frame | `packages/tui/src/prompts/select/render.ts`, `packages/tui/src/prompts/select/read-selected.ts`, `packages/tui/tests/choices.test.ts` | diff --git a/packages/tui/src/prompts/select/index.ts b/packages/tui/src/prompts/select/index.ts index 15aa280b..841acb11 100644 --- a/packages/tui/src/prompts/select/index.ts +++ b/packages/tui/src/prompts/select/index.ts @@ -1,6 +1,6 @@ export { confirm } from '#tui/prompts/select/confirm'; -import { promptUntilValid, PromptValidationError } from '#tui/prompt'; +import { promptUntilValid } from '#tui/prompt'; import { normalizeChoices } from '#tui/concerns/choices'; import { readMultipleChoices } from '#tui/prompts/select/read-multiple'; import { readSelectedChoice } from '#tui/prompts/select/read-selected'; @@ -49,13 +49,7 @@ export async function select( return promptUntilValid( promptOptions, async () => { - const selected = await readSelectedChoice(promptOptions.message, choices, promptOptions.default, promptOptions.hint, promptOptions.scroll, promptOptions.info).catch((error: unknown) => { - if (promptOptions.default !== undefined && error instanceof PromptValidationError) { - return { cancelled: false, submitted: false, submittedLabel: '', value: promptOptions.default }; - } - - throw error; - }); + const selected = await readSelectedChoice(promptOptions.message, choices, promptOptions.default, promptOptions.hint, promptOptions.scroll, promptOptions.info); shouldRenderSubmittedFrame = selected.submitted && !selected.cancelled; submittedLabel = selected.submittedLabel; diff --git a/packages/tui/src/prompts/select/read-selected.ts b/packages/tui/src/prompts/select/read-selected.ts index 66f67657..220f5a87 100644 --- a/packages/tui/src/prompts/select/read-selected.ts +++ b/packages/tui/src/prompts/select/read-selected.ts @@ -65,6 +65,14 @@ export const readSelectedChoice = async ( const key = await environment.input.readKey(); if (key === null) { + if (defaultValue !== undefined) { + const choice = choices.find((candidate) => !candidate.disabled && Object.is(candidate.value, defaultValue)); + + if (choice) { + return { cancelled: false, submitted: false, submittedLabel: choice.label, value: choice.value }; + } + } + throw new PromptValidationError('Please select a valid option.'); } diff --git a/packages/tui/tests/choices.test.ts b/packages/tui/tests/choices.test.ts index 0370f950..5cac02d5 100644 --- a/packages/tui/tests/choices.test.ts +++ b/packages/tui/tests/choices.test.ts @@ -608,6 +608,44 @@ describe('choice prompts', () => { expect(result).toBe('second'); }); + it('returns select defaults when key input is exhausted', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([]), + output, + error: output, + interactive: true, + }, + () => select({ message: 'Pick one', options: ['first', 'second'], default: 'second' }), + ); + + expect(result).toBe('second'); + }); + + it('rejects invalid line-mode select answers before accepting defaults', async () => { + const output = createMemoryOutput(); + const answers = ['invalid', '']; + + const result = await withPromptEnvironment( + { + input: { + async readLine(): Promise { + return answers.shift() ?? ''; + }, + }, + output, + error: output, + interactive: true, + }, + () => select({ message: 'Pick one', options: ['first', 'second'], default: 'second' }), + ); + + expect(result).toBe('second'); + expect(output.text()).toContain('Please select a valid option.'); + }); + it('rejects partial numeric line-mode select answers and retries', async () => { const output = createMemoryOutput(); const answers = ['1abc', '2']; From 77073117a62391021fbc971974a73de715de6e63 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Fri, 12 Jun 2026 17:57:49 +0800 Subject: [PATCH 262/675] Return current data table selection on exhausted input --- UPSTREAM.md | 1 + packages/tui/src/output/data-table/read.ts | 2 +- packages/tui/tests/table.test.ts | 25 ++++++++++++++++++++++ 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/UPSTREAM.md b/UPSTREAM.md index 1844c51c..bef53a3d 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -86,6 +86,7 @@ TypeScript and OpenTUI internals. | 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/tests/table.test.ts` | | Data table interactive default selection | `packages/tui/src/output/data-table/read.ts`, `packages/tui/tests/table.test.ts` | +| Data table exhausted-input default selection | `packages/tui/src/output/data-table/read.ts`, `packages/tui/tests/table.test.ts` | | Data table no-results search rendering | `packages/tui/src/output/data-table/render.ts`, `packages/tui/tests/table.test.ts` | | Data table viewing-info rendering | `packages/tui/src/output/data-table/render.ts`, `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/read.ts`, `packages/tui/tests/table.test.ts` | diff --git a/packages/tui/src/output/data-table/read.ts b/packages/tui/src/output/data-table/read.ts index 98fc966b..3b3549f2 100644 --- a/packages/tui/src/output/data-table/read.ts +++ b/packages/tui/src/output/data-table/read.ts @@ -72,7 +72,7 @@ export const readDataTableSelection = async (options: DataTablePromptOptions< const key = await environment.input.readKey(); if (key === null) { - throw invalidRow(); + return dataTableSelectionResult(visibleRows(), selected, false); } const rows = visibleRows(); diff --git a/packages/tui/tests/table.test.ts b/packages/tui/tests/table.test.ts index e03a11c6..5d0a9680 100644 --- a/packages/tui/tests/table.test.ts +++ b/packages/tui/tests/table.test.ts @@ -271,6 +271,31 @@ describe('data table prompt', () => { expect(output.text()).toContain('Beta'); }); + it('returns the current data table selection when key input is exhausted', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([]), + output, + error: output, + interactive: true, + }, + () => + datatable({ + default: 'beta', + message: 'Pick project', + rows: [ + { Name: 'Alpha', value: 'alpha' }, + { Name: 'Beta', value: 'beta' }, + ], + }), + ); + + expect(result).toBe('beta'); + expect(output.text()).toContain('Beta'); + }); + it('filters rows in explicit search mode', async () => { const output = createMemoryOutput(); From 7e7ed959f137c998962cf1e0b0fedc03bd0a033b Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Fri, 12 Jun 2026 18:34:07 +0800 Subject: [PATCH 263/675] Pad note blocks by visible width --- UPSTREAM.md | 1 + packages/tui/src/output/notes.ts | 5 +++-- packages/tui/tests/output.test.ts | 10 ++++++++++ 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/UPSTREAM.md b/UPSTREAM.md index bef53a3d..5f273aa8 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -80,6 +80,7 @@ TypeScript and OpenTUI internals. | 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` | | Notes and alert-style output | `packages/tui/src/output/notes.ts` | | Note multiline and typed theme rendering | `packages/tui/src/output/notes.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/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/data-table.ts` | diff --git a/packages/tui/src/output/notes.ts b/packages/tui/src/output/notes.ts index c7f85f45..24e48359 100644 --- a/packages/tui/src/output/notes.ts +++ b/packages/tui/src/output/notes.ts @@ -1,13 +1,14 @@ import { promptEnvironment } from '#tui/environment'; +import { visibleWidth } from '#tui/strings'; import { backgroundCyan, backgroundRed, black, green, red, white, yellow } from '#tui/theme/styles'; export type NoteType = 'alert' | 'error' | 'info' | 'intro' | 'outro' | 'warning' | string; const paddedIntroLines = (lines: string[]): string[] => { const padded = lines.map((line) => ` ${line} `); - const longest = Math.max(...padded.map((line) => line.length)); + const longest = Math.max(...padded.map(visibleWidth)); - return padded.map((line) => line.padEnd(longest, ' ')); + return padded.map((line) => `${line}${' '.repeat(Math.max(0, longest - visibleWidth(line)))}`); }; const renderNoteLine = (line: string, type?: NoteType | null): string => { diff --git a/packages/tui/tests/output.test.ts b/packages/tui/tests/output.test.ts index f787bc55..32d4c91e 100644 --- a/packages/tui/tests/output.test.ts +++ b/packages/tui/tests/output.test.ts @@ -50,6 +50,16 @@ describe('output helpers', () => { expect(output.text()).toContain('\u001B[46m\u001B[30m Start \u001B[39m\u001B[49m'); }); + it('pads intro and outro lines by visible width', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + intro('東京\nA'); + }); + + expect(parseAnsiText(output.text())).toBe(' 東京 \n A \n'); + }); + it('renders table and boxed grid helpers without returning a value', async () => { const output = createMemoryOutput(); From f4de47ab7e8a9868a40f824f905ac473ed9bd9fe Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Fri, 12 Jun 2026 18:58:22 +0800 Subject: [PATCH 264/675] Close ANSI styles when truncating --- UPSTREAM.md | 1 + packages/tui/src/strings.ts | 39 +++++++++++++++++++++++++----- packages/tui/tests/strings.test.ts | 14 +++++++++++ 3 files changed, 48 insertions(+), 6 deletions(-) diff --git a/UPSTREAM.md b/UPSTREAM.md index 5f273aa8..9045e708 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -140,6 +140,7 @@ TypeScript and OpenTUI internals. | Terminal split escape-sequence buffering | `packages/tui/src/environment/raw-key.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` | | ANSI segment parsing, ANSI-aware wrapping, truncation, Unicode width | `packages/tui/src/strings.ts`, `packages/tui/tests/strings.test.ts` | +| ANSI truncation style cleanup and visible-width markers | `packages/tui/src/strings.ts`, `packages/tui/tests/strings.test.ts` | | OpenTUI adapter | `packages/tui/src/opentui.ts` | | Type contracts | `packages/tui/src/types.ts`, `packages/tui/src/contracts/*` | | Public ESM package runtime and type-consumption surface | `packages/tui/src/index.ts`, `packages/tui/src/form/builder/index.ts`, `packages/tui/src/output/notify.ts`, `packages/tui/src/output/notify/*`, `packages/acceptance/tests/package.test.ts` | diff --git a/packages/tui/src/strings.ts b/packages/tui/src/strings.ts index f1d643df..2dfa5222 100644 --- a/packages/tui/src/strings.ts +++ b/packages/tui/src/strings.ts @@ -21,18 +21,45 @@ export const truncate = (value: string, width: number, marker = '...'): string = return value; } - if (width <= marker.length) { - return marker.slice(0, width); + if (width <= visibleWidth(marker)) { + let clippedMarker = ''; + + for (const char of marker) { + if (visibleWidth(`${clippedMarker}${char}`) > width) { + break; + } + + clippedMarker += char; + } + + return clippedMarker; } let result = ''; + let activeCodes = ''; + + const markerWidth = visibleWidth(marker); - for (const char of value) { - if (visibleWidth(`${result}${char}${marker}`) > width) { - return `${result}${marker}`; + for (const segment of parseAnsiSegments(value)) { + if (segment.codes !== activeCodes) { + if (activeCodes !== '') { + result += ansiCloseSequence(activeCodes); + } + + if (segment.codes !== '') { + result += segment.codes; + } + + activeCodes = segment.codes; } - result += char; + for (const char of segment.text) { + if (visibleWidth(`${result}${char}`) + markerWidth > width) { + return `${result}${activeCodes === '' ? '' : ansiCloseSequence(activeCodes)}${marker}`; + } + + result += char; + } } return result; diff --git a/packages/tui/tests/strings.test.ts b/packages/tui/tests/strings.test.ts index e0f298fd..e7e6f3c2 100644 --- a/packages/tui/tests/strings.test.ts +++ b/packages/tui/tests/strings.test.ts @@ -10,6 +10,20 @@ describe('string utilities', () => { expect(truncate('Ollin Prompts', 10)).toBe('Ollin P...'); }); + it('closes ANSI styles when truncating styled text', () => { + const result = truncate('\u001B[31mOllin Prompts\u001B[39m', 10); + + expect(parseAnsiText(result)).toBe('Ollin P...'); + expect(result).toContain('\u001B[31m'); + expect(result).toContain('\u001B[0m...'); + expect(result.endsWith('\u001B[39m')).toBe(false); + }); + + it('clips truncation markers by visible width', () => { + expect(truncate('Ollin Prompts', 1, '…')).toBe('…'); + expect(truncate('Ollin Prompts', 1, '東京')).toBe(''); + }); + it('wraps words to the requested width', () => { expect(wrap('Ollin Prompts', 7)).toEqual(['Ollin', 'Prompts']); }); From 98f2b1e16ae048163242e40f4b4ff16b130a5d6b Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Fri, 12 Jun 2026 20:32:00 +0800 Subject: [PATCH 265/675] Accept key-mode search defaults --- UPSTREAM.md | 1 + .../tui/src/prompts/search/read-single.ts | 8 ++++++- .../src/prompts/search/read-single/result.ts | 8 +++++++ packages/tui/tests/search.test.ts | 22 +++++++++++++++++++ 4 files changed, 38 insertions(+), 1 deletion(-) diff --git a/UPSTREAM.md b/UPSTREAM.md index 9045e708..8effffb2 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -141,6 +141,7 @@ TypeScript and OpenTUI internals. | Red, strikethrough, and border-styled frames | `packages/tui/src/theme/styles.ts`, `packages/tui/src/theme/box.ts` | | ANSI segment parsing, ANSI-aware wrapping, truncation, Unicode width | `packages/tui/src/strings.ts`, `packages/tui/tests/strings.test.ts` | | ANSI truncation style cleanup and visible-width markers | `packages/tui/src/strings.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/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 and type-consumption surface | `packages/tui/src/index.ts`, `packages/tui/src/form/builder/index.ts`, `packages/tui/src/output/notify.ts`, `packages/tui/src/output/notify/*`, `packages/acceptance/tests/package.test.ts` | diff --git a/packages/tui/src/prompts/search/read-single.ts b/packages/tui/src/prompts/search/read-single.ts index 0c6f665c..1dd07560 100644 --- a/packages/tui/src/prompts/search/read-single.ts +++ b/packages/tui/src/prompts/search/read-single.ts @@ -5,7 +5,7 @@ import { applyTypedKey } from '#tui/typed-value'; import { resolveSearchChoices } from '#tui/prompts/search/choices'; import { clearsSearchHighlight, moveSearchHighlight, searchNavigationAction } from '#tui/prompts/search/keys'; import { renderCancelledSearch, renderSearchChoices } from '#tui/prompts/search/render'; -import { cancelledSearchValue, lineSearchValue, selectedSearchValue } from '#tui/prompts/search/read-single/result'; +import { cancelledSearchValue, defaultSearchChoice, lineSearchValue, selectedSearchValue } from '#tui/prompts/search/read-single/result'; import type { SearchChoiceReadResult } from '#tui/prompts/search/read-single/result'; import type { SearchPromptOptions } from '#tui/types'; @@ -63,6 +63,12 @@ export const readSearchChoice = async (options: SearchPromptOptions, attem choices = await resolveSearchChoices(options.options, state.value); + if (state.value === '' && options.default !== undefined) { + const choice = defaultSearchChoice(choices, options.default); + + return { cancelled: false, submitted: choice !== undefined, submittedLabel: choice?.label ?? '', value: choice?.value ?? options.default }; + } + highlighted = null; renderSearchChoices(options.message, state.value, choices, highlighted, new Set(), [], options.scroll, options.info, false, options.placeholder); continue; diff --git a/packages/tui/src/prompts/search/read-single/result.ts b/packages/tui/src/prompts/search/read-single/result.ts index 9e4e015d..dd75c881 100644 --- a/packages/tui/src/prompts/search/read-single/result.ts +++ b/packages/tui/src/prompts/search/read-single/result.ts @@ -25,6 +25,14 @@ export const selectedSearchValue = (choices: Array>, highlighted: n return choice?.disabled ? undefined : choice?.value; }; +export const defaultSearchChoice = (choices: Array>, fallback?: T): Choice | undefined => { + if (fallback === undefined) { + return undefined; + } + + return choices.find((choice) => !choice.disabled && Object.is(choice.value, fallback)); +}; + export const cancelledSearchValue = (choices: Array>, highlighted: number | null, fallback?: T): T | undefined => { return selectedSearchValue(choices, highlighted) ?? fallback; }; diff --git a/packages/tui/tests/search.test.ts b/packages/tui/tests/search.test.ts index 0732c525..f30e7772 100644 --- a/packages/tui/tests/search.test.ts +++ b/packages/tui/tests/search.test.ts @@ -373,6 +373,28 @@ describe('search prompt', () => { expect(result).toBe('green'); }); + it('returns search defaults for empty key-mode submissions', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.enter]), + output, + error: output, + interactive: true, + }, + () => + search({ + message: 'Favorite color?', + options: colors, + default: 'green', + }), + ); + + expect(result).toBe('green'); + expect(parseAnsiText(output.text())).toMatch(/^ │ Green\s*│$/m); + }); + it('transforms non-interactive search defaults before validation and return', async () => { const output = createMemoryOutput(); From 8ee9993ac4706152ae7bd4f5e0fbbabc53b54fda Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Fri, 12 Jun 2026 21:26:03 +0800 Subject: [PATCH 266/675] Validate data table arguments --- UPSTREAM.md | 1 + packages/tui/src/output/data-table.ts | 15 ++------ .../tui/src/output/validators/data-table.ts | 35 +++++++++++++++++++ packages/tui/tests/table.test.ts | 10 ++++++ 4 files changed, 48 insertions(+), 13 deletions(-) diff --git a/UPSTREAM.md b/UPSTREAM.md index 8effffb2..ce2187c5 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -145,6 +145,7 @@ TypeScript and OpenTUI internals. | OpenTUI adapter | `packages/tui/src/opentui.ts` | | Type contracts | `packages/tui/src/types.ts`, `packages/tui/src/contracts/*` | | Public ESM package runtime and type-consumption surface | `packages/tui/src/index.ts`, `packages/tui/src/form/builder/index.ts`, `packages/tui/src/output/notify.ts`, `packages/tui/src/output/notify/*`, `packages/acceptance/tests/package.test.ts` | +| Data table prompt argument validation layer | `packages/tui/src/output/validators/data-table.ts`, `packages/tui/src/output/data-table.ts`, `packages/tui/tests/table.test.ts` | ## Remaining Exact-Parity Work diff --git a/packages/tui/src/output/data-table.ts b/packages/tui/src/output/data-table.ts index 1663d415..fdb5a896 100644 --- a/packages/tui/src/output/data-table.ts +++ b/packages/tui/src/output/data-table.ts @@ -2,6 +2,7 @@ import { promptUntilValid } from '#tui/prompt'; 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 { parseDataTablePromptOptions } from '#tui/output/validators/data-table'; import type { DataTableSelectionReadResult } from '#tui/output/data-table/types'; import type { DataTablePromptOptions, DataTableRow } from '#tui/types'; @@ -30,19 +31,7 @@ export async function datatable( transform: DataTablePromptOptions['transform'] = undefined, filter: DataTablePromptOptions['filter'] = undefined, ): Promise { - const options = Array.isArray(optionsOrHeaders) - ? { - filter, - headers: optionsOrHeaders, - hint, - message: label, - required, - rows: rows ?? [], - scroll, - transform, - validate, - } - : optionsOrHeaders; + const options = parseDataTablePromptOptions(optionsOrHeaders, rows, scroll, label, hint, required, validate, transform, filter); const headers = options.headers ?? deriveDataTableHeaders(options.rows); diff --git a/packages/tui/src/output/validators/data-table.ts b/packages/tui/src/output/validators/data-table.ts index da773176..65d74232 100644 --- a/packages/tui/src/output/validators/data-table.ts +++ b/packages/tui/src/output/validators/data-table.ts @@ -17,11 +17,16 @@ const dataTableRowSchema = z.union([dataObjectRowSchema, dataTableArrayRowSchema const dataTablePromptOptionsSchema = z .object({ + headers: z.array(z.string()).optional(), message: z.string(), rows: z.array(dataTableRowSchema), + scroll: z.number().optional(), }) .passthrough(); +const dataTableHeadersSchema = z.array(z.string()); +const dataTableRowsSchema = z.array(dataTableRowSchema); + export type DataTableRowShape = | { kind: 'array'; @@ -50,6 +55,36 @@ export const dataTableStepName = (value: unknown): string | undefined => { 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 = dataTablePromptOptionsSchema.safeParse(optionsOrHeaders); + + if (promptOptions.success) { + return promptOptions.data as DataTablePromptOptions; + } + + return { + filter, + headers: dataTableHeadersSchema.parse(optionsOrHeaders), + hint, + message: label, + required, + rows: rows === null ? [] : (dataTableRowsSchema.parse(rows) as Array>), + scroll, + transform, + validate, + }; +}; + export const parseDataTableRowShape = (row: DataTableRow): DataTableRowShape => { const objectRow = dataObjectRowSchema.safeParse(row); diff --git a/packages/tui/tests/table.test.ts b/packages/tui/tests/table.test.ts index 5d0a9680..a154b834 100644 --- a/packages/tui/tests/table.test.ts +++ b/packages/tui/tests/table.test.ts @@ -217,6 +217,16 @@ describe('data table prompt', () => { expect(result).toBe('ALPHA'); }); + it('rejects invalid data table runtime shapes through the validator layer', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + await expect(datatable(null as never)).rejects.toThrow(); + + await expect(datatable(['Name'], [null] as never)).rejects.toThrow(); + }); + }); + it('renders data table submitted frames only after validation passes', async () => { const output = createMemoryOutput(); From 4c57965c27e682a05e2e93f1f57f5e281f074281 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Fri, 12 Jun 2026 21:44:03 +0800 Subject: [PATCH 267/675] Validate table row cells --- UPSTREAM.md | 1 + packages/tui/src/output/table.ts | 14 ++------------ packages/tui/src/output/validators/table.ts | 16 ++++++++++++++++ packages/tui/tests/table.test.ts | 11 +++++++++++ 4 files changed, 30 insertions(+), 12 deletions(-) diff --git a/UPSTREAM.md b/UPSTREAM.md index ce2187c5..08099a3b 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -146,6 +146,7 @@ TypeScript and OpenTUI internals. | Type contracts | `packages/tui/src/types.ts`, `packages/tui/src/contracts/*` | | Public ESM package runtime and type-consumption surface | `packages/tui/src/index.ts`, `packages/tui/src/form/builder/index.ts`, `packages/tui/src/output/notify.ts`, `packages/tui/src/output/notify/*`, `packages/acceptance/tests/package.test.ts` | | Data table prompt argument validation layer | `packages/tui/src/output/validators/data-table.ts`, `packages/tui/src/output/data-table.ts`, `packages/tui/tests/table.test.ts` | +| Table row cell normalization validation layer | `packages/tui/src/output/validators/table.ts`, `packages/tui/src/output/table.ts`, `packages/tui/tests/table.test.ts` | ## Remaining Exact-Parity Work diff --git a/packages/tui/src/output/table.ts b/packages/tui/src/output/table.ts index 2ac0fa7e..64800c37 100644 --- a/packages/tui/src/output/table.ts +++ b/packages/tui/src/output/table.ts @@ -1,23 +1,13 @@ import { promptEnvironment } from '#tui/environment'; -import { parseTableOptions } from '#tui/output/validators/table'; +import { parseTableOptions, tableRowCells } from '#tui/output/validators/table'; import { renderGrid } from '#tui/output/grid'; import { renderTable } from '#tui/theme'; import type { TableOptions } from '#tui/types'; -const stringify = (value: string | number | boolean | null | undefined): string => { - return value === null || value === undefined ? '' : String(value); -}; - export const table = (headersOrOptions: TableOptions | string[] = [], rows: TableOptions['rows'] | null = null): void => { const options = parseTableOptions(headersOrOptions, rows); - const normalizedRows = options.rows.map((row) => { - if (Array.isArray(row)) { - return row.map(stringify); - } - - return options.headers.map((header) => stringify(row[header])); - }); + const normalizedRows = options.rows.map((row) => tableRowCells(row, options.headers)); promptEnvironment().output.write(`${renderTable(options.headers, normalizedRows)}\n`); }; diff --git a/packages/tui/src/output/validators/table.ts b/packages/tui/src/output/validators/table.ts index 69fc6c68..df2534f0 100644 --- a/packages/tui/src/output/validators/table.ts +++ b/packages/tui/src/output/validators/table.ts @@ -26,6 +26,10 @@ const inferredTableHeaders = (rows: TableOptions['rows']): string[] => { return Object.keys(firstRow); }; +const stringifyTableCell = (value: TableCell): string => { + return value === null || value === undefined ? '' : String(value); +}; + export const isTableOptions = (value: unknown): value is TableOptions => { return tableOptionsSchema.safeParse(value).success; }; @@ -58,3 +62,15 @@ export const parseTableOptions = (headersOrOptions: unknown = [], rows: unknown rows: rows === null ? [] : tableRowsSchema.parse(rows), }; }; + +export const tableRowCells = (row: TableOptions['rows'][number], headers: string[]): string[] => { + const arrayRow = z.array(tableCellSchema).safeParse(row); + + if (arrayRow.success) { + return arrayRow.data.map(stringifyTableCell); + } + + const recordRow = z.record(z.string(), tableCellSchema).parse(row); + + return headers.map((header) => stringifyTableCell(recordRow[header])); +}; diff --git a/packages/tui/tests/table.test.ts b/packages/tui/tests/table.test.ts index a154b834..ccc29b5e 100644 --- a/packages/tui/tests/table.test.ts +++ b/packages/tui/tests/table.test.ts @@ -92,6 +92,17 @@ describe('table output', () => { expect(output.text()).toContain('| TypeScript | Prompts | Port |'); }); + it('normalizes table row cells through the validator layer', async () => { + const output = createMemoryOutput(); + + await withPromptEnvironment({ output, error: output }, async () => { + table(['Name', 'Enabled', 'Notes'], [{ Enabled: false, Name: 'Ollin', Notes: null }]); + }); + + expect(output.text()).toContain('| Name | Enabled | Notes |'); + expect(output.text()).toContain('| Ollin | false | |'); + }); + it('renders tables without headers from row-only input', async () => { const output = createMemoryOutput(); From 248e166c9bcf7043171924fb54d0728d61ba711d Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Fri, 12 Jun 2026 22:17:08 +0800 Subject: [PATCH 268/675] Validate choice option shapes --- UPSTREAM.md | 1 + packages/tui/src/concerns/choices.ts | 10 ++++---- .../tui/src/concerns/validators/choice.ts | 23 ++++++++++++++++++- packages/tui/tests/choices.test.ts | 17 ++++++++++++++ 4 files changed, 45 insertions(+), 6 deletions(-) diff --git a/UPSTREAM.md b/UPSTREAM.md index 08099a3b..23a5deed 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -147,6 +147,7 @@ TypeScript and OpenTUI internals. | Public ESM package runtime and type-consumption surface | `packages/tui/src/index.ts`, `packages/tui/src/form/builder/index.ts`, `packages/tui/src/output/notify.ts`, `packages/tui/src/output/notify/*`, `packages/acceptance/tests/package.test.ts` | | Data table prompt argument validation layer | `packages/tui/src/output/validators/data-table.ts`, `packages/tui/src/output/data-table.ts`, `packages/tui/tests/table.test.ts` | | Table row cell normalization validation layer | `packages/tui/src/output/validators/table.ts`, `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/choices.ts`, `packages/tui/tests/choices.test.ts` | ## Remaining Exact-Parity Work diff --git a/packages/tui/src/concerns/choices.ts b/packages/tui/src/concerns/choices.ts index b1a0c99e..4c652e37 100644 --- a/packages/tui/src/concerns/choices.ts +++ b/packages/tui/src/concerns/choices.ts @@ -1,22 +1,22 @@ import { promptEnvironment } from '#tui/environment'; import { parseChoiceAnswerIndex, parseChoiceRecordKey } from '#tui/concerns/validators/choice-answer'; -import { parseChoice, parseChoiceRecord } from '#tui/concerns/validators/choice'; +import { parseChoice, parseChoiceOptions } from '#tui/concerns/validators/choice'; import { parseOptionalScrollSize } from '#tui/concerns/validators/scroll'; import { renderScrollbarRows } from '#tui/concerns/scrollbar'; import { cyan, dim } from '#tui/theme/styles'; import type { Choice, ChoiceOptions } from '#tui/types'; export const normalizeChoices = (options: ChoiceOptions): Array> => { - if (!Array.isArray(options)) { - const parsed = parseChoiceRecord(options); + const parsed = parseChoiceOptions(options); - return Object.entries(parsed ?? {}).map(([value, label]) => ({ + if (parsed.kind === 'record') { + return Object.entries(parsed.options).map(([value, label]) => ({ label, value: parseChoiceRecordKey(value) as T, })); } - return options.map((choice) => { + return parsed.options.map((choice) => { const parsed = parseChoice(choice); if (parsed) { diff --git a/packages/tui/src/concerns/validators/choice.ts b/packages/tui/src/concerns/validators/choice.ts index 8fc1408c..3a9aeabf 100644 --- a/packages/tui/src/concerns/validators/choice.ts +++ b/packages/tui/src/concerns/validators/choice.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import type { Choice } from '#tui/types'; +import type { Choice, ChoiceOptions } from '#tui/types'; const choiceSchema = z .object({ @@ -11,6 +11,17 @@ const choiceSchema = z .passthrough(); const choiceRecordSchema = z.record(z.string(), z.string()); +const choiceOptionsListSchema = z.array(z.unknown()); + +export type ParsedChoiceOptions = + | { + kind: 'list'; + options: Array | T>; + } + | { + kind: 'record'; + options: Record; + }; export const parseChoice = (value: unknown): Choice | null => { const parsed = choiceSchema.safeParse(value); @@ -23,3 +34,13 @@ export const parseChoiceRecord = (value: unknown): Record | null return parsed.success ? parsed.data : null; }; + +export const parseChoiceOptions = (value: ChoiceOptions): ParsedChoiceOptions => { + const record = choiceRecordSchema.safeParse(value); + + if (record.success) { + return { kind: 'record', options: record.data }; + } + + return { kind: 'list', options: choiceOptionsListSchema.parse(value) as Array | T> }; +}; diff --git a/packages/tui/tests/choices.test.ts b/packages/tui/tests/choices.test.ts index 5cac02d5..f20c5b4c 100644 --- a/packages/tui/tests/choices.test.ts +++ b/packages/tui/tests/choices.test.ts @@ -302,6 +302,23 @@ describe('choice prompts', () => { expect(result).toBe(2); }); + it('normalizes keyed select options through the validator layer', async () => { + const output = createMemoryOutput(); + + const result = await withPromptEnvironment( + { + input: createScriptedInput([Key.enter]), + output, + error: output, + interactive: true, + }, + () => select({ message: 'Pick one', options: { 1: 'First', second: 'Second' } }), + ); + + expect(result).toBe(1); + expect(output.text()).toContain('First'); + }); + it('starts select prompts on the default value', async () => { const output = createMemoryOutput(); From f3d9bbbbad1666b87f9b1c19cd149a937d888a16 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Fri, 12 Jun 2026 22:26:01 +0800 Subject: [PATCH 269/675] Validate required values --- UPSTREAM.md | 1 + packages/tui/src/validators/required.ts | 4 +++- packages/tui/tests/prompt.test.ts | 4 ++++ 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/UPSTREAM.md b/UPSTREAM.md index 23a5deed..66afca10 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -148,6 +148,7 @@ TypeScript and OpenTUI internals. | Data table prompt argument validation layer | `packages/tui/src/output/validators/data-table.ts`, `packages/tui/src/output/data-table.ts`, `packages/tui/tests/table.test.ts` | | Table row cell normalization validation layer | `packages/tui/src/output/validators/table.ts`, `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/choices.ts`, `packages/tui/tests/choices.test.ts` | +| Required value validation layer | `packages/tui/src/validators/required.ts`, `packages/tui/tests/prompt.test.ts` | ## Remaining Exact-Parity Work diff --git a/packages/tui/src/validators/required.ts b/packages/tui/src/validators/required.ts index b9d643a1..5ee326bd 100644 --- a/packages/tui/src/validators/required.ts +++ b/packages/tui/src/validators/required.ts @@ -2,8 +2,10 @@ import { z } from 'zod'; const requiredOptionSchema = z.union([z.boolean(), z.string()]).optional(); +const invalidRequiredValueSchema = z.union([z.literal(''), z.literal(false), z.null(), z.undefined(), z.array(z.unknown()).length(0)]); + const isInvalidRequiredValue = (value: unknown): boolean => { - return value === '' || value === false || value === null || value === undefined || (Array.isArray(value) && value.length === 0); + return invalidRequiredValueSchema.safeParse(value).success; }; export const requiredMessage = (value: unknown, required?: boolean | string): string | undefined => { diff --git a/packages/tui/tests/prompt.test.ts b/packages/tui/tests/prompt.test.ts index 9920701b..f949ba1f 100644 --- a/packages/tui/tests/prompt.test.ts +++ b/packages/tui/tests/prompt.test.ts @@ -17,6 +17,10 @@ describe('prompt validation', () => { expect(ensureRequired(null, true)).toBe('Required.'); }); + it('accepts non-empty required arrays', () => { + expect(ensureRequired(['choice'], true)).toBeUndefined(); + }); + it('uses custom required messages and treats empty strings as required configuration', () => { expect(ensureRequired('', 'Choose something.')).toBe('Choose something.'); expect(ensureRequired('', '')).toBe('Required.'); From c5649ffdf9ad5489682fd00d68b01b1c50f3cf7c Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Fri, 12 Jun 2026 22:39:30 +0800 Subject: [PATCH 270/675] Validate key match values --- UPSTREAM.md | 1 + packages/tui/src/key/match.ts | 19 ++----------------- packages/tui/src/key/validators/match.ts | 10 ++++++++++ packages/tui/src/validators/required.ts | 1 - packages/tui/tests/key.test.ts | 5 +++++ 5 files changed, 18 insertions(+), 18 deletions(-) create mode 100644 packages/tui/src/key/validators/match.ts diff --git a/UPSTREAM.md b/UPSTREAM.md index 66afca10..504304d4 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -149,6 +149,7 @@ TypeScript and OpenTUI internals. | Table row cell normalization validation layer | `packages/tui/src/output/validators/table.ts`, `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/choices.ts`, `packages/tui/tests/choices.test.ts` | | Required value validation layer | `packages/tui/src/validators/required.ts`, `packages/tui/tests/prompt.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` | ## Remaining Exact-Parity Work diff --git a/packages/tui/src/key/match.ts b/packages/tui/src/key/match.ts index bb871507..a5204feb 100644 --- a/packages/tui/src/key/match.ts +++ b/packages/tui/src/key/match.ts @@ -1,21 +1,6 @@ +import { parseKeyMatchValues } from '#tui/key/validators/match'; import type { KeyValue } from '#tui/key/types'; export const oneOf = (keys: KeyValue[], match: string): string | undefined => { - for (const key of keys) { - if (Array.isArray(key)) { - const nested = oneOf([...key], match); - - if (nested !== undefined) { - return nested; - } - - continue; - } - - if (key === match) { - return match; - } - } - - return undefined; + return parseKeyMatchValues(keys).includes(match) ? match : undefined; }; diff --git a/packages/tui/src/key/validators/match.ts b/packages/tui/src/key/validators/match.ts new file mode 100644 index 00000000..72bb67d8 --- /dev/null +++ b/packages/tui/src/key/validators/match.ts @@ -0,0 +1,10 @@ +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[] => { + return keyValuesSchema.parse(values); +}; diff --git a/packages/tui/src/validators/required.ts b/packages/tui/src/validators/required.ts index 5ee326bd..a3cc8ebd 100644 --- a/packages/tui/src/validators/required.ts +++ b/packages/tui/src/validators/required.ts @@ -1,7 +1,6 @@ import { z } from 'zod'; const requiredOptionSchema = z.union([z.boolean(), z.string()]).optional(); - const invalidRequiredValueSchema = z.union([z.literal(''), z.literal(false), z.null(), z.undefined(), z.array(z.unknown()).length(0)]); const isInvalidRequiredValue = (value: unknown): boolean => { diff --git a/packages/tui/tests/key.test.ts b/packages/tui/tests/key.test.ts index 5f2e85b8..c8fbebd4 100644 --- a/packages/tui/tests/key.test.ts +++ b/packages/tui/tests/key.test.ts @@ -36,4 +36,9 @@ describe('keyboard mapping', () => { expect(oneOf([Key.home, Key.end], Key.end[3])).toBe(Key.end[3]); expect(oneOf([Key.home, Key.end], Key.enter)).toBeUndefined(); }); + + it('matches scalar and grouped keys through the validator layer', () => { + expect(oneOf([Key.enter, Key.home], Key.enter)).toBe(Key.enter); + expect(oneOf([Key.enter, Key.home], Key.home[0])).toBe(Key.home[0]); + }); }); From b7f2da7429682a7a2a63fb2e770909a09d8657d3 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Sat, 13 Jun 2026 15:22:11 +0800 Subject: [PATCH 271/675] Preserve empty form number defaults --- UPSTREAM.md | 1 + packages/tui/src/form/builder/prompts/basic.ts | 4 ++-- packages/tui/tests/form.test.ts | 17 +++++++++++++++++ 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/UPSTREAM.md b/UPSTREAM.md index 504304d4..450495e7 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -150,6 +150,7 @@ TypeScript and OpenTUI internals. | Choice option shape validation layer | `packages/tui/src/concerns/validators/choice.ts`, `packages/tui/src/concerns/choices.ts`, `packages/tui/tests/choices.test.ts` | | Required value validation layer | `packages/tui/src/validators/required.ts`, `packages/tui/tests/prompt.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/tests/form.test.ts` | ## Remaining Exact-Parity Work diff --git a/packages/tui/src/form/builder/prompts/basic.ts b/packages/tui/src/form/builder/prompts/basic.ts index 476750a9..f8d12964 100644 --- a/packages/tui/src/form/builder/prompts/basic.ts +++ b/packages/tui/src/form/builder/prompts/basic.ts @@ -25,7 +25,7 @@ function numberFormStep( this: FormBuilder, optionsOrLabel: NumberPromptOptions | string, placeholder = '', - defaultValue: number | string = 0, + defaultValue: number | string = '', required: NumberPromptOptions['required'] = false, validate: NumberPromptOptions['validate'] = undefined, hint = '', @@ -36,7 +36,7 @@ function numberFormStep( transform: NumberPromptOptions['transform'] = undefined, ): FormBuilder { if (!isBasicPromptLabel(optionsOrLabel)) { - return this.add((_, previous) => number({ ...optionsOrLabel, default: previousNumber(previous, optionsOrLabel.default ?? 0) }), placeholder); + return this.add((_, previous) => number({ ...optionsOrLabel, default: previousNumber(previous, optionsOrLabel.default ?? '') }), placeholder); } return this.add((_, previous) => number(optionsOrLabel, placeholder, previousNumber(previous, defaultValue), required, validate, hint, min, max, step, transform), name); diff --git a/packages/tui/tests/form.test.ts b/packages/tui/tests/form.test.ts index 93ebddb7..ef5ecfb3 100644 --- a/packages/tui/tests/form.test.ts +++ b/packages/tui/tests/form.test.ts @@ -140,6 +140,23 @@ describe('form builder', () => { expect(responses.count).toBe(8); }); + it('preserves empty number defaults in form steps', async () => { + const output = createMemoryOutput(); + + const responses = await withPromptEnvironment( + { + input: createScriptedInput([Key.enter, Key.enter]), + output, + error: output, + interactive: true, + }, + () => form().number('Count', '', undefined, false, undefined, '', undefined, undefined, undefined, 'labelCount').number({ message: 'Size' }, 'objectCount').submit(), + ); + + expect(responses.labelCount).toBe(''); + expect(responses.objectCount).toBe(''); + }); + it('runs object-option basic prompt form steps', async () => { const output = createMemoryOutput(); From 627f5dd5c63aab430141e5709f35d4247be007c0 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Sat, 13 Jun 2026 16:52:15 +0800 Subject: [PATCH 272/675] chore(shell): move app stub into console shell workspace --- console/.gitignore | 1 + console/shell/.gitignore | 2 ++ console/shell/go.mod | 3 +++ console/shell/package.json | 10 ++++++++++ console/shell/portal/main.go | 7 +++++++ pnpm-lock.yaml | 2 ++ pnpm-workspace.yaml | 1 + turbo.json | 2 +- 8 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 console/.gitignore create mode 100644 console/shell/.gitignore create mode 100644 console/shell/go.mod create mode 100644 console/shell/package.json create mode 100644 console/shell/portal/main.go diff --git a/console/.gitignore b/console/.gitignore new file mode 100644 index 00000000..e660fd93 --- /dev/null +++ b/console/.gitignore @@ -0,0 +1 @@ +bin/ diff --git a/console/shell/.gitignore b/console/shell/.gitignore new file mode 100644 index 00000000..3515c4b9 --- /dev/null +++ b/console/shell/.gitignore @@ -0,0 +1,2 @@ +*.test +coverage.out diff --git a/console/shell/go.mod b/console/shell/go.mod new file mode 100644 index 00000000..904edf14 --- /dev/null +++ b/console/shell/go.mod @@ -0,0 +1,3 @@ +module github.com/oullin/tui + +go 1.26.3 diff --git a/console/shell/package.json b/console/shell/package.json new file mode 100644 index 00000000..ab6212f2 --- /dev/null +++ b/console/shell/package.json @@ -0,0 +1,10 @@ +{ + "name": "@ollin/shell", + "version": "0.1.0", + "private": true, + "scripts": { + "build": "go build -o ../bin/shell ./portal", + "test": "go test ./...", + "typecheck": "go test ./... -run '^$'" + } +} diff --git a/console/shell/portal/main.go b/console/shell/portal/main.go new file mode 100644 index 00000000..6480e315 --- /dev/null +++ b/console/shell/portal/main.go @@ -0,0 +1,7 @@ +package main + +import "fmt" + +func main() { + fmt.Println("Oullin TUI shell") +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e78e003a..92d2dac4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,6 +27,8 @@ importers: specifier: ^4.1.5 version: 4.1.8(@types/node@25.9.2)(vite@8.0.16(@types/node@25.9.2)) + console/shell: {} + packages/acceptance: dependencies: '@ollin/tui': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ff9eb2d5..5d048023 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,5 +1,6 @@ packages: - "packages/*" + - "console/shell" onlyBuiltDependencies: - "@opentui/core" diff --git a/turbo.json b/turbo.json index 7e037ab3..184e420f 100644 --- a/turbo.json +++ b/turbo.json @@ -3,7 +3,7 @@ "tasks": { "build": { "dependsOn": ["^build"], - "outputs": ["dist/**"] + "outputs": ["dist/**", "console/bin/**"] }, "test": { "dependsOn": ["^test"], From 2423fe2edcf168a4349c1e9f9b82e06ab82e4b06 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Sat, 13 Jun 2026 16:55:28 +0800 Subject: [PATCH 273/675] feat(shell): port terminal key string and theme primitives --- console/shell/go.mod | 4 + console/shell/go.sum | 4 + console/shell/internal/key/key.go | 175 ++++++++++ console/shell/internal/key/key_test.go | 61 ++++ console/shell/internal/strings/strings.go | 300 ++++++++++++++++++ .../shell/internal/strings/strings_test.go | 97 ++++++ console/shell/internal/terminal/terminal.go | 93 ++++++ .../shell/internal/terminal/terminal_test.go | 39 +++ console/shell/internal/theme/box.go | 79 +++++ console/shell/internal/theme/styles.go | 63 ++++ console/shell/internal/theme/theme.go | 121 +++++++ console/shell/internal/theme/theme_test.go | 47 +++ 12 files changed, 1083 insertions(+) create mode 100644 console/shell/go.sum create mode 100644 console/shell/internal/key/key.go create mode 100644 console/shell/internal/key/key_test.go create mode 100644 console/shell/internal/strings/strings.go create mode 100644 console/shell/internal/strings/strings_test.go create mode 100644 console/shell/internal/terminal/terminal.go create mode 100644 console/shell/internal/terminal/terminal_test.go create mode 100644 console/shell/internal/theme/box.go create mode 100644 console/shell/internal/theme/styles.go create mode 100644 console/shell/internal/theme/theme.go create mode 100644 console/shell/internal/theme/theme_test.go diff --git a/console/shell/go.mod b/console/shell/go.mod index 904edf14..efdb8bef 100644 --- a/console/shell/go.mod +++ b/console/shell/go.mod @@ -1,3 +1,7 @@ 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 diff --git a/console/shell/go.sum b/console/shell/go.sum new file mode 100644 index 00000000..dfdcfc87 --- /dev/null +++ b/console/shell/go.sum @@ -0,0 +1,4 @@ +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/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU= +github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= diff --git a/console/shell/internal/key/key.go b/console/shell/internal/key/key.go new file mode 100644 index 00000000..775aca55 --- /dev/null +++ b/console/shell/internal/key/key.go @@ -0,0 +1,175 @@ +package key + +type Value []string + +type Event struct { + Name string + Sequence string + Ctrl bool + Meta bool + Shift bool +} + +var ( + Up = Value{"\x1b[A"} + ShiftUp = Value{"\x1b[1;2A"} + PageUp = Value{"\x1b[5~"} + Down = Value{"\x1b[B"} + ShiftDown = Value{"\x1b[1;2B"} + PageDown = Value{"\x1b[6~"} + Right = Value{"\x1b[C"} + Left = Value{"\x1b[D"} + UpArrow = Value{"\x1bOA"} + DownArrow = Value{"\x1bOB"} + RightArrow = Value{"\x1bOC"} + LeftArrow = Value{"\x1bOD"} + Escape = Value{"\x1b"} + Delete = Value{"\x1b[3~"} + Backspace = Value{"\x7f"} + Enter = Value{"\n"} + Space = Value{" "} + Tab = Value{"\t"} + ShiftTab = Value{"\x1b[Z"} + Home = Value{"\x1b[1~", "\x1bOH", "\x1b[H", "\x1b[7~"} + End = Value{"\x1b[4~", "\x1bOF", "\x1b[F", "\x1b[8~"} + CtrlC = Value{"\x03"} + CtrlP = Value{"\x10"} + CtrlN = Value{"\x0e"} + CtrlF = Value{"\x06"} + CtrlB = Value{"\x02"} + CtrlH = Value{"\x08"} + CtrlA = Value{"\x01"} + CtrlD = Value{"\x04"} + CtrlE = Value{"\x05"} + CtrlU = Value{"\x15"} + OptionBackspace = Value{"\x1b\x7f"} +) + +var named = map[string]Value{ + "up": Up, + "shiftUp": ShiftUp, + "pageUp": PageUp, + "down": Down, + "shiftDown": ShiftDown, + "pageDown": PageDown, + "right": Right, + "left": Left, + "upArrow": UpArrow, + "downArrow": DownArrow, + "rightArrow": RightArrow, + "leftArrow": LeftArrow, + "escape": Escape, + "delete": Delete, + "backspace": Backspace, + "enter": Enter, + "space": Space, + "tab": Tab, + "shiftTab": ShiftTab, + "home": Home, + "end": End, + "ctrlC": CtrlC, + "ctrlP": CtrlP, + "ctrlN": CtrlN, + "ctrlF": CtrlF, + "ctrlB": CtrlB, + "ctrlH": CtrlH, + "ctrlA": CtrlA, + "ctrlD": CtrlD, + "ctrlE": CtrlE, + "ctrlU": CtrlU, + "optionBackspace": OptionBackspace, +} + +var ctrlKeys = map[string]Value{ + "a": CtrlA, + "b": CtrlB, + "c": CtrlC, + "d": CtrlD, + "e": CtrlE, + "f": CtrlF, + "h": CtrlH, + "n": CtrlN, + "p": CtrlP, + "u": CtrlU, +} + +var aliases = map[string]Value{ + "pagedown": PageDown, + "pageup": PageUp, + "return": Enter, +} + +func First(value Value) string { + if len(value) == 0 { + return "" + } + + return value[0] +} + +func FromEvent(event Event) string { + name := lowerASCII(event.Name) + + if event.Ctrl && name != "" { + if value, ok := ctrlKeys[name]; ok { + return First(value) + } + } + + if event.Meta && event.Name == "backspace" { + return First(OptionBackspace) + } + + if event.Shift && name == "tab" { + return First(ShiftTab) + } + + if name != "" { + if value, ok := aliases[name]; ok { + return First(value) + } + } + + if event.Name != "" { + if value, ok := named[event.Name]; ok { + return First(value) + } + } + + if event.Sequence == " " { + return First(Space) + } + + if event.Sequence == "\r" || event.Sequence == "\n" { + return First(Enter) + } + + if event.Name != "" { + return event.Name + } + + return event.Sequence +} + +func OneOf(keys []Value, match string) (string, bool) { + for _, value := range keys { + for _, candidate := range value { + if candidate == match { + return match, true + } + } + } + + return "", false +} + +func lowerASCII(value string) string { + bytes := []byte(value) + for index, char := range bytes { + if char >= 'A' && char <= 'Z' { + bytes[index] = char + ('a' - 'A') + } + } + + return string(bytes) +} diff --git a/console/shell/internal/key/key_test.go b/console/shell/internal/key/key_test.go new file mode 100644 index 00000000..713ec8a3 --- /dev/null +++ b/console/shell/internal/key/key_test.go @@ -0,0 +1,61 @@ +package key + +import "testing" + +func TestFromEventMapsNamedKeys(t *testing.T) { + if got := FromEvent(Event{Name: "enter"}); got != First(Enter) { + t.Fatalf("FromEvent() = %q, want %q", got, First(Enter)) + } +} + +func TestFromEventMapsAliases(t *testing.T) { + tests := []struct { + name string + want string + }{ + {name: "return", want: First(Enter)}, + {name: "pageup", want: First(PageUp)}, + {name: "pagedown", want: First(PageDown)}, + } + + for _, test := range tests { + if got := FromEvent(Event{Name: test.name}); got != test.want { + t.Fatalf("FromEvent(%q) = %q, want %q", test.name, got, test.want) + } + } +} + +func TestFromEventMapsSequencesAndModifiers(t *testing.T) { + tests := []struct { + event Event + want string + }{ + {event: Event{Sequence: " "}, want: First(Space)}, + {event: Event{Sequence: "\n"}, want: First(Enter)}, + {event: Event{Ctrl: true, Name: "c"}, want: First(CtrlC)}, + {event: Event{Ctrl: true, Name: "n"}, want: First(CtrlN)}, + {event: Event{Ctrl: true, Name: "U"}, want: First(CtrlU)}, + {event: Event{Meta: true, Name: "backspace"}, want: First(OptionBackspace)}, + {event: Event{Shift: true, Name: "tab"}, want: First(ShiftTab)}, + } + + for _, test := range tests { + if got := FromEvent(test.event); got != test.want { + t.Fatalf("FromEvent(%+v) = %q, want %q", test.event, got, test.want) + } + } +} + +func TestOneOfMatchesGroupedKeys(t *testing.T) { + if got, ok := OneOf([]Value{Home, End}, Home[2]); !ok || got != Home[2] { + t.Fatalf("OneOf(Home[2]) = %q, %v", got, ok) + } + + if got, ok := OneOf([]Value{Home, End}, End[3]); !ok || got != End[3] { + t.Fatalf("OneOf(End[3]) = %q, %v", got, ok) + } + + if _, ok := OneOf([]Value{Home, End}, First(Enter)); ok { + t.Fatalf("OneOf(Enter) matched unexpectedly") + } +} diff --git a/console/shell/internal/strings/strings.go b/console/shell/internal/strings/strings.go new file mode 100644 index 00000000..4d1bcbb2 --- /dev/null +++ b/console/shell/internal/strings/strings.go @@ -0,0 +1,300 @@ +package strings + +import ( + stdstrings "strings" + "unicode/utf8" + + "github.com/mattn/go-runewidth" +) + +const esc = "\x1b" + +type AnsiSegment struct { + Codes string + Text string +} + +var resetCodes = map[string]bool{ + "0": true, + "22": true, + "23": true, + "24": true, + "27": true, + "29": true, + "39": true, + "49": true, +} + +func VisibleWidth(value string) int { + return runewidth.StringWidth(ParseAnsiText(value)) +} + +func Truncate(value string, width int, marker ...string) string { + suffix := "..." + if len(marker) > 0 { + suffix = marker[0] + } + + if width <= 0 { + return "" + } + + if VisibleWidth(value) <= width { + return value + } + + if width <= VisibleWidth(suffix) { + clipped := "" + for _, char := range suffix { + next := clipped + string(char) + if VisibleWidth(next) > width { + break + } + clipped = next + } + + return clipped + } + + result := "" + activeCodes := "" + markerWidth := VisibleWidth(suffix) + + for _, segment := range ParseAnsiSegments(value) { + if segment.Codes != activeCodes { + if activeCodes != "" { + result += ansiCloseSequence(activeCodes) + } + if segment.Codes != "" { + result += segment.Codes + } + activeCodes = segment.Codes + } + + for _, char := range segment.Text { + if VisibleWidth(result+string(char))+markerWidth > width { + if activeCodes != "" { + result += ansiCloseSequence(activeCodes) + } + + return result + suffix + } + + result += string(char) + } + } + + return result +} + +func ParseAnsiSegments(value string) []AnsiSegment { + segments := make([]AnsiSegment, 0) + currentCodes := "" + currentText := "" + + for index := 0; index < len(value); { + if value[index] == '\x1b' && index+1 < len(value) && value[index+1] == '[' { + if currentText != "" { + segments = append(segments, AnsiSegment{Codes: currentCodes, Text: currentText}) + currentText = "" + } + + start := index + index += 2 + for index < len(value) && value[index] != 'm' { + index++ + } + + if index < len(value) && value[index] == 'm' { + index++ + sequence := value[start:index] + code := sequence[2 : len(sequence)-1] + if resetCodes[code] { + currentCodes = "" + } else { + currentCodes = sequence + } + continue + } + + currentText += value[start:] + break + } + + runeValue, size := utf8.DecodeRuneInString(value[index:]) + currentText += string(runeValue) + index += size + } + + if currentText != "" { + segments = append(segments, AnsiSegment{Codes: currentCodes, Text: currentText}) + } + + return segments +} + +func ParseAnsiText(value string) string { + var builder stdstrings.Builder + + for _, segment := range ParseAnsiSegments(value) { + builder.WriteString(segment.Text) + } + + return builder.String() +} + +func Wrap(value string, width int) []string { + plainLines := plainWrap(ParseAnsiText(value), width) + characters := codedCharacters(value) + result := make([]string, 0, len(plainLines)) + characterIndex := 0 + + for _, plainLine := range plainLines { + line := "" + activeCodes := "" + + for _, plainCharacter := range plainLine { + for characterIndex < len(characters) && characters[characterIndex].Text != string(plainCharacter) { + if characters[characterIndex].Text == " " { + characterIndex++ + continue + } + + break + } + + if characterIndex >= len(characters) { + line += string(plainCharacter) + continue + } + + character := characters[characterIndex] + if character.Codes != activeCodes { + if activeCodes != "" { + line += "\x1b[0m" + } + if character.Codes != "" { + line += character.Codes + } + activeCodes = character.Codes + } + + line += string(plainCharacter) + characterIndex++ + } + + if activeCodes != "" { + line += ansiCloseSequence(activeCodes) + } + + result = append(result, line) + } + + return result +} + +func ansiCloseSequence(codes string) string { + code := codes[2 : len(codes)-1] + + switch code { + case "1", "2": + return esc + "[22m" + case "3": + return esc + "[23m" + case "4": + return esc + "[24m" + case "7": + return esc + "[27m" + case "9": + return esc + "[29m" + default: + return esc + "[0m" + } +} + +func codedCharacters(value string) []AnsiSegment { + characters := make([]AnsiSegment, 0) + + for _, segment := range ParseAnsiSegments(value) { + for _, char := range segment.Text { + characters = append(characters, AnsiSegment{Codes: segment.Codes, Text: string(char)}) + } + } + + return characters +} + +func plainWrap(value string, width int) []string { + if width <= 0 { + return []string{value} + } + + lines := make([]string, 0) + for _, originalLine := range stdstrings.Split(value, "\n") { + line := "" + + for _, word := range splitWhitespace(originalLine) { + if VisibleWidth(line+word) <= width { + line += word + continue + } + + if line != "" { + lines = append(lines, stdstrings.TrimRight(line, " \t\r\n")) + line = "" + } + + if VisibleWidth(word) > width { + chunk := "" + for _, char := range word { + next := chunk + string(char) + if VisibleWidth(next) > width { + if chunk != "" { + lines = append(lines, chunk) + } + chunk = "" + } + chunk += string(char) + } + line = chunk + continue + } + + line = stdstrings.TrimLeft(word, " \t\r\n") + } + + lines = append(lines, stdstrings.TrimRight(line, " \t\r\n")) + } + + return lines +} + +func splitWhitespace(value string) []string { + if value == "" { + return []string{""} + } + + parts := make([]string, 0) + current := "" + currentWhitespace := isWhitespace(rune(value[0])) + + for _, char := range value { + whitespace := isWhitespace(char) + if current != "" && whitespace != currentWhitespace { + parts = append(parts, current) + current = "" + } + current += string(char) + currentWhitespace = whitespace + } + + if current != "" { + parts = append(parts, current) + } + + return parts +} + +func isWhitespace(char rune) bool { + return char == ' ' || char == '\t' || char == '\n' || char == '\r' +} diff --git a/console/shell/internal/strings/strings_test.go b/console/shell/internal/strings/strings_test.go new file mode 100644 index 00000000..920c086f --- /dev/null +++ b/console/shell/internal/strings/strings_test.go @@ -0,0 +1,97 @@ +package strings + +import ( + "reflect" + stdstrings "strings" + "testing" +) + +func TestVisibleWidthIgnoresANSI(t *testing.T) { + if got := VisibleWidth("\x1b[31mPrompts\x1b[39m"); got != 7 { + t.Fatalf("VisibleWidth() = %d, want 7", got) + } +} + +func TestTruncate(t *testing.T) { + if got := Truncate("Ollin Prompts", 10); got != "Ollin P..." { + t.Fatalf("Truncate() = %q", got) + } +} + +func TestTruncateClosesANSIStyles(t *testing.T) { + result := Truncate("\x1b[31mOllin Prompts\x1b[39m", 10) + + if got := ParseAnsiText(result); got != "Ollin P..." { + t.Fatalf("ParseAnsiText(truncated) = %q", got) + } + if !stdstrings.Contains(result, "\x1b[31m") { + t.Fatalf("expected red ANSI open sequence in %q", result) + } + if !stdstrings.Contains(result, "\x1b[0m...") { + t.Fatalf("expected reset before marker in %q", result) + } + if stdstrings.HasSuffix(result, "\x1b[39m") { + t.Fatalf("did not expect foreground reset suffix in %q", result) + } +} + +func TestTruncateClipsMarkers(t *testing.T) { + if got := Truncate("Ollin Prompts", 1, "…"); got != "…" { + t.Fatalf("Truncate(marker) = %q", got) + } + if got := Truncate("Ollin Prompts", 1, "東京"); got != "" { + t.Fatalf("Truncate(wide marker) = %q", got) + } +} + +func TestWrap(t *testing.T) { + if got := Wrap("Ollin Prompts", 7); !reflect.DeepEqual(got, []string{"Ollin", "Prompts"}) { + t.Fatalf("Wrap() = %#v", got) + } + + if got := Wrap("😀", 1); !reflect.DeepEqual(got, []string{"😀"}) { + t.Fatalf("Wrap(wide) = %#v", got) + } +} + +func TestParseAnsi(t *testing.T) { + if got := ParseAnsiText("\x1b[32mDone\x1b[39m"); got != "Done" { + t.Fatalf("ParseAnsiText() = %q", got) + } + + if got := ParseAnsiSegments("Hello, World!"); !reflect.DeepEqual(got, []AnsiSegment{{Text: "Hello, World!", Codes: ""}}) { + t.Fatalf("ParseAnsiSegments(plain) = %#v", got) + } + + want := []AnsiSegment{{Text: "Hello ", Codes: ""}, {Text: "Bold", Codes: "\x1b[1m"}, {Text: " World", Codes: ""}} + if got := ParseAnsiSegments("Hello \x1b[1mBold\x1b[0m World"); !reflect.DeepEqual(got, want) { + t.Fatalf("ParseAnsiSegments(styled) = %#v", got) + } + + want = []AnsiSegment{{Text: "Colored", Codes: "\x1b[38;2;255;100;50m"}} + if got := ParseAnsiSegments("\x1b[38;2;255;100;50mColored\x1b[0m"); !reflect.DeepEqual(got, want) { + t.Fatalf("ParseAnsiSegments(rgb) = %#v", got) + } +} + +func TestWrapPreservesANSI(t *testing.T) { + result := Wrap("\x1b[31mHello World\x1b[0m", 5) + + if len(result) != 2 { + t.Fatalf("Wrap() len = %d, want 2: %#v", len(result), result) + } + for _, want := range []string{"Hello", "World"} { + found := false + for _, line := range result { + found = found || stdstrings.Contains(line, want) + } + if !found { + t.Fatalf("expected wrapped output to contain %q: %#v", want, result) + } + } + for _, line := range result { + if !stdstrings.Contains(line, "\x1b[31m") || !stdstrings.HasSuffix(line, "\x1b[0m") { + t.Fatalf("wrapped line does not preserve ANSI: %q", line) + } + } +} diff --git a/console/shell/internal/terminal/terminal.go b/console/shell/internal/terminal/terminal.go new file mode 100644 index 00000000..b446c297 --- /dev/null +++ b/console/shell/internal/terminal/terminal.go @@ -0,0 +1,93 @@ +package terminal + +import ( + "io" + "math" + "os" +) + +type Size struct { + Columns int + Rows int +} + +type Color [3]int + +func DefaultSize() Size { + return Size{Columns: 80, Rows: 24} +} + +func ClearSequence() string { + return "\x1b[H\x1b[J" +} + +func TitleSequence(title string) string { + return "\x1b]0;" + title + "\x07" +} + +func EraseLineSequence() string { + return "\x1b[2K" +} + +func ErasePreviousLinesSequence(count int) string { + lines := int(math.Max(0, math.Floor(float64(count)))) + result := "" + for range lines { + result += "\x1b[1A\x1b[2K" + } + + return result +} + +func CursorToStartSequence() string { + return "\r" +} + +func HideCursorSequence() string { + return "\x1b[?25l" +} + +func ShowCursorSequence() string { + return "\x1b[?25h" +} + +func WriteClear(writer io.Writer) error { + _, err := io.WriteString(writer, ClearSequence()) + return err +} + +func WriteTitle(writer io.Writer, title string) error { + _, err := io.WriteString(writer, TitleSequence(title)) + return err +} + +func SupportsTrueColor(value ...string) bool { + colorTerm := os.Getenv("COLORTERM") + if len(value) > 0 { + colorTerm = value[0] + } + + return colorTerm == "truecolor" || colorTerm == "24bit" +} + +func DefaultForegroundColor() Color { + return Color{204, 204, 204} +} + +func DefaultBackgroundColor() Color { + return Color{0, 0, 0} +} + +func ParseColor(value []int, fallback Color) Color { + if len(value) != 3 { + return fallback + } + + for _, part := range value { + if part < 0 || part > 255 { + return fallback + } + } + + return Color{value[0], value[1], value[2]} +} diff --git a/console/shell/internal/terminal/terminal_test.go b/console/shell/internal/terminal/terminal_test.go new file mode 100644 index 00000000..c6ccf2a5 --- /dev/null +++ b/console/shell/internal/terminal/terminal_test.go @@ -0,0 +1,39 @@ +package terminal + +import "testing" + +func TestControlSequences(t *testing.T) { + if got := ClearSequence(); got != "\x1b[H\x1b[J" { + t.Fatalf("ClearSequence() = %q", got) + } + if got := TitleSequence("Demo"); got != "\x1b]0;Demo\x07" { + t.Fatalf("TitleSequence() = %q", got) + } + if got := CursorToStartSequence() + EraseLineSequence() + ErasePreviousLinesSequence(2) + ErasePreviousLinesSequence(-1) + HideCursorSequence() + ShowCursorSequence(); got != "\r\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[?25l\x1b[?25h" { + t.Fatalf("combined sequences = %q", got) + } +} + +func TestSupportsTrueColor(t *testing.T) { + if !SupportsTrueColor("truecolor") { + t.Fatalf("expected truecolor to be supported") + } + if !SupportsTrueColor("24bit") { + t.Fatalf("expected 24bit to be supported") + } + if SupportsTrueColor("256color") { + t.Fatalf("expected 256color to be unsupported") + } +} + +func TestParseColor(t *testing.T) { + if got := ParseColor([]int{1, 2, 3}, DefaultForegroundColor()); got != (Color{1, 2, 3}) { + t.Fatalf("ParseColor() = %#v", got) + } + if got := ParseColor([]int{0, 0, 300}, DefaultBackgroundColor()); got != DefaultBackgroundColor() { + t.Fatalf("ParseColor(invalid) = %#v", got) + } + if got := ParseColor([]int{1, 2}, DefaultForegroundColor()); got != DefaultForegroundColor() { + t.Fatalf("ParseColor(short) = %#v", got) + } +} diff --git a/console/shell/internal/theme/box.go b/console/shell/internal/theme/box.go new file mode 100644 index 00000000..f7245da2 --- /dev/null +++ b/console/shell/internal/theme/box.go @@ -0,0 +1,79 @@ +package theme + +import ( + stdstrings "strings" + + text "github.com/oullin/tui/internal/strings" +) + +const defaultBoxWidth = 60 + +type BoxOptions struct { + Body string + BorderStyle func(string) string + Info string + Title string + Width int +} + +func RenderBox(options BoxOptions) string { + borderStyle := options.BorderStyle + if borderStyle == nil { + borderStyle = func(value string) string { return value } + } + + width := options.Width + if width == 0 { + width = defaultBoxWidth + } + + bodyLines := stdstrings.Split(options.Body, "\n") + contentWidth := width + if titleWidth := text.VisibleWidth(options.Title); titleWidth > contentWidth { + contentWidth = titleWidth + } + for _, line := range bodyLines { + if lineWidth := text.VisibleWidth(line); lineWidth > contentWidth { + contentWidth = lineWidth + } + } + + titleWidth := text.VisibleWidth(options.Title) + titleLabel := "" + if titleWidth > 0 { + titleLabel = " " + options.Title + " " + } + + topBorderWidth := contentWidth - titleWidth + if titleWidth == 0 { + topBorderWidth += 2 + } + + renderedInfo := "" + if options.Info != "" { + renderedInfo = text.Truncate(options.Info, contentWidth-1) + } + + bottomBorderWidth := contentWidth + 2 + if renderedInfo != "" { + bottomBorderWidth = contentWidth - text.VisibleWidth(renderedInfo) + } + + lines := []string{ + borderStyle(" ┌") + titleLabel + borderStyle(stdstrings.Repeat("─", topBorderWidth)+"┐"), + } + + for _, line := range bodyLines { + lines = append(lines, borderStyle(" │")+" "+padVisible(line, contentWidth)+" "+borderStyle("│")) + } + + bottom := " └" + stdstrings.Repeat("─", bottomBorderWidth) + if renderedInfo != "" { + bottom += " " + renderedInfo + " " + } + bottom += "┘" + + lines = append(lines, borderStyle(bottom)) + + return stdstrings.Join(lines, "\n") +} diff --git a/console/shell/internal/theme/styles.go b/console/shell/internal/theme/styles.go new file mode 100644 index 00000000..f573253f --- /dev/null +++ b/console/shell/internal/theme/styles.go @@ -0,0 +1,63 @@ +package theme + +import "fmt" + +func ansi(open int, close int, value string) string { + if value == "" { + return "" + } + + return fmt.Sprintf("\x1b[%dm%s\x1b[%dm", open, value, close) +} + +func Dim(value string) string { + return ansi(2, 22, value) +} + +func Cyan(value string) string { + return ansi(36, 39, value) +} + +func Green(value string) string { + return ansi(32, 39, value) +} + +func Inverse(value string) string { + return ansi(7, 27, value) +} + +func Black(value string) string { + return ansi(30, 39, value) +} + +func Red(value string) string { + return ansi(31, 39, value) +} + +func Strikethrough(value string) string { + return ansi(9, 29, value) +} + +func White(value string) string { + return ansi(37, 39, value) +} + +func Yellow(value string) string { + return ansi(33, 39, value) +} + +func BackgroundCyan(value string) string { + return ansi(46, 49, value) +} + +func BackgroundRed(value string) string { + return ansi(41, 49, value) +} + +func ForegroundRGB(value string, red int, green int, blue int) string { + if value == "" { + return "" + } + + return fmt.Sprintf("\x1b[38;2;%d;%d;%dm%s\x1b[0m", red, green, blue, value) +} diff --git a/console/shell/internal/theme/theme.go b/console/shell/internal/theme/theme.go new file mode 100644 index 00000000..d424d367 --- /dev/null +++ b/console/shell/internal/theme/theme.go @@ -0,0 +1,121 @@ +package theme + +import ( + "fmt" + stdstrings "strings" + + text "github.com/oullin/tui/internal/strings" +) + +const ( + SymbolQuestion = "?" + SymbolAnswer = ">" + SymbolError = "!" + SymbolInfo = "i" + SymbolSelected = "●" + SymbolUnselected = "○" + SymbolPointer = "›" + SymbolSuccess = "✓" + SymbolWarning = "!" +) + +func RenderQuestion(message string, hint string) string { + suffix := "" + if hint != "" { + suffix = " " + hint + } + + return fmt.Sprintf("%s %s%s ", SymbolQuestion, message, suffix) +} + +func RenderError(message string) string { + return Red(fmt.Sprintf("%s %s", SymbolError, message)) + "\n" +} + +func RenderTable(headers []string, rows [][]string) string { + columnCount := len(headers) + for _, row := range rows { + if len(row) > columnCount { + columnCount = len(row) + } + } + + if columnCount <= 0 { + return "" + } + + widths := make([]int, columnCount) + for index := range columnCount { + widths[index] = text.VisibleWidth(valueAt(headers, index)) + for _, row := range rows { + width := text.VisibleWidth(valueAt(row, index)) + if width > widths[index] { + widths[index] = width + } + } + } + + renderRow := func(columns []string) string { + parts := make([]string, len(widths)) + for index, width := range widths { + parts[index] = padVisible(valueAt(columns, index), width) + } + + return " │ " + stdstrings.Join(parts, " │ ") + " │" + } + + renderBorder := func(left string, middle string, right string) string { + parts := make([]string, len(widths)) + for index, width := range widths { + parts[index] = stdstrings.Repeat("─", width+2) + } + + return " " + left + stdstrings.Join(parts, middle) + right + } + + top := renderBorder("┌", "┬", "┐") + divider := renderBorder("├", "┼", "┤") + bottom := renderBorder("└", "┴", "┘") + + if len(headers) == 0 { + lines := append([]string{top}, mapRows(rows, renderRow)...) + lines = append(lines, bottom) + return stdstrings.Join(lines, "\n") + } + + dimmedHeaders := make([]string, len(headers)) + for index, header := range headers { + dimmedHeaders[index] = Dim(header) + } + + lines := []string{top, renderRow(dimmedHeaders), divider} + lines = append(lines, mapRows(rows, renderRow)...) + lines = append(lines, bottom) + return stdstrings.Join(lines, "\n") +} + +func valueAt(values []string, index int) string { + if index < 0 || index >= len(values) { + return "" + } + + return values[index] +} + +func padVisible(value string, width int) string { + padding := width - text.VisibleWidth(value) + if padding < 0 { + padding = 0 + } + + return value + stdstrings.Repeat(" ", padding) +} + +func mapRows(rows [][]string, render func([]string) string) []string { + result := make([]string, len(rows)) + for index, row := range rows { + result[index] = render(row) + } + + return result +} diff --git a/console/shell/internal/theme/theme_test.go b/console/shell/internal/theme/theme_test.go new file mode 100644 index 00000000..6b7c89a8 --- /dev/null +++ b/console/shell/internal/theme/theme_test.go @@ -0,0 +1,47 @@ +package theme + +import ( + stdstrings "strings" + "testing" +) + +func TestStyles(t *testing.T) { + if got := Red("Error"); got != "\x1b[31mError\x1b[39m" { + t.Fatalf("Red() = %q", got) + } + if got := Dim(""); got != "" { + t.Fatalf("Dim(empty) = %q", got) + } + if got := ForegroundRGB("Text", 1, 2, 3); got != "\x1b[38;2;1;2;3mText\x1b[0m" { + t.Fatalf("ForegroundRGB() = %q", got) + } +} + +func TestRenderQuestionAndError(t *testing.T) { + if got := RenderQuestion("Name?", "required"); got != "? Name? required " { + t.Fatalf("RenderQuestion() = %q", got) + } + if got := RenderError("Required"); got != "\x1b[31m! Required\x1b[39m\n" { + t.Fatalf("RenderError() = %q", got) + } +} + +func TestRenderTable(t *testing.T) { + result := RenderTable([]string{"Name", "Role"}, [][]string{{"Ada", "Admin"}, {"Linus", "User"}}) + + for _, want := range []string{"┌───────┬───────┐", "\x1b[2mName\x1b[22m", "Ada", "Linus", "└───────┴───────┘"} { + if !stdstrings.Contains(result, want) { + t.Fatalf("RenderTable() missing %q in:\n%s", want, result) + } + } +} + +func TestRenderBox(t *testing.T) { + result := RenderBox(BoxOptions{Body: "Ready", Title: "Task", Width: 10}) + + for _, want := range []string{" ┌ Task ──────┐", " │ Ready │", " └────────────┘"} { + if !stdstrings.Contains(result, want) { + t.Fatalf("RenderBox() missing %q in:\n%s", want, result) + } + } +} From 8f9e74476e170827551d76e9f66221730df93074 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Sat, 13 Jun 2026 16:58:23 +0800 Subject: [PATCH 274/675] feat(shell): port prompt environment and typed input engine --- .../shell/internal/environment/environment.go | 82 ++++++ .../internal/environment/environment_test.go | 121 +++++++++ console/shell/internal/environment/errors.go | 25 ++ .../internal/environment/memory_output.go | 33 +++ .../internal/environment/scripted_input.go | 34 +++ console/shell/internal/typedvalue/lines.go | 168 ++++++++++++ .../shell/internal/typedvalue/typedvalue.go | 247 ++++++++++++++++++ .../internal/typedvalue/typedvalue_test.go | 139 ++++++++++ 8 files changed, 849 insertions(+) create mode 100644 console/shell/internal/environment/environment.go create mode 100644 console/shell/internal/environment/environment_test.go create mode 100644 console/shell/internal/environment/errors.go create mode 100644 console/shell/internal/environment/memory_output.go create mode 100644 console/shell/internal/environment/scripted_input.go create mode 100644 console/shell/internal/typedvalue/lines.go create mode 100644 console/shell/internal/typedvalue/typedvalue.go create mode 100644 console/shell/internal/typedvalue/typedvalue_test.go diff --git a/console/shell/internal/environment/environment.go b/console/shell/internal/environment/environment.go new file mode 100644 index 00000000..022b3d32 --- /dev/null +++ b/console/shell/internal/environment/environment.go @@ -0,0 +1,82 @@ +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 +) + +func Current() Environment { + currentMu.Lock() + defer currentMu.Unlock() + + return current +} + +func Configure(patch Patch) { + currentMu.Lock() + defer currentMu.Unlock() + + current = merge(current, patch) +} + +func With(patch Patch, callback func() error) error { + currentMu.Lock() + previous := current + current = merge(current, patch) + currentMu.Unlock() + + defer func() { + currentMu.Lock() + current = previous + 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/console/shell/internal/environment/environment_test.go b/console/shell/internal/environment/environment_test.go new file mode 100644 index 00000000..ea30ef9f --- /dev/null +++ b/console/shell/internal/environment/environment_test.go @@ -0,0 +1,121 @@ +package environment + +import ( + "errors" + "testing" +) + +func TestWithRestoresPreviousEnvironmentWhenCallbackFails(t *testing.T) { + output := NewMemoryOutput() + errorOutput := NewMemoryOutput() + previous := Current() + interactive := false + + Configure(Patch{Output: output, ErrorOutput: errorOutput, Interactive: &interactive}) + + scopedOutput := NewMemoryOutput() + scopedInteractive := true + err := With(Patch{Output: scopedOutput, Interactive: &scopedInteractive}, func() error { + current := Current() + if current.Output != scopedOutput { + t.Fatalf("Current().Output was not scoped output") + } + if current.ErrorOutput != errorOutput { + t.Fatalf("Current().ErrorOutput was not preserved") + } + if !current.Interactive { + t.Fatalf("Current().Interactive = false, want true") + } + + return errors.New("failed") + }) + + if err == nil || err.Error() != "failed" { + t.Fatalf("With() error = %v", err) + } + + current := Current() + if current.Output != output { + t.Fatalf("Current().Output was not restored") + } + if current.ErrorOutput != errorOutput { + t.Fatalf("Current().ErrorOutput was not restored") + } + if current.Interactive { + t.Fatalf("Current().Interactive = true, want false") + } + + Configure(Patch{ + Input: previous.Input, + Output: previous.Output, + ErrorOutput: previous.ErrorOutput, + Interactive: &previous.Interactive, + }) +} + +func TestConfigureMergesPartialPromptConfiguration(t *testing.T) { + previous := Current() + output := NewMemoryOutput() + errorOutput := NewMemoryOutput() + interactive := false + + Configure(Patch{Output: output, ErrorOutput: errorOutput, Interactive: &interactive}) + + nextOutput := NewMemoryOutput() + Configure(Patch{Output: nextOutput}) + + current := Current() + if current.Output != nextOutput { + t.Fatalf("Current().Output was not updated") + } + if current.ErrorOutput != errorOutput { + t.Fatalf("Current().ErrorOutput was not preserved") + } + if current.Interactive { + t.Fatalf("Current().Interactive = true, want false") + } + + Configure(Patch{ + Input: previous.Input, + Output: previous.Output, + ErrorOutput: previous.ErrorOutput, + Interactive: &previous.Interactive, + }) +} + +func TestMemoryOutputCapturesAndClears(t *testing.T) { + output := NewMemoryOutput() + + output.Write("first") + output.Write(" second") + + if got := output.Text(); got != "first second" { + t.Fatalf("Text() = %q", got) + } + + output.Clear() + + if got := output.Text(); got != "" { + t.Fatalf("Text() after Clear() = %q", got) + } +} + +func TestScriptedInputDrainsForKeysAndLines(t *testing.T) { + input := NewScriptedInput([]string{"a", "line"}) + + if got, ok, err := input.ReadKey(); err != nil || !ok || got != "a" { + t.Fatalf("ReadKey() = %q, %v, %v", got, ok, err) + } + + if got, err := input.ReadLine("Question"); err != nil || got != "line" { + t.Fatalf("ReadLine() = %q, %v", got, err) + } + + if got, ok, err := input.ReadKey(); err != nil || ok || got != "" { + t.Fatalf("ReadKey(empty) = %q, %v, %v", got, ok, err) + } + + if got, err := input.ReadLine("Question"); err != nil || got != "" { + t.Fatalf("ReadLine(empty) = %q, %v", got, err) + } +} diff --git a/console/shell/internal/environment/errors.go b/console/shell/internal/environment/errors.go new file mode 100644 index 00000000..3d84cdaf --- /dev/null +++ b/console/shell/internal/environment/errors.go @@ -0,0 +1,25 @@ +package environment + +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/console/shell/internal/environment/memory_output.go b/console/shell/internal/environment/memory_output.go new file mode 100644 index 00000000..f12a2c89 --- /dev/null +++ b/console/shell/internal/environment/memory_output.go @@ -0,0 +1,33 @@ +package environment + +import "sync" + +type MemoryOutput struct { + mu sync.Mutex + buffer string +} + +func NewMemoryOutput() *MemoryOutput { + return &MemoryOutput{} +} + +func (output *MemoryOutput) Write(content string) { + output.mu.Lock() + defer output.mu.Unlock() + + output.buffer += content +} + +func (output *MemoryOutput) Clear() { + output.mu.Lock() + defer output.mu.Unlock() + + output.buffer = "" +} + +func (output *MemoryOutput) Text() string { + output.mu.Lock() + defer output.mu.Unlock() + + return output.buffer +} diff --git a/console/shell/internal/environment/scripted_input.go b/console/shell/internal/environment/scripted_input.go new file mode 100644 index 00000000..76a2b37a --- /dev/null +++ b/console/shell/internal/environment/scripted_input.go @@ -0,0 +1,34 @@ +package environment + +type ScriptedInput struct { + queued []string +} + +func NewScriptedInput(lines []string) *ScriptedInput { + queued := make([]string, len(lines)) + copy(queued, lines) + + return &ScriptedInput{queued: queued} +} + +func (input *ScriptedInput) ReadKey() (string, bool, error) { + if len(input.queued) == 0 { + return "", false, nil + } + + value := input.queued[0] + input.queued = input.queued[1:] + + return value, true, nil +} + +func (input *ScriptedInput) ReadLine(_ string) (string, error) { + if len(input.queued) == 0 { + return "", nil + } + + value := input.queued[0] + input.queued = input.queued[1:] + + return value, nil +} diff --git a/console/shell/internal/typedvalue/lines.go b/console/shell/internal/typedvalue/lines.go new file mode 100644 index 00000000..37a58aa9 --- /dev/null +++ b/console/shell/internal/typedvalue/lines.go @@ -0,0 +1,168 @@ +package typedvalue + +import text "github.com/oullin/tui/internal/strings" + +type LineRange struct { + Start int + End int +} + +type VisibleLineWindow struct { + Lines []string + Start int + Total int +} + +func VisibleLines(value string, cursor int, rows int) string { + lines := VisibleLineWindowFor(value, cursor, rows).Lines + result := "" + for index, line := range lines { + if index > 0 { + result += "\n" + } + result += line + } + + return result +} + +func VisibleLineWindowFor(value string, cursor int, rows int, width ...int) VisibleLineWindow { + valueCharacters := Characters(value) + wrapWidth := 0 + if len(width) > 0 { + wrapWidth = width[0] + } + ranges := lineRanges(valueCharacters, wrapWidth) + + if rows <= 0 { + return VisibleLineWindow{ + Lines: linesForRanges(valueCharacters, ranges), + Start: 0, + Total: len(ranges), + } + } + + line := currentLine(ranges, cursor) + start := max(0, line-rows+1) + end := start + rows + if end > len(ranges) { + end = len(ranges) + } + + return VisibleLineWindow{ + Lines: linesForRanges(valueCharacters, ranges[start:end]), + Start: start, + Total: len(ranges), + } +} + +func MoveLine(value []string, cursor int, direction int, width int) int { + ranges := lineRanges(value, width) + index := currentLine(ranges, cursor) + if index < 0 || index >= len(ranges) { + return cursor + } + + targetIndex := index + direction + if targetIndex < 0 { + return 0 + } + if targetIndex >= len(ranges) { + return len(value) + } + + current := ranges[index] + target := ranges[targetIndex] + column := min(cursor-current.Start, current.End-current.Start) + targetColumn := min(column, target.End-target.Start) + + return target.Start + targetColumn +} + +func MoveToLineBoundary(value []string, cursor int, boundary string) int { + ranges := lineRanges(value, 0) + index := currentLine(ranges, cursor) + if index < 0 || index >= len(ranges) { + return cursor + } + + if boundary == "start" { + return ranges[index].Start + } + + return ranges[index].End +} + +func lineRanges(value []string, width int) []LineRange { + ranges := make([]LineRange, 0) + start := 0 + + for index, char := range value { + if char == "\n" { + ranges = append(ranges, wrappedRanges(value, start, index, width)...) + start = index + 1 + } + } + + ranges = append(ranges, wrappedRanges(value, start, len(value), width)...) + + return ranges +} + +func wrappedRanges(value []string, start int, end int, width int) []LineRange { + if width <= 0 || start == end { + return []LineRange{{Start: start, End: end}} + } + + ranges := make([]LineRange, 0) + rangeStart := start + rangeWidth := 0 + + for index := start; index < end; index++ { + characterWidth := text.VisibleWidth(value[index]) + if rangeWidth > 0 && rangeWidth+characterWidth > width { + ranges = append(ranges, LineRange{Start: rangeStart, End: index}) + rangeStart = index + rangeWidth = 0 + } + + rangeWidth += characterWidth + } + + ranges = append(ranges, LineRange{Start: rangeStart, End: end}) + + return ranges +} + +func currentLine(ranges []LineRange, cursor int) int { + for index, lineRange := range ranges { + if cursor <= lineRange.End { + return index + } + } + + return len(ranges) - 1 +} + +func linesForRanges(value []string, ranges []LineRange) []string { + lines := make([]string, len(ranges)) + for index, lineRange := range ranges { + lines[index] = FromCharacters(value[lineRange.Start:lineRange.End]) + } + + return lines +} + +func min(left int, right int) int { + if left < right { + return left + } + return right +} + +func max(left int, right int) int { + if left > right { + return left + } + return right +} diff --git a/console/shell/internal/typedvalue/typedvalue.go b/console/shell/internal/typedvalue/typedvalue.go new file mode 100644 index 00000000..9b602a6b --- /dev/null +++ b/console/shell/internal/typedvalue/typedvalue.go @@ -0,0 +1,247 @@ +package typedvalue + +import ( + "unicode" + + "github.com/oullin/tui/internal/key" +) + +type State struct { + Cursor int + Value string +} + +type AppliedKey struct { + Cursor int + Value string + Cancelled bool + Submitted bool +} + +func InitialState(value string) State { + return State{Cursor: CharacterLength(value), Value: value} +} + +func ApplyKey(state State, pressed string, allowNewLine bool, wrapWidth ...int) AppliedKey { + value := Characters(state.Value) + cursor := clamp(state.Cursor, 0, len(value)) + width := 0 + if len(wrapWidth) > 0 { + width = wrapWidth[0] + } + + if pressed == key.First(key.CtrlC) { + return result(value, cursor, true, false) + } + + if pressed == key.First(key.CtrlD) && allowNewLine { + return result(value, cursor, false, true) + } + + if pressed == key.First(key.Enter) { + if !allowNewLine { + return result(value, cursor, false, true) + } + + value = insertAt(value, cursor, "\n") + return result(value, cursor+1, false, false) + } + + if moved, ok := moveCursor(value, cursor, pressed, allowNewLine, width); ok { + return result(value, moved, false, false) + } + + if next, ok := deleteKey(value, cursor, pressed, allowNewLine); ok { + return result(next.value, next.cursor, false, false) + } + + if next, ok := insertPrintable(value, cursor, pressed); ok { + return result(next.value, next.cursor, false, false) + } + + return result(value, cursor, false, false) +} + +func Characters(value string) []string { + characters := make([]string, 0) + for _, char := range value { + characters = append(characters, string(char)) + } + + return characters +} + +func FromCharacters(value []string) string { + result := "" + for _, char := range value { + result += char + } + + return result +} + +func CharacterLength(value string) int { + return len(Characters(value)) +} + +func IsPrintable(value string) bool { + if value == "" { + return false + } + + for _, char := range value { + if char < 32 || char == 127 { + return false + } + } + + return true +} + +type cursorResult struct { + cursor int + value []string +} + +func result(value []string, cursor int, cancelled bool, submitted bool) AppliedKey { + return AppliedKey{ + Cancelled: cancelled, + Cursor: cursor, + Submitted: submitted, + Value: FromCharacters(value), + } +} + +func moveCursor(value []string, cursor int, pressed string, allowNewLine bool, wrapWidth int) (int, bool) { + switch pressed { + case key.First(key.Left), key.First(key.LeftArrow), key.First(key.CtrlB): + return max(0, cursor-1), true + case key.First(key.Right), key.First(key.RightArrow), key.First(key.CtrlF): + return min(len(value), cursor+1), true + } + + if allowNewLine { + switch pressed { + case key.First(key.Up), key.First(key.UpArrow), key.First(key.CtrlP): + return MoveLine(value, cursor, -1, wrapWidth), true + case key.First(key.Down), key.First(key.DownArrow), key.First(key.CtrlN): + return MoveLine(value, cursor, 1, wrapWidth), true + } + } + + if _, ok := key.OneOf([]key.Value{key.Home, key.CtrlA}, pressed); ok { + if allowNewLine { + return MoveToLineBoundary(value, cursor, "start"), true + } + return 0, true + } + + if _, ok := key.OneOf([]key.Value{key.End, key.CtrlE}, pressed); ok { + if allowNewLine { + return MoveToLineBoundary(value, cursor, "end"), true + } + return len(value), true + } + + return 0, false +} + +func deleteKey(value []string, cursor int, pressed string, allowNewLine bool) (cursorResult, bool) { + switch pressed { + case key.First(key.Delete): + return cursorResult{cursor: cursor, value: deleteRange(value, cursor, cursor+1)}, true + case key.First(key.CtrlU): + start := 0 + if allowNewLine { + start = MoveToLineBoundary(value, cursor, "start") + } + return cursorResult{cursor: start, value: deleteRange(value, start, cursor)}, true + case key.First(key.Backspace), key.First(key.CtrlH): + if cursor == 0 { + return cursorResult{cursor: cursor, value: value}, true + } + return cursorResult{cursor: cursor - 1, value: deleteRange(value, cursor-1, cursor)}, true + case key.First(key.OptionBackspace): + start := previousWordStart(value, cursor) + return cursorResult{cursor: start, value: deleteRange(value, start, cursor)}, true + default: + return cursorResult{}, false + } +} + +func insertPrintable(value []string, cursor int, pressed string) (cursorResult, bool) { + if !IsPrintable(pressed) { + return cursorResult{}, false + } + + next := append([]string{}, value...) + nextCursor := cursor + for _, char := range Characters(pressed) { + next = insertAt(next, nextCursor, char) + nextCursor++ + } + + return cursorResult{cursor: nextCursor, value: next}, true +} + +func insertAt(value []string, cursor int, char string) []string { + next := append([]string{}, value[:cursor]...) + next = append(next, char) + next = append(next, value[cursor:]...) + return next +} + +func deleteRange(value []string, start int, end int) []string { + start = clamp(start, 0, len(value)) + end = clamp(end, start, len(value)) + next := append([]string{}, value[:start]...) + next = append(next, value[end:]...) + return next +} + +func previousWordStart(value []string, cursor int) int { + index := clamp(cursor, 0, len(value)) + for index > 0 && isSpace([]rune(value[index-1])[0]) { + index-- + } + + if index == 0 { + return 0 + } + + char := []rune(value[index-1])[0] + if isWord(char) { + for index > 0 && isWord([]rune(value[index-1])[0]) { + index-- + } + return index + } + + for index > 0 { + previous := []rune(value[index-1])[0] + if isWord(previous) || isSpace(previous) { + break + } + index-- + } + + return index +} + +func isWord(char rune) bool { + return unicode.IsLetter(char) || unicode.IsMark(char) || unicode.IsNumber(char) +} + +func isSpace(char rune) bool { + return unicode.IsSpace(char) +} + +func clamp(value int, low int, high int) int { + if value < low { + return low + } + if value > high { + return high + } + return value +} diff --git a/console/shell/internal/typedvalue/typedvalue_test.go b/console/shell/internal/typedvalue/typedvalue_test.go new file mode 100644 index 00000000..0b71ea42 --- /dev/null +++ b/console/shell/internal/typedvalue/typedvalue_test.go @@ -0,0 +1,139 @@ +package typedvalue + +import ( + "reflect" + "testing" + + "github.com/oullin/tui/internal/key" +) + +func apply(keys []string) string { + state := State{} + for _, pressed := range keys { + next := ApplyKey(state, pressed, false) + state = State{Cursor: next.Cursor, Value: next.Value} + } + + return state.Value +} + +func applyMultiline(keys []string) AppliedKey { + state := State{} + var next AppliedKey + for _, pressed := range keys { + next = ApplyKey(state, pressed, true) + state = State{Cursor: next.Cursor, Value: next.Value} + } + + return next +} + +func applyMultilineState(value string, cursor int, keys []string, wrapWidth ...int) State { + state := State{Cursor: cursor, Value: value} + for _, pressed := range keys { + next := ApplyKey(state, pressed, true, wrapWidth...) + state = State{Cursor: next.Cursor, Value: next.Value} + } + + return state +} + +func TestTypedValueInsertionAndDeletion(t *testing.T) { + if got := apply([]string{"Je", "ss"}); got != "Jess" { + t.Fatalf("apply(insert) = %q", got) + } + + if got := apply([]string{"J", "e", "z", key.First(key.Backspace), "s", "s"}); got != "Jess" { + t.Fatalf("apply(backspace) = %q", got) + } + + if got := apply([]string{"J", "e", "z", key.First(key.Left), key.First(key.Delete), "s", "s"}); got != "Jess" { + t.Fatalf("apply(delete) = %q", got) + } + + if got := apply([]string{"J", "e", "s", "s", key.First(key.Delete)}); got != "Jess" { + t.Fatalf("apply(delete at end) = %q", got) + } +} + +func TestTypedValueMovementAndCancellation(t *testing.T) { + if got := apply([]string{"J", "a", "n", "e", key.First(key.Left), key.First(key.Left), key.First(key.CtrlU), "D", "o"}); got != "Done" { + t.Fatalf("apply(ctrl-u) = %q", got) + } + + if got := apply([]string{"J", "z", "e", key.First(key.CtrlB), key.First(key.CtrlH), key.First(key.CtrlF), "s", "s"}); got != "Jess" { + t.Fatalf("apply(emacs movement) = %q", got) + } + + got := ApplyKey(State{Cursor: 3, Value: "Ada"}, key.First(key.CtrlC), false) + want := AppliedKey{Cancelled: true, Cursor: 3, Submitted: false, Value: "Ada"} + if got != want { + t.Fatalf("ApplyKey(ctrl-c) = %#v, want %#v", got, want) + } +} + +func TestTypedValueUnicodeAndPreviousWordDeletion(t *testing.T) { + if got := applyMultilineState("😀", len("😀"), []string{"!"}); got != (State{Cursor: 2, Value: "😀!"}) { + t.Fatalf("unicode clamp = %#v", got) + } + + if got := applyMultilineState("😀 hello", len([]rune("😀 hello")), []string{key.First(key.OptionBackspace)}); got != (State{Cursor: 2, Value: "😀 "}) { + t.Fatalf("previous word after emoji = %#v", got) + } + + if got := applyMultilineState("hello --", len([]rune("hello --")), []string{key.First(key.OptionBackspace)}); got != (State{Cursor: 6, Value: "hello "}) { + t.Fatalf("previous punctuation group = %#v", got) + } +} + +func TestTypedValueLineMovement(t *testing.T) { + if got := apply([]string{"A", "r", key.Home[0], "J", key.End[0], "c", "h", "e", "r"}); got != "JArcher" { + t.Fatalf("home/end = %q", got) + } + + if got := applyMultiline([]string{"A", key.First(key.Enter), "B", key.First(key.CtrlD)}); !got.Submitted || got.Value != "A\nB" { + t.Fatalf("multiline submit = %#v", got) + } + + tests := []struct { + name string + got State + want State + }{ + {name: "up", got: applyMultilineState("abc\nde\nfghi", 6, []string{key.First(key.Up)}), want: State{Cursor: 2, Value: "abc\nde\nfghi"}}, + {name: "down", got: applyMultilineState("abc\nde\nfghi", 2, []string{key.First(key.Down)}), want: State{Cursor: 6, Value: "abc\nde\nfghi"}}, + {name: "down long", got: applyMultilineState("abc\nde\nfghi", 6, []string{key.First(key.Down)}), want: State{Cursor: 9, Value: "abc\nde\nfghi"}}, + {name: "ctrl-p", got: applyMultilineState("abc\nde\nfghi", 6, []string{key.First(key.CtrlP)}), want: State{Cursor: 2, Value: "abc\nde\nfghi"}}, + {name: "ctrl-n", got: applyMultilineState("abc\nde\nfghi", 2, []string{key.First(key.CtrlN)}), want: State{Cursor: 6, Value: "abc\nde\nfghi"}}, + {name: "wrapped up", got: applyMultilineState("abcdefghijkl", 7, []string{key.First(key.Up)}, 5), want: State{Cursor: 2, Value: "abcdefghijkl"}}, + {name: "wrapped down", got: applyMultilineState("abcdefghijkl", 2, []string{key.First(key.Down)}, 5), want: State{Cursor: 7, Value: "abcdefghijkl"}}, + {name: "wrapped up from end", got: applyMultilineState("abcdefghijkl", 11, []string{key.First(key.Up)}, 5), want: State{Cursor: 6, Value: "abcdefghijkl"}}, + {name: "home line", got: applyMultilineState("abc\nde\nfghi", 5, []string{key.Home[0]}), want: State{Cursor: 4, Value: "abc\nde\nfghi"}}, + {name: "end line", got: applyMultilineState("abc\nde\nfghi", 5, []string{key.End[0]}), want: State{Cursor: 6, Value: "abc\nde\nfghi"}}, + {name: "ctrl-u line", got: applyMultilineState("abc\nde\nfghi", 9, []string{key.First(key.CtrlU)}), want: State{Cursor: 7, Value: "abc\nde\nhi"}}, + } + + for _, test := range tests { + if test.got != test.want { + t.Fatalf("%s = %#v, want %#v", test.name, test.got, test.want) + } + } + + if got := applyMultilineState("abc\nde\nfghi", 9, []string{key.First(key.CtrlA), "X", key.First(key.CtrlE), "Y"}); got != (State{Cursor: 13, Value: "abc\nde\nXfghiY"}) { + t.Fatalf("ctrl-a/ctrl-e edit = %#v", got) + } +} + +func TestVisibleLineWindow(t *testing.T) { + got := VisibleLineWindowFor("A\nB\nC", 5, 2) + want := VisibleLineWindow{Lines: []string{"B", "C"}, Start: 1, Total: 3} + if !reflect.DeepEqual(got, want) { + t.Fatalf("VisibleLineWindowFor() = %#v, want %#v", got, want) + } + + got = VisibleLineWindowFor("abcdefghijkl", 12, 2, 5) + want = VisibleLineWindow{Lines: []string{"fghij", "kl"}, Start: 1, Total: 3} + if !reflect.DeepEqual(got, want) { + t.Fatalf("VisibleLineWindowFor(wrapped) = %#v, want %#v", got, want) + } +} From 2cf7fa46b576428410cdee01b457a6eaa576a740 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Sat, 13 Jun 2026 17:05:30 +0800 Subject: [PATCH 275/675] feat(shell): port prompt lifecycle and basic prompts --- console/shell/internal/environment/errors.go | 4 + console/shell/internal/prompt/lifecycle.go | 71 +++++ console/shell/internal/prompt/validation.go | 90 +++++++ console/shell/internal/prompts/basic_test.go | 260 +++++++++++++++++++ console/shell/internal/prompts/number.go | 230 ++++++++++++++++ console/shell/internal/prompts/render.go | 218 ++++++++++++++++ console/shell/internal/prompts/text.go | 252 ++++++++++++++++++ 7 files changed, 1125 insertions(+) create mode 100644 console/shell/internal/prompt/lifecycle.go create mode 100644 console/shell/internal/prompt/validation.go create mode 100644 console/shell/internal/prompts/basic_test.go create mode 100644 console/shell/internal/prompts/number.go create mode 100644 console/shell/internal/prompts/render.go create mode 100644 console/shell/internal/prompts/text.go diff --git a/console/shell/internal/environment/errors.go b/console/shell/internal/environment/errors.go index 3d84cdaf..c0312899 100644 --- a/console/shell/internal/environment/errors.go +++ b/console/shell/internal/environment/errors.go @@ -1,5 +1,9 @@ package environment +import "errors" + +var ErrKeyInputUnsupported = errors.New("key input is unsupported") + type PromptValidationError struct { Message string } diff --git a/console/shell/internal/prompt/lifecycle.go b/console/shell/internal/prompt/lifecycle.go new file mode 100644 index 00000000..73c6c215 --- /dev/null +++ b/console/shell/internal/prompt/lifecycle.go @@ -0,0 +1,71 @@ +package prompt + +import ( + "errors" + + "github.com/oullin/tui/internal/environment" + "github.com/oullin/tui/internal/theme" +) + +type Options[T any] struct { + Message string + Default T + Required Required + Validate Validator[T] +} + +type Reader[T any] func(attempt int) (T, error) +type OnValid[T any] func(T) error + +func UntilValid[T any](options Options[T], read Reader[T], onValid OnValid[T]) (T, error) { + env := environment.Current() + + if !env.Interactive { + value := options.Default + if err := EnsureValid(value, options.Required, options.Validate); err != nil { + return value, err + } + + return value, nil + } + + attempt := 0 + for { + value, err := read(attempt) + if err != nil { + var validation environment.PromptValidationError + if errors.As(err, &validation) { + env.ErrorOutput.Write(theme.RenderError(validation.Message)) + attempt++ + continue + } + + return value, err + } + + if err := EnsureValid(value, options.Required, options.Validate); err != nil { + var validation environment.PromptValidationError + if errors.As(err, &validation) { + env.ErrorOutput.Write(theme.RenderError(validation.Message)) + options.Default = value + attempt++ + continue + } + + return value, err + } + + if onValid != nil { + if err := onValid(value); err != nil { + return value, err + } + } + + return value, nil + } +} + +func Ask(message string, hint string) (string, error) { + env := environment.Current() + return env.Input.ReadLine(theme.RenderQuestion(message, hint)) +} diff --git a/console/shell/internal/prompt/validation.go b/console/shell/internal/prompt/validation.go new file mode 100644 index 00000000..ecdb1cc7 --- /dev/null +++ b/console/shell/internal/prompt/validation.go @@ -0,0 +1,90 @@ +package prompt + +import ( + "errors" + "reflect" + + "github.com/oullin/tui/internal/environment" +) + +type Required struct { + Enabled bool + Message string +} + +type Validator[T any] func(T) (string, error) + +func RequiredMessage(value any, required Required) string { + if !required.Enabled || !isInvalidRequiredValue(value) { + return "" + } + + if required.Message != "" { + return required.Message + } + + return "Required." +} + +func ValidationMessage[T any](value T, validator Validator[T]) (string, error) { + if validator == nil { + return "", nil + } + + message, err := validator(value) + if err != nil { + return "", err + } + + return message, nil +} + +func EnsureValid[T any](value T, required Required, validator Validator[T]) error { + if message := RequiredMessage(value, required); message != "" { + return environment.PromptValidationError{Message: message} + } + + message, err := ValidationMessage(value, validator) + if err != nil { + return err + } + if message != "" { + return environment.PromptValidationError{Message: message} + } + + return nil +} + +func isInvalidRequiredValue(value any) bool { + if value == nil { + return true + } + + switch typed := value.(type) { + case string: + return typed == "" + case bool: + return !typed + } + + reflected := reflect.ValueOf(value) + switch reflected.Kind() { + case reflect.Slice, reflect.Array: + return reflected.Len() == 0 + default: + return false + } +} + +func ValidationError(message string) error { + return environment.PromptValidationError{Message: message} +} + +func IsValidationError(err error) (string, bool) { + var validation environment.PromptValidationError + if ok := errors.As(err, &validation); ok { + return validation.Message, true + } + + return "", false +} diff --git a/console/shell/internal/prompts/basic_test.go b/console/shell/internal/prompts/basic_test.go new file mode 100644 index 00000000..536f5a5a --- /dev/null +++ b/console/shell/internal/prompts/basic_test.go @@ -0,0 +1,260 @@ +package prompts + +import ( + "errors" + "strings" + "testing" + + "github.com/oullin/tui/internal/environment" + "github.com/oullin/tui/internal/key" + "github.com/oullin/tui/internal/prompt" + textutil "github.com/oullin/tui/internal/strings" +) + +type lineOnlyInput struct { + asked string + line string +} + +func (input *lineOnlyInput) ReadKey() (string, bool, error) { + return "", false, environment.ErrKeyInputUnsupported +} + +func (input *lineOnlyInput) ReadLine(message string) (string, error) { + input.asked = message + return input.line, nil +} + +func withTestEnvironment(input environment.Input, callback func(output *environment.MemoryOutput) error) error { + output := environment.NewMemoryOutput() + interactive := true + return environment.With(environment.Patch{Input: input, Output: output, ErrorOutput: output, Interactive: &interactive}, func() error { + return callback(output) + }) +} + +func TestTextPromptReadsAndRendersTypedValues(t *testing.T) { + err := withTestEnvironment(environment.NewScriptedInput([]string{"A", "d", "a", key.First(key.Enter)}), func(output *environment.MemoryOutput) error { + result, err := Text(TextOptions{Message: "Name"}) + if err != nil { + return err + } + if result != "Ada" { + t.Fatalf("Text() = %q", result) + } + if !strings.Contains(output.Text(), "\x1b[36mName\x1b[39m") || !strings.Contains(textutil.ParseAnsiText(output.Text()), "Ada") { + t.Fatalf("text output missing rendered prompt/value:\n%s", output.Text()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestTextRetriesValidationAndRendersSubmittedOnce(t *testing.T) { + err := withTestEnvironment(environment.NewScriptedInput([]string{"J", "e", "s", key.First(key.Enter), "s", key.First(key.Enter)}), func(output *environment.MemoryOutput) error { + result, err := Text(TextOptions{ + Message: "Name", + Validate: func(value string) (string, error) { + if value != "Jess" { + return "Invalid name.", nil + } + return "", nil + }, + }) + if err != nil { + return err + } + if result != "Jess" { + t.Fatalf("Text() = %q", result) + } + if !strings.Contains(output.Text(), "Invalid name.") { + t.Fatalf("output missing validation error:\n%s", output.Text()) + } + if count := strings.Count(output.Text(), "┌ \x1b[2mName\x1b[22m"); count != 1 { + t.Fatalf("submitted frame count = %d", count) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestTextNonInteractiveDefaultTransformAndValidation(t *testing.T) { + output := environment.NewMemoryOutput() + interactive := false + err := environment.With(environment.Patch{Output: output, ErrorOutput: output, Interactive: &interactive}, func() error { + result, err := Text(TextOptions{ + Message: "Name", + Default: " Ada ", + Transform: func(value string) (string, error) { + return strings.TrimSpace(value), nil + }, + Validate: func(value string) (string, error) { + if value != "Ada" { + return "Unexpected value.", nil + } + return "", nil + }, + }) + if err != nil { + return err + } + if result != "Ada" { + t.Fatalf("Text() = %q", result) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestTextareaPromptAcceptsMultilineInput(t *testing.T) { + err := withTestEnvironment(environment.NewScriptedInput([]string{"A", key.First(key.Enter), "B", key.First(key.CtrlD)}), func(output *environment.MemoryOutput) error { + result, err := Textarea(TextOptions{Message: "Description"}) + if err != nil { + return err + } + if result != "A\nB" { + t.Fatalf("Textarea() = %q", result) + } + if !strings.Contains(output.Text(), "Ctrl+D to submit") || !strings.Contains(textutil.ParseAnsiText(output.Text()), "│ A") || !strings.Contains(textutil.ParseAnsiText(output.Text()), "│ B") { + t.Fatalf("textarea output missing expected frame:\n%s", output.Text()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestPasswordPromptMasksValues(t *testing.T) { + err := withTestEnvironment(environment.NewScriptedInput([]string{"s", "e", "c", "r", "e", "t", key.First(key.Enter)}), func(output *environment.MemoryOutput) error { + result, err := Password(TextOptions{Message: "Password"}) + if err != nil { + return err + } + if result != "secret" { + t.Fatalf("Password() = %q", result) + } + if strings.Contains(output.Text(), "secret") || !strings.Contains(output.Text(), "••••••") { + t.Fatalf("password output leaked value or missed mask:\n%s", output.Text()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestNumberPromptParsesRetriesAndSteps(t *testing.T) { + err := withTestEnvironment(environment.NewScriptedInput([]string{"1", "2", "a", key.First(key.Enter), "4", "2", key.First(key.Enter)}), func(output *environment.MemoryOutput) error { + result, err := Number(NumberOptions{Message: "Amount"}) + if err != nil { + return err + } + if result != 42 { + t.Fatalf("Number() = %#v", result) + } + if !strings.Contains(output.Text(), "Must be a number") { + t.Fatalf("number output missing validation error:\n%s", output.Text()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } + + err = withTestEnvironment(environment.NewScriptedInput([]string{key.First(key.Up), key.First(key.Up), key.First(key.Down), key.First(key.Enter)}), func(_ *environment.MemoryOutput) error { + result, err := Number(NumberOptions{Message: "Count", Step: 2, HasStep: true}) + if err != nil { + return err + } + if result != 1 { + t.Fatalf("stepped Number() = %#v", result) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestPausePromptWaitsForEnterAndFallsBackToLineInput(t *testing.T) { + err := withTestEnvironment(environment.NewScriptedInput([]string{"x", key.First(key.Enter)}), func(output *environment.MemoryOutput) error { + result, err := Pause("Continue") + if err != nil { + return err + } + if !result { + t.Fatalf("Pause() = false") + } + if !strings.Contains(output.Text(), "\x1b[36mContinue\x1b[39m") || !strings.HasSuffix(output.Text(), "\n") { + t.Fatalf("pause output missing frame/newline:\n%s", output.Text()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } + + input := &lineOnlyInput{} + err = withTestEnvironment(input, func(_ *environment.MemoryOutput) error { + result, err := Pause("Continue") + if err != nil { + return err + } + if !result || !strings.Contains(input.asked, "Continue") { + t.Fatalf("line fallback result=%v asked=%q", result, input.asked) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestCancellationUsesCustomHandlerAfterRendering(t *testing.T) { + environment.CancelUsing(func(string) (string, error) { + return "Manual", nil + }) + defer environment.CancelUsing(nil) + + err := withTestEnvironment(environment.NewScriptedInput([]string{key.First(key.CtrlC)}), func(output *environment.MemoryOutput) error { + result, err := Text(TextOptions{Message: "Name"}) + if err != nil { + return err + } + if result != "Manual" { + t.Fatalf("Text(cancelled) = %q", result) + } + if !strings.Contains(output.Text(), "Cancelled.") { + t.Fatalf("cancelled output missing message:\n%s", output.Text()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestPromptValidationHelpers(t *testing.T) { + if got := prompt.RequiredMessage("", prompt.Required{Enabled: true}); got != "Required." { + t.Fatalf("RequiredMessage(empty) = %q", got) + } + if got := prompt.RequiredMessage("", prompt.Required{Enabled: true, Message: "Choose something."}); got != "Choose something." { + t.Fatalf("RequiredMessage(custom) = %q", got) + } + if got := prompt.RequiredMessage("value", prompt.Required{Enabled: true}); got != "" { + t.Fatalf("RequiredMessage(value) = %q", got) + } + + _, err := Text(TextOptions{Message: "Name", Required: prompt.Required{Enabled: true}}) + var validation environment.PromptValidationError + if err != nil && !errors.As(err, &validation) { + t.Fatalf("expected validation error, got %v", err) + } +} diff --git a/console/shell/internal/prompts/number.go b/console/shell/internal/prompts/number.go new file mode 100644 index 00000000..3ec2c5a3 --- /dev/null +++ b/console/shell/internal/prompts/number.go @@ -0,0 +1,230 @@ +package prompts + +import ( + "errors" + "fmt" + "math" + "strconv" + stdstrings "strings" + + "github.com/oullin/tui/internal/environment" + "github.com/oullin/tui/internal/key" + "github.com/oullin/tui/internal/prompt" + "github.com/oullin/tui/internal/theme" + "github.com/oullin/tui/internal/typedvalue" +) + +type NumberOptions struct { + Message string + Placeholder string + Default string + HasDefault bool + Required prompt.Required + Validate prompt.Validator[any] + Hint string + Min float64 + HasMin bool + Max float64 + HasMax bool + Step float64 + HasStep bool + Transform func(any) (any, error) +} + +func Number(options NumberOptions) (any, error) { + shouldRenderSubmittedFrame := false + defaultValue, defaultErr := numberDefault(options) + if defaultErr != nil { + return nil, defaultErr + } + + return prompt.UntilValid( + prompt.Options[any]{Message: options.Message, Default: defaultValue, Required: options.Required, Validate: options.Validate}, + func(_ int) (any, error) { + answer, err := readNumberValue(options.Message, options) + if err != nil { + return nil, err + } + + shouldRenderSubmittedFrame = !answer.Cancelled + if answer.Value == "" && options.HasDefault { + return numberDefault(options) + } + + parsed, err := ParseNumberInput(answer.Value, options) + if err != nil { + return nil, err + } + + return transformNumber(options, parsed) + }, + func(value any) error { + if shouldRenderSubmittedFrame { + renderSubmittedNumberValue(options.Message, value) + } + return nil + }, + ) +} + +func numberDefault(options NumberOptions) (any, error) { + if !options.HasDefault { + return "", nil + } + + parsed, err := ParseNumberInput(options.Default, options) + if err != nil { + return nil, err + } + + return transformNumber(options, parsed) +} + +func transformNumber(options NumberOptions, value any) (any, error) { + if options.Transform == nil { + return value, nil + } + + return options.Transform(value) +} + +func readNumberValue(message string, options NumberOptions) (typedReadResult, error) { + env := environment.Current() + state := typedvalue.State{} + if options.HasDefault { + state = typedvalue.InitialState(options.Default) + } + + renderNumberValue(message, state.Value, options) + + for { + pressed, ok, err := env.Input.ReadKey() + if err != nil { + if errors.Is(err, environment.ErrKeyInputUnsupported) { + answer, lineErr := env.Input.ReadLine(theme.RenderQuestion(message, options.Hint)) + if lineErr != nil { + return typedReadResult{}, lineErr + } + if answer == "" && options.HasDefault { + answer = options.Default + } + return typedReadResult{Value: answer}, nil + } + return typedReadResult{}, err + } + if !ok { + return typedReadResult{Value: state.Value}, nil + } + + if pressed == key.First(key.Up) || pressed == key.First(key.UpArrow) { + state.Value = steppedNumberValue(state.Value, 1, options) + state.Cursor = typedvalue.CharacterLength(state.Value) + renderNumberValue(message, state.Value, options) + continue + } + + if pressed == key.First(key.Down) || pressed == key.First(key.DownArrow) { + state.Value = steppedNumberValue(state.Value, -1, options) + state.Cursor = typedvalue.CharacterLength(state.Value) + renderNumberValue(message, state.Value, options) + continue + } + + next := typedvalue.ApplyKey(state, pressed, false) + if next.Cancelled { + renderCancelledNumberValue(message, state.Value, options) + value, cancelErr := environment.CancelPrompt(state.Value) + return typedReadResult{Cancelled: true, Value: value}, cancelErr + } + + state = typedvalue.State{Cursor: next.Cursor, Value: next.Value} + if next.Submitted { + return typedReadResult{Value: state.Value}, nil + } + + renderNumberValue(message, state.Value, options) + } +} + +func ParseNumberInput(input string, options NumberOptions) (any, error) { + normalized := stdstrings.TrimSpace(input) + if normalized == "" { + return "", nil + } + + numeric, number := parseFloat(normalized) + if !numeric { + return nil, prompt.ValidationError("Must be a number") + } + + if options.HasMin && number < options.Min { + return nil, prompt.ValidationError(fmt.Sprintf("Must be at least %s", formatNumber(options.Min))) + } + + if options.HasMax && number > options.Max { + return nil, prompt.ValidationError(fmt.Sprintf("Must be less than %s", formatNumber(options.Max))) + } + + return int(math.Trunc(number)), nil +} + +func steppedNumberValue(value string, direction int, options NumberOptions) string { + step := numberStep(options) + if value == "" { + if direction == 1 { + if options.HasMin { + return formatNumber(options.Min) + } + return "1" + } + if options.HasMax { + return formatNumber(options.Max) + } + return "0" + } + + numeric, number := parseFloat(value) + if !numeric { + return value + } + + next := math.Trunc(number) + float64(step*direction) + if options.HasMin && next < options.Min { + next = options.Min + } + if options.HasMax && next > options.Max { + next = options.Max + } + + return formatNumber(next) +} + +func numberStep(options NumberOptions) int { + if options.HasStep && options.Step > 0 { + truncated := int(math.Trunc(options.Step)) + if truncated > 1 { + return truncated + } + } + + return 1 +} + +func parseFloat(value string) (bool, float64) { + if stdstrings.TrimSpace(value) == "" { + return false, 0 + } + number, err := strconv.ParseFloat(value, 64) + if err != nil || math.IsInf(number, 0) || math.IsNaN(number) { + return false, 0 + } + return true, number +} + +func formatNumber(value float64) string { + if math.Trunc(value) == value { + return strconv.FormatInt(int64(value), 10) + } + + return strconv.FormatFloat(value, 'f', -1, 64) +} diff --git a/console/shell/internal/prompts/render.go b/console/shell/internal/prompts/render.go new file mode 100644 index 00000000..3ace43de --- /dev/null +++ b/console/shell/internal/prompts/render.go @@ -0,0 +1,218 @@ +package prompts + +import ( + "fmt" + stdstrings "strings" + + "github.com/oullin/tui/internal/environment" + "github.com/oullin/tui/internal/strings" + "github.com/oullin/tui/internal/theme" + "github.com/oullin/tui/internal/typedvalue" +) + +const textareaContentWidth = 60 + +func renderTypedValue(message string, state typedvalue.State, options TextOptions) { + env := environment.Current() + if options.AllowNewLine { + env.Output.Write(renderTextareaFrame(message, state, options) + "\n") + if options.Hint != "" { + env.Output.Write(theme.Dim(options.Hint) + "\n") + } + return + } + + display := theme.Dim(options.Placeholder) + if state.Value != "" { + display = typedvalue.VisibleLines(state.Value, state.Cursor, options.Rows) + } + + env.Output.Write(theme.RenderBox(theme.BoxOptions{Body: display, BorderStyle: theme.Cyan, Info: options.Hint, Title: theme.Cyan(message)}) + "\n") +} + +func renderSubmittedTypedValue(message string, value string) { + environment.Current().Output.Write(theme.RenderBox(theme.BoxOptions{Body: value, Title: theme.Dim(message)}) + "\n") +} + +func renderCancelledTypedValue(message string, value string, options TextOptions) { + env := environment.Current() + display := value + if display == "" { + display = options.Placeholder + } + env.Output.Write(theme.RenderBox(theme.BoxOptions{Body: theme.Strikethrough(theme.Dim(display)), BorderStyle: theme.Red, Title: message}) + "\n") + env.ErrorOutput.Write(theme.Red(" ⚠ Cancelled.") + "\n") +} + +func renderTextareaFrame(message string, state typedvalue.State, options TextOptions) string { + return theme.RenderBox(theme.BoxOptions{Body: textareaBody(state, options), BorderStyle: theme.Cyan, Info: "Ctrl+D to submit", Title: theme.Cyan(message)}) +} + +func renderSubmittedTextareaFrame(message string, value string) { + environment.Current().Output.Write(theme.RenderBox(theme.BoxOptions{Body: value, Title: theme.Dim(message)}) + "\n") +} + +func renderCancelledTextareaFrame(message string, value string, options TextOptions) { + env := environment.Current() + body := textareaCancelledBody(value, options) + env.Output.Write(theme.RenderBox(theme.BoxOptions{Body: body, BorderStyle: theme.Red, Title: message}) + "\n") + env.ErrorOutput.Write(theme.Red(" ⚠ Cancelled.") + "\n") +} + +func textareaBody(state typedvalue.State, options TextOptions) string { + rows := options.Rows + if rows <= 0 { + rows = 0 + } + + if state.Value == "" { + return placeholderBody(options, rows) + } + + window := typedvalue.VisibleLineWindowFor(state.Value, state.Cursor, rows, textareaContentWidth) + if rows <= 0 { + return stdstrings.Join(window.Lines, "\n") + } + + padded := append([]string{}, window.Lines...) + for len(padded) < rows { + padded = append(padded, "") + } + if len(padded) > rows { + padded = padded[:rows] + } + + return stdstrings.Join(renderScrollbarRows(padded, window.Start, rows, window.Total), "\n") +} + +func placeholderBody(options TextOptions, rows int) string { + lines := []string{theme.Dim(options.Placeholder)} + for rows > 0 && len(lines) < rows { + lines = append(lines, "") + } + + return stdstrings.Join(lines, "\n") +} + +func textareaCancelledBody(value string, options TextOptions) string { + text := value + if text == "" { + text = options.Placeholder + } + + lines := stdstrings.Split(text, "\n") + for index, line := range lines { + lines[index] = theme.Strikethrough(theme.Dim(line)) + } + + return stdstrings.Join(lines, "\n") +} + +func renderPasswordValue(message string, value string, options TextOptions) { + environment.Current().Output.Write(theme.RenderBox(theme.BoxOptions{Body: passwordDisplay(value, options), BorderStyle: theme.Cyan, Info: options.Hint, Title: theme.Cyan(message)}) + "\n") +} + +func renderSubmittedPasswordValue(message string, value string) { + environment.Current().Output.Write(theme.RenderBox(theme.BoxOptions{Body: maskPassword(value), Title: theme.Dim(message)}) + "\n") +} + +func renderCancelledPasswordValue(message string, value string, options TextOptions) { + env := environment.Current() + display := maskPassword(value) + if display == "" { + display = options.Placeholder + } + env.Output.Write(theme.RenderBox(theme.BoxOptions{Body: theme.Strikethrough(theme.Dim(display)), BorderStyle: theme.Red, Title: message}) + "\n") + env.ErrorOutput.Write(theme.Red(" ⚠ Cancelled.") + "\n") +} + +func maskPassword(value string) string { + return stdstrings.Repeat("•", typedvalue.CharacterLength(value)) +} + +func passwordDisplay(value string, options TextOptions) string { + if value != "" { + return maskPassword(value) + } + + return theme.Dim(options.Placeholder) +} + +func renderNumberValue(message string, value string, options NumberOptions) { + body := renderNumberBody(value, options, func(text string) string { return text }) + environment.Current().Output.Write(theme.RenderBox(theme.BoxOptions{Body: body, BorderStyle: theme.Cyan, Info: options.Hint, Title: theme.Cyan(message)}) + "\n") +} + +func renderSubmittedNumberValue(message string, value any) { + environment.Current().Output.Write(theme.RenderBox(theme.BoxOptions{Body: fmt.Sprint(value), Title: theme.Dim(message)}) + "\n") +} + +func renderCancelledNumberValue(message string, value string, options NumberOptions) { + env := environment.Current() + display := value + if display == "" { + display = options.Placeholder + } + env.Output.Write(theme.RenderBox(theme.BoxOptions{Body: theme.Strikethrough(theme.Dim(display)), BorderStyle: theme.Red, Title: message}) + "\n") + env.ErrorOutput.Write(theme.Red(" ⚠ Cancelled.") + "\n") +} + +func renderNumberBody(value string, options NumberOptions, style func(string) string) string { + display := value + if display == "" { + display = theme.Dim(options.Placeholder) + } + + return display + " " + renderNumberArrows(value, options, style) +} + +func renderNumberArrows(value string, options NumberOptions, style func(string) string) string { + numeric, number := parseFloat(value) + up := style("▲") + down := style("▼") + if numeric && options.HasMax && number >= options.Max { + up = theme.Dim("▲") + } + if numeric && options.HasMin && number <= options.Min { + down = theme.Dim("▼") + } + if value != "" && !numeric { + return theme.Dim("▲") + theme.Dim("▼") + } + + return up + down +} + +func renderPause(message string) string { + return theme.RenderBox(theme.BoxOptions{Body: "", BorderStyle: theme.Cyan, Title: theme.Cyan(message)}) +} + +func renderScrollbarRows(lines []string, start int, rows int, total int) []string { + if rows <= 0 || total <= rows { + return lines + } + + result := make([]string, len(lines)) + thumbStart := start * rows / total + thumbSize := rows * rows / total + if thumbSize < 1 { + thumbSize = 1 + } + if thumbStart+thumbSize > rows { + thumbStart = rows - thumbSize + } + + for index, line := range lines { + bar := theme.Dim("│") + if index >= thumbStart && index < thumbStart+thumbSize { + bar = theme.Cyan("┃") + } + padding := textareaContentWidth - strings.VisibleWidth(line) + if padding < 0 { + padding = 0 + } + result[index] = line + stdstrings.Repeat(" ", padding) + " " + bar + } + + return result +} diff --git a/console/shell/internal/prompts/text.go b/console/shell/internal/prompts/text.go new file mode 100644 index 00000000..95cc43ee --- /dev/null +++ b/console/shell/internal/prompts/text.go @@ -0,0 +1,252 @@ +package prompts + +import ( + "errors" + + "github.com/oullin/tui/internal/environment" + "github.com/oullin/tui/internal/key" + "github.com/oullin/tui/internal/prompt" + "github.com/oullin/tui/internal/theme" + "github.com/oullin/tui/internal/typedvalue" +) + +type TextOptions struct { + Message string + Placeholder string + Default string + Required prompt.Required + Validate prompt.Validator[string] + Hint string + Transform func(string) (string, error) + AllowNewLine bool + Rows int +} + +type typedReadResult struct { + Cancelled bool + Value string +} + +func Text(options TextOptions) (string, error) { + options.AllowNewLine = false + shouldRenderSubmittedFrame := false + validate := preserveInvalidTextDefault(&options) + + return prompt.UntilValid( + prompt.Options[string]{Message: options.Message, Default: transformedDefault(options), Required: options.Required, Validate: validate}, + func(_ int) (string, error) { + answer, err := readTypedValue(options.Message, options) + if err != nil { + return "", err + } + + value := answer.Value + if value == "" { + value = options.Default + } + shouldRenderSubmittedFrame = !answer.Cancelled + return transformText(options, value) + }, + func(value string) error { + if shouldRenderSubmittedFrame { + renderSubmittedTypedValue(options.Message, value) + } + return nil + }, + ) +} + +func Textarea(options TextOptions) (string, error) { + options.AllowNewLine = true + if options.Rows == 0 { + options.Rows = 5 + } + shouldRenderSubmittedFrame := false + validate := preserveInvalidTextDefault(&options) + + return prompt.UntilValid( + prompt.Options[string]{Message: options.Message, Default: transformedDefault(options), Required: options.Required, Validate: validate}, + func(_ int) (string, error) { + answer, err := readTypedValue(options.Message, options) + if err != nil { + return "", err + } + + value := answer.Value + if value == "" { + value = options.Default + } + shouldRenderSubmittedFrame = !answer.Cancelled + return transformText(options, value) + }, + func(value string) error { + if shouldRenderSubmittedFrame { + renderSubmittedTextareaFrame(options.Message, value) + } + return nil + }, + ) +} + +func Password(options TextOptions) (string, error) { + shouldRenderSubmittedFrame := false + validate := preserveInvalidTextDefault(&options) + + return prompt.UntilValid( + prompt.Options[string]{Message: options.Message, Default: transformedDefault(options), Required: options.Required, Validate: validate}, + func(_ int) (string, error) { + answer, err := readPasswordValue(options.Message, options) + if err != nil { + return "", err + } + shouldRenderSubmittedFrame = !answer.Cancelled + return transformText(options, answer.Value) + }, + func(value string) error { + if shouldRenderSubmittedFrame { + renderSubmittedPasswordValue(options.Message, value) + } + return nil + }, + ) +} + +func readTypedValue(message string, options TextOptions) (typedReadResult, error) { + env := environment.Current() + state := typedvalue.InitialState(options.Default) + + renderTypedValue(message, state, options) + + for { + pressed, ok, err := env.Input.ReadKey() + if err != nil { + if errors.Is(err, environment.ErrKeyInputUnsupported) { + answer, lineErr := env.Input.ReadLine(theme.RenderQuestion(message, options.Hint)) + if lineErr != nil { + return typedReadResult{}, lineErr + } + if answer == "" { + answer = options.Default + } + return typedReadResult{Value: answer}, nil + } + return typedReadResult{}, err + } + if !ok { + return typedReadResult{Value: state.Value}, nil + } + + next := typedvalue.ApplyKey(state, pressed, options.AllowNewLine, textareaContentWidth) + if next.Cancelled { + if options.AllowNewLine { + renderCancelledTextareaFrame(message, state.Value, options) + } else { + renderCancelledTypedValue(message, state.Value, options) + } + value, cancelErr := environment.CancelPrompt(state.Value) + return typedReadResult{Cancelled: true, Value: value}, cancelErr + } + + state = typedvalue.State{Cursor: next.Cursor, Value: next.Value} + if next.Submitted { + return typedReadResult{Value: state.Value}, nil + } + + renderTypedValue(message, state, options) + } +} + +func readPasswordValue(message string, options TextOptions) (typedReadResult, error) { + env := environment.Current() + state := typedvalue.InitialState(options.Default) + + renderPasswordValue(message, state.Value, options) + + for { + pressed, ok, err := env.Input.ReadKey() + if err != nil { + if errors.Is(err, environment.ErrKeyInputUnsupported) { + answer, lineErr := env.Input.ReadLine(theme.RenderQuestion(message, options.Hint)) + if lineErr != nil { + return typedReadResult{}, lineErr + } + return typedReadResult{Value: answer}, nil + } + return typedReadResult{}, err + } + if !ok { + return typedReadResult{Value: state.Value}, nil + } + + next := typedvalue.ApplyKey(state, pressed, false) + if next.Cancelled { + renderCancelledPasswordValue(message, state.Value, options) + value, cancelErr := environment.CancelPrompt(state.Value) + return typedReadResult{Cancelled: true, Value: value}, cancelErr + } + + state = typedvalue.State{Cursor: next.Cursor, Value: next.Value} + if next.Submitted { + return typedReadResult{Value: state.Value}, nil + } + + renderPasswordValue(message, state.Value, options) + } +} + +func Pause(message string) (bool, error) { + env := environment.Current() + if !env.Interactive { + return false, nil + } + + env.Output.Write(renderPause(message)) + for { + pressed, ok, err := env.Input.ReadKey() + if err != nil { + if errors.Is(err, environment.ErrKeyInputUnsupported) { + _, lineErr := prompt.Ask(message, "") + return lineErr == nil, lineErr + } + return false, err + } + if !ok || pressed == key.First(key.Enter) { + env.Output.Write("\n") + return true, nil + } + } +} + +func transformedDefault(options TextOptions) string { + value, err := transformText(options, options.Default) + if err != nil { + return options.Default + } + return value +} + +func transformText(options TextOptions, value string) (string, error) { + if options.Transform == nil { + return value, nil + } + + return options.Transform(value) +} + +func preserveInvalidTextDefault(options *TextOptions) prompt.Validator[string] { + return func(value string) (string, error) { + if options.Validate == nil { + return "", nil + } + + message, err := options.Validate(value) + if err != nil { + return "", err + } + if message != "" { + options.Default = value + } + + return message, nil + } +} From 8a97d4d54ff41a19a6dfe6cf6ef48ba04446242d Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Sat, 13 Jun 2026 17:09:48 +0800 Subject: [PATCH 276/675] feat(shell): port choice search and suggest prompts --- console/shell/internal/prompts/choices.go | 689 +++++++++++++++++ .../shell/internal/prompts/choices_test.go | 249 ++++++ .../shell/internal/prompts/search_suggest.go | 728 ++++++++++++++++++ 3 files changed, 1666 insertions(+) create mode 100644 console/shell/internal/prompts/choices.go create mode 100644 console/shell/internal/prompts/choices_test.go create mode 100644 console/shell/internal/prompts/search_suggest.go diff --git a/console/shell/internal/prompts/choices.go b/console/shell/internal/prompts/choices.go new file mode 100644 index 00000000..cb0a7faf --- /dev/null +++ b/console/shell/internal/prompts/choices.go @@ -0,0 +1,689 @@ +package prompts + +import ( + "errors" + "fmt" + "reflect" + "sort" + "strconv" + stdstrings "strings" + + "github.com/oullin/tui/internal/environment" + "github.com/oullin/tui/internal/key" + "github.com/oullin/tui/internal/prompt" + "github.com/oullin/tui/internal/theme" + "github.com/oullin/tui/internal/typedvalue" +) + +type Choice struct { + Label string + Value any + Disabled bool + DisabledReason string + Hint string +} + +type ChoiceOptions struct { + Message string + Choices []Choice + Default any + HasDefault bool + Required prompt.Required + Validate prompt.Validator[any] + Hint string + Scroll int + Transform func(any) (any, error) +} + +type MultiChoiceOptions struct { + Message string + Choices []Choice + Defaults []any + Required prompt.Required + Validate prompt.Validator[[]any] + Hint string + Scroll int + Transform func([]any) ([]any, error) +} + +type ConfirmOptions struct { + Message string + Default bool + HasDefault bool + Yes string + No string + Required prompt.Required + Validate prompt.Validator[bool] + Hint string + Transform func(bool) (bool, error) +} + +func NewStringChoices(values ...string) []Choice { + choices := make([]Choice, len(values)) + for index, value := range values { + choices[index] = Choice{Label: value, Value: value} + } + + return choices +} + +func Select(options ChoiceOptions) (any, error) { + if options.Scroll == 0 { + options.Scroll = 5 + } + validate := preserveInvalidChoiceDefault(&options) + defaultValue := transformedChoiceDefault(options) + + return prompt.UntilValid( + prompt.Options[any]{Message: options.Message, Default: defaultValue, Required: requiredDefault(options.Required, true), Validate: validate}, + func(_ int) (any, error) { + value, label, submitted, err := readSelectedChoice(options) + if err != nil { + return nil, err + } + value, err = transformChoice(options, value) + if err != nil { + return nil, err + } + if submitted { + renderSubmittedChoice(options.Message, label) + } + return value, nil + }, + nil, + ) +} + +func Multiselect(options MultiChoiceOptions) ([]any, error) { + if options.Scroll == 0 { + options.Scroll = 5 + } + + defaultValue := options.Defaults + if options.Transform != nil { + transformed, err := options.Transform(defaultValue) + if err == nil { + defaultValue = transformed + } + } + + return prompt.UntilValid( + prompt.Options[[]any]{Message: options.Message, Default: defaultValue, Required: options.Required, Validate: options.Validate}, + func(_ int) ([]any, error) { + value, labels, submitted, err := readMultipleChoices(options) + if err != nil { + return nil, err + } + if options.Transform != nil { + value, err = options.Transform(value) + if err != nil { + return nil, err + } + } + if submitted { + renderSubmittedChoices(options.Message, labels) + } + return value, nil + }, + nil, + ) +} + +func Confirm(options ConfirmOptions) (bool, error) { + if options.Yes == "" { + options.Yes = "Yes" + } + if options.No == "" { + options.No = "No" + } + defaultValue := true + if options.HasDefault { + defaultValue = options.Default + } + if options.Transform != nil { + transformed, err := options.Transform(defaultValue) + if err == nil { + defaultValue = transformed + } + } + + return prompt.UntilValid( + prompt.Options[bool]{Message: options.Message, Default: defaultValue, Required: options.Required, Validate: options.Validate}, + func(_ int) (bool, error) { + value, submitted, err := readConfirm(options) + if err != nil { + return false, err + } + if options.Transform != nil { + value, err = options.Transform(value) + if err != nil { + return false, err + } + } + if submitted { + renderSubmittedConfirm(options, value) + } + return value, nil + }, + nil, + ) +} + +func readSelectedChoice(options ChoiceOptions) (any, string, bool, error) { + env := environment.Current() + selected := defaultChoiceIndex(options.Choices, options.Default, options.HasDefault) + + renderSelectedChoice(options.Message, options.Choices, selected, options.Scroll) + for { + pressed, ok, err := env.Input.ReadKey() + if err != nil { + if errors.Is(err, environment.ErrKeyInputUnsupported) { + answer, lineErr := env.Input.ReadLine(choiceLineQuestion(options.Message, options.Choices, options.Hint)) + if lineErr != nil { + return nil, "", false, lineErr + } + if stdstrings.TrimSpace(answer) == "" && options.HasDefault { + choice := choiceByValue(options.Choices, options.Default) + if choice != nil { + return choice.Value, choice.Label, false, nil + } + } + choice := findChoice(options.Choices, answer) + if choice == nil || choice.Disabled { + return nil, "", false, prompt.ValidationError("Please select a valid option.") + } + return choice.Value, choice.Label, false, nil + } + return nil, "", false, err + } + if !ok { + if options.HasDefault { + choice := choiceByValue(options.Choices, options.Default) + if choice != nil { + return choice.Value, choice.Label, false, nil + } + } + return nil, "", false, prompt.ValidationError("Please select a valid option.") + } + + if pressed == key.First(key.CtrlC) { + renderCancelledChoice(options.Message, options.Choices, selected, options.Scroll) + choice := options.Choices[selected] + value, cancelErr := environment.CancelPrompt(fmt.Sprint(choice.Value)) + if cancelErr != nil { + return nil, "", false, cancelErr + } + return valueOrOriginal(choice.Value, value), choice.Label, false, nil + } + + if numeric, ok := choiceIndex(pressed); ok && numeric >= 0 && numeric < len(options.Choices) && !options.Choices[numeric].Disabled { + choice := options.Choices[numeric] + return choice.Value, choice.Label, true, nil + } + + if action, ok := navigationDirection(pressed); ok { + selected = moveChoiceHighlight(options.Choices, selected, action) + renderSelectedChoice(options.Message, options.Choices, selected, options.Scroll) + continue + } + + if pressed == key.First(key.Enter) { + choice := options.Choices[selected] + if choice.Disabled { + return nil, "", false, prompt.ValidationError("Please select a valid option.") + } + return choice.Value, choice.Label, true, nil + } + } +} + +func readMultipleChoices(options MultiChoiceOptions) ([]any, []string, bool, error) { + env := environment.Current() + selected := firstEnabledIndex(options.Choices) + marked := markedChoiceIndexes(options.Choices, options.Defaults) + + renderMultipleChoices(options.Message, options.Choices, selected, marked, options.Scroll) + for { + pressed, ok, err := env.Input.ReadKey() + if err != nil { + if errors.Is(err, environment.ErrKeyInputUnsupported) { + answer, lineErr := env.Input.ReadLine(choiceLineQuestion(options.Message, options.Choices, options.Hint)) + if lineErr != nil { + return nil, nil, false, lineErr + } + if stdstrings.TrimSpace(answer) == "" { + return options.Defaults, nil, false, nil + } + return choicesFromCommaSeparated(options.Choices, answer), nil, false, nil + } + return nil, nil, false, err + } + if !ok || pressed == key.First(key.Enter) { + return markedChoiceValues(options.Choices, marked), markedChoiceLabels(options.Choices, marked), true, nil + } + if pressed == key.First(key.CtrlC) { + renderCancelledChoices(options.Message, options.Choices, selected, marked, options.Scroll) + return markedChoiceValues(options.Choices, marked), markedChoiceLabels(options.Choices, marked), false, nil + } + if stdstrings.Contains(pressed, ",") { + return choicesFromCommaSeparated(options.Choices, pressed), nil, false, nil + } + if numeric, ok := choiceIndex(pressed); ok && numeric >= 0 && numeric < len(options.Choices) && !options.Choices[numeric].Disabled { + toggleMarked(marked, numeric) + renderMultipleChoices(options.Message, options.Choices, selected, marked, options.Scroll) + continue + } + if action, ok := navigationDirection(pressed); ok { + selected = moveChoiceHighlight(options.Choices, selected, action) + renderMultipleChoices(options.Message, options.Choices, selected, marked, options.Scroll) + continue + } + if pressed == key.First(key.CtrlA) { + marked = toggleAllEnabledChoices(options.Choices, marked) + renderMultipleChoices(options.Message, options.Choices, selected, marked, options.Scroll) + continue + } + if pressed == key.First(key.Space) { + if !options.Choices[selected].Disabled { + toggleMarked(marked, selected) + } + renderMultipleChoices(options.Message, options.Choices, selected, marked, options.Scroll) + continue + } + } +} + +func readConfirm(options ConfirmOptions) (bool, bool, error) { + env := environment.Current() + confirmed := true + if options.HasDefault { + confirmed = options.Default + } + renderActiveConfirm(options, confirmed) + + for { + pressed, ok, err := env.Input.ReadKey() + if err != nil { + if errors.Is(err, environment.ErrKeyInputUnsupported) { + suffix := " [Y/n]" + if options.HasDefault && !options.Default { + suffix = " [y/N]" + } + answer, lineErr := env.Input.ReadLine(theme.RenderQuestion(options.Message+suffix, options.Hint)) + if lineErr != nil { + return false, false, lineErr + } + normalized := stdstrings.ToLower(stdstrings.TrimSpace(answer)) + if normalized == "" && options.HasDefault { + return options.Default, false, nil + } + return normalized == "y" || normalized == "yes" || normalized == stdstrings.ToLower(options.Yes), false, nil + } + return false, false, err + } + if !ok { + return confirmed, true, nil + } + switch stdstrings.ToLower(pressed) { + case "y": + confirmed = true + renderActiveConfirm(options, confirmed) + case "n": + confirmed = false + renderActiveConfirm(options, confirmed) + default: + if pressed == key.First(key.Enter) { + return confirmed, true, nil + } + if pressed == key.First(key.CtrlC) { + renderCancelledConfirm(options, confirmed) + return confirmed, false, nil + } + if isConfirmToggleKey(pressed) { + confirmed = !confirmed + renderActiveConfirm(options, confirmed) + } + } + } +} + +func renderSelectedChoice(message string, choices []Choice, selected int, scroll int) { + rows := choiceRows(choices, selected, nil, false) + environment.Current().Output.Write(theme.RenderBox(theme.BoxOptions{Body: windowedRows(rows, selected, scroll), BorderStyle: theme.Cyan, Title: theme.Cyan(message)}) + "\n") +} + +func renderMultipleChoices(message string, choices []Choice, selected int, marked map[int]bool, scroll int) { + rows := choiceRows(choices, selected, marked, true) + environment.Current().Output.Write(theme.RenderBox(theme.BoxOptions{Body: windowedRows(rows, selected, scroll), BorderStyle: theme.Cyan, Info: "Use the space bar to select options.", Title: theme.Cyan(message)}) + "\n") +} + +func renderCancelledChoice(message string, choices []Choice, selected int, scroll int) { + rows := choiceRows(choices, selected, nil, false) + env := environment.Current() + env.Output.Write(theme.RenderBox(theme.BoxOptions{Body: windowedRows(rows, selected, scroll), BorderStyle: theme.Red, Title: message}) + "\n") + env.ErrorOutput.Write(theme.Red(" ⚠ Cancelled.") + "\n") +} + +func renderCancelledChoices(message string, choices []Choice, selected int, marked map[int]bool, scroll int) { + rows := choiceRows(choices, selected, marked, true) + env := environment.Current() + env.Output.Write(theme.RenderBox(theme.BoxOptions{Body: windowedRows(rows, selected, scroll), BorderStyle: theme.Red, Title: message}) + "\n") + env.ErrorOutput.Write(theme.Red(" ⚠ Cancelled.") + "\n") +} + +func renderSubmittedChoice(message string, label string) { + environment.Current().Output.Write(theme.RenderBox(theme.BoxOptions{Body: label, Title: theme.Dim(message)}) + "\n") +} + +func renderSubmittedChoices(message string, labels []string) { + environment.Current().Output.Write(theme.RenderBox(theme.BoxOptions{Body: stdstrings.Join(labels, "\n"), Title: theme.Dim(message)}) + "\n") +} + +func renderActiveConfirm(options ConfirmOptions, confirmed bool) { + yes := "○ " + options.Yes + no := "○ " + options.No + if confirmed { + yes = theme.Green("●") + " " + options.Yes + } else { + no = theme.Green("●") + " " + options.No + } + environment.Current().Output.Write(theme.RenderBox(theme.BoxOptions{Body: yes + " / " + no, BorderStyle: theme.Cyan, Info: options.Hint, Title: theme.Cyan(options.Message)}) + "\n") +} + +func renderCancelledConfirm(options ConfirmOptions, confirmed bool) { + yes := "○ " + options.Yes + no := "○ " + options.No + if confirmed { + yes = "● " + options.Yes + } else { + no = "● " + options.No + } + env := environment.Current() + env.Output.Write(theme.RenderBox(theme.BoxOptions{Body: yes + " / " + no, BorderStyle: theme.Red, Title: options.Message}) + "\n") + env.ErrorOutput.Write(theme.Red(" ⚠ Cancelled.") + "\n") +} + +func renderSubmittedConfirm(options ConfirmOptions, value bool) { + label := options.No + if value { + label = options.Yes + } + environment.Current().Output.Write(theme.RenderBox(theme.BoxOptions{Body: label, Title: theme.Dim(options.Message)}) + "\n") +} + +func choiceRows(choices []Choice, selected int, marked map[int]bool, multiple bool) []string { + rows := make([]string, len(choices)) + for index, choice := range choices { + pointer := " " + if index == selected { + pointer = "›" + } + marker := "○" + if multiple { + if marked[index] { + marker = "◼" + } else { + marker = "◻" + } + } else if index == selected { + marker = "●" + } + label := choice.Label + if choice.Hint != "" { + label += " " + choice.Hint + } + if choice.Disabled { + reason := "disabled" + if choice.DisabledReason != "" { + reason = choice.DisabledReason + } + label += " (" + reason + ")" + } + if index == selected { + rows[index] = theme.Cyan(pointer+" "+marker) + " " + label + } else { + rows[index] = " " + marker + " " + label + } + } + return rows +} + +func windowedRows(rows []string, selected int, scroll int) string { + if scroll <= 0 || scroll >= len(rows) { + return stdstrings.Join(rows, "\n") + } + start := selected - (scroll-1)/2 + if start < 0 { + start = 0 + } + if start+scroll > len(rows) { + start = len(rows) - scroll + } + window := append([]string{}, rows[start:start+scroll]...) + return stdstrings.Join(renderScrollbarRows(window, start, scroll, len(rows)), "\n") +} + +func choiceLineQuestion(message string, choices []Choice, hint string) string { + rendered := make([]string, len(choices)) + for index, choice := range choices { + rendered[index] = fmt.Sprintf(" %d. %s", index+1, choice.Label) + } + return theme.RenderQuestion(message+"\n"+stdstrings.Join(rendered, "\n")+"\n", hint) +} + +func firstEnabledIndex(choices []Choice) int { + for index, choice := range choices { + if !choice.Disabled { + return index + } + } + return 0 +} + +func defaultChoiceIndex(choices []Choice, value any, hasDefault bool) int { + if !hasDefault { + return firstEnabledIndex(choices) + } + for index, choice := range choices { + if !choice.Disabled && reflect.DeepEqual(choice.Value, value) { + return index + } + } + return firstEnabledIndex(choices) +} + +func choiceByValue(choices []Choice, value any) *Choice { + for index, choice := range choices { + if !choice.Disabled && reflect.DeepEqual(choice.Value, value) { + return &choices[index] + } + } + return nil +} + +func findChoice(choices []Choice, answer string) *Choice { + normalized := stdstrings.TrimSpace(answer) + if numeric, err := strconv.Atoi(normalized); err == nil { + index := numeric - 1 + if index >= 0 && index < len(choices) { + return &choices[index] + } + } + for index, choice := range choices { + if choice.Label == normalized || fmt.Sprint(choice.Value) == normalized { + return &choices[index] + } + } + return nil +} + +func moveChoiceHighlight(choices []Choice, current int, direction int) int { + if len(choices) == 0 { + return 0 + } + index := current + for range choices { + index = (index + direction + len(choices)) % len(choices) + if !choices[index].Disabled { + return index + } + } + return current +} + +func navigationDirection(pressed string) (int, bool) { + switch pressed { + case key.First(key.Down), key.First(key.DownArrow), key.First(key.Tab), "j", "l", key.First(key.Right), key.First(key.RightArrow), key.First(key.CtrlN), key.First(key.CtrlF): + return 1, true + case key.First(key.Up), key.First(key.UpArrow), key.First(key.ShiftTab), "k", "h", key.First(key.Left), key.First(key.LeftArrow), key.First(key.CtrlP), key.First(key.CtrlB): + return -1, true + default: + return 0, false + } +} + +func choiceIndex(value string) (int, bool) { + numeric, err := strconv.Atoi(value) + if err != nil { + return 0, false + } + return numeric - 1, true +} + +func markedChoiceIndexes(choices []Choice, defaults []any) map[int]bool { + marked := map[int]bool{} + for index, choice := range choices { + for _, value := range defaults { + if reflect.DeepEqual(choice.Value, value) { + marked[index] = true + } + } + } + return marked +} + +func markedChoiceValues(choices []Choice, marked map[int]bool) []any { + indexes := sortedMarkedIndexes(marked) + values := make([]any, 0, len(indexes)) + for _, index := range indexes { + values = append(values, choices[index].Value) + } + return values +} + +func markedChoiceLabels(choices []Choice, marked map[int]bool) []string { + indexes := sortedMarkedIndexes(marked) + labels := make([]string, 0, len(indexes)) + for _, index := range indexes { + labels = append(labels, choices[index].Label) + } + return labels +} + +func sortedMarkedIndexes(marked map[int]bool) []int { + indexes := make([]int, 0, len(marked)) + for index, enabled := range marked { + if enabled { + indexes = append(indexes, index) + } + } + sort.Ints(indexes) + return indexes +} + +func toggleMarked(marked map[int]bool, index int) { + if marked[index] { + delete(marked, index) + } else { + marked[index] = true + } +} + +func toggleAllEnabledChoices(choices []Choice, marked map[int]bool) map[int]bool { + allMarked := true + for index, choice := range choices { + if !choice.Disabled && !marked[index] { + allMarked = false + break + } + } + next := map[int]bool{} + if !allMarked { + for index, choice := range choices { + if !choice.Disabled { + next[index] = true + } + } + } + return next +} + +func choicesFromCommaSeparated(choices []Choice, answer string) []any { + parts := stdstrings.Split(answer, ",") + values := make([]any, 0, len(parts)) + for _, part := range parts { + choice := findChoice(choices, part) + if choice != nil && !choice.Disabled { + values = append(values, choice.Value) + } + } + return values +} + +func isConfirmToggleKey(pressed string) bool { + _, ok := navigationDirection(pressed) + return ok || pressed == key.First(key.PageDown) || pressed == key.First(key.PageUp) +} + +func valueOrOriginal(original any, value string) any { + if value == fmt.Sprint(original) { + return original + } + return value +} + +func transformChoice(options ChoiceOptions, value any) (any, error) { + if options.Transform == nil { + return value, nil + } + return options.Transform(value) +} + +func transformedChoiceDefault(options ChoiceOptions) any { + if !options.HasDefault { + return nil + } + value, err := transformChoice(options, options.Default) + if err != nil { + return options.Default + } + return value +} + +func preserveInvalidChoiceDefault(options *ChoiceOptions) prompt.Validator[any] { + return func(value any) (string, error) { + if options.Validate == nil { + return "", nil + } + message, err := options.Validate(value) + if err != nil { + return "", err + } + if message != "" { + options.Default = value + options.HasDefault = true + } + return message, nil + } +} + +func requiredDefault(required prompt.Required, enabled bool) prompt.Required { + if required.Enabled || required.Message != "" { + return required + } + return prompt.Required{Enabled: enabled} +} + +func currentInputState(value string) typedvalue.State { + return typedvalue.State{Cursor: typedvalue.CharacterLength(value), Value: value} +} diff --git a/console/shell/internal/prompts/choices_test.go b/console/shell/internal/prompts/choices_test.go new file mode 100644 index 00000000..362569a5 --- /dev/null +++ b/console/shell/internal/prompts/choices_test.go @@ -0,0 +1,249 @@ +package prompts + +import ( + "reflect" + "strings" + "testing" + + "github.com/oullin/tui/internal/environment" + "github.com/oullin/tui/internal/key" + "github.com/oullin/tui/internal/prompt" + textutil "github.com/oullin/tui/internal/strings" +) + +func TestConfirmSelectsAndToggles(t *testing.T) { + err := withTestEnvironment(environment.NewScriptedInput([]string{"y", key.First(key.Enter)}), func(_ *environment.MemoryOutput) error { + result, err := Confirm(ConfirmOptions{Message: "Continue?", Default: false, HasDefault: true}) + if err != nil { + return err + } + if !result { + t.Fatalf("Confirm() = false") + } + return nil + }) + if err != nil { + t.Fatal(err) + } + + err = withTestEnvironment(environment.NewScriptedInput([]string{key.First(key.Left), "l", key.First(key.Enter)}), func(output *environment.MemoryOutput) error { + result, err := Confirm(ConfirmOptions{Message: "Continue?"}) + if err != nil { + return err + } + if !result { + t.Fatalf("Confirm(toggle) = false") + } + if !strings.Contains(output.Text(), "\x1b[36mContinue?\x1b[39m") || !strings.Contains(output.Text(), "\x1b[32m●\x1b[39m Yes") { + t.Fatalf("confirm output missing active frame:\n%s", output.Text()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestSelectNavigatesDefaultsAndTransforms(t *testing.T) { + choices := NewStringChoices("first", "second", "third") + err := withTestEnvironment(environment.NewScriptedInput([]string{key.First(key.Down), key.First(key.Enter)}), func(output *environment.MemoryOutput) error { + result, err := Select(ChoiceOptions{Message: "Pick one", Choices: choices}) + if err != nil { + return err + } + if result != "second" { + t.Fatalf("Select() = %#v", result) + } + if !strings.Contains(output.Text(), "Pick one") || !strings.Contains(output.Text(), "second") { + t.Fatalf("select output missing expected frame:\n%s", output.Text()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } + + interactive := false + err = environment.With(environment.Patch{Output: environment.NewMemoryOutput(), ErrorOutput: environment.NewMemoryOutput(), Interactive: &interactive}, func() error { + result, err := Select(ChoiceOptions{ + Message: "Pick one", + Choices: choices, + Default: "second", + HasDefault: true, + Transform: func(value any) (any, error) { + return strings.ToUpper(value.(string)), nil + }, + Validate: func(value any) (string, error) { + if value == "SECOND" { + return "", nil + } + return "Unexpected value.", nil + }, + }) + if err != nil { + return err + } + if result != "SECOND" { + t.Fatalf("noninteractive Select() = %#v", result) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestMultiselectTogglesChoices(t *testing.T) { + err := withTestEnvironment(environment.NewScriptedInput([]string{key.First(key.Space), key.First(key.Down), key.First(key.Space), key.First(key.Enter)}), func(output *environment.MemoryOutput) error { + result, err := Multiselect(MultiChoiceOptions{Message: "Pick many", Choices: NewStringChoices("first", "second")}) + if err != nil { + return err + } + if !reflect.DeepEqual(result, []any{"first", "second"}) { + t.Fatalf("Multiselect() = %#v", result) + } + if !strings.Contains(output.Text(), "Use the space bar") { + t.Fatalf("multiselect output missing hint:\n%s", output.Text()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestSearchFiltersAndValidatesSelections(t *testing.T) { + colors := func(query string) []Choice { + all := []Choice{{Label: "Red", Value: "red"}, {Label: "Green", Value: "green"}, {Label: "Blue", Value: "blue"}} + if query == "" { + return all + } + filtered := make([]Choice, 0) + for _, choice := range all { + if strings.Contains(strings.ToLower(choice.Label), strings.ToLower(query)) { + filtered = append(filtered, choice) + } + } + return filtered + } + + err := withTestEnvironment(environment.NewScriptedInput([]string{"u", "e", key.First(key.Down), key.First(key.Enter)}), func(output *environment.MemoryOutput) error { + result, err := Search(SearchOptions{Message: "Favorite color?", Source: colors}) + if err != nil { + return err + } + if result != "blue" { + t.Fatalf("Search() = %#v", result) + } + if !strings.Contains(textutil.ParseAnsiText(output.Text()), "ue") || !strings.Contains(output.Text(), "Blue") { + t.Fatalf("search output missing query/result:\n%s", output.Text()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } + + err = withTestEnvironment(environment.NewScriptedInput([]string{key.First(key.Down), key.First(key.Enter), key.First(key.Down), key.First(key.Enter)}), func(output *environment.MemoryOutput) error { + result, err := Search(SearchOptions{ + Message: "Favorite color?", + Source: colors, + Validate: func(value any) (string, error) { + if value == "red" { + return "Please choose green.", nil + } + return "", nil + }, + }) + if err != nil { + return err + } + if result != "green" { + t.Fatalf("validated Search() = %#v", result) + } + if !strings.Contains(output.Text(), "Please choose green.") { + t.Fatalf("search output missing validation error:\n%s", output.Text()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestMultiSearchMarksHighlightedChoices(t *testing.T) { + err := withTestEnvironment(environment.NewScriptedInput([]string{key.First(key.Down), key.First(key.Space), key.First(key.Down), key.First(key.Space), key.First(key.Enter)}), func(_ *environment.MemoryOutput) error { + result, err := MultiSearch(MultiSearchOptions{Message: "Pick colors", Choices: NewStringChoices("red", "green", "blue")}) + if err != nil { + return err + } + if len(result) != 2 || result[0] == result[1] { + t.Fatalf("MultiSearch() = %#v", result) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestSuggestAndAutocomplete(t *testing.T) { + err := withTestEnvironment(environment.NewScriptedInput([]string{"b", key.First(key.Tab), key.First(key.Enter)}), func(output *environment.MemoryOutput) error { + result, err := Suggest(SuggestOptions{Message: "Favorite color?", Options: []string{"Red", "Green", "Blue"}}) + if err != nil { + return err + } + if result != "Blue" { + t.Fatalf("Suggest() = %q", result) + } + if !strings.Contains(output.Text(), "Blue") { + t.Fatalf("suggest output missing match:\n%s", output.Text()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } + + err = withTestEnvironment(environment.NewScriptedInput([]string{"b", key.First(key.Tab), key.First(key.Enter)}), func(output *environment.MemoryOutput) error { + result, err := Autocomplete(SuggestOptions{Message: "Favorite color?", Options: []string{"Red", "Green", "Blue"}}) + if err != nil { + return err + } + if result != "Blue" { + t.Fatalf("Autocomplete() = %q", result) + } + if !strings.Contains(textutil.ParseAnsiText(output.Text()), "Blue") { + t.Fatalf("autocomplete output missing completion:\n%s", output.Text()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestChoiceRequiredValidation(t *testing.T) { + interactive := false + err := environment.With(environment.Patch{Output: environment.NewMemoryOutput(), ErrorOutput: environment.NewMemoryOutput(), Interactive: &interactive}, func() error { + _, err := Select(ChoiceOptions{Message: "Pick one", Choices: NewStringChoices("first", "second")}) + var validation environment.PromptValidationError + if err == nil || !errorsAs(err, &validation) || validation.Message != "Required." { + t.Fatalf("expected required validation error, got %v", err) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func errorsAs(err error, target *environment.PromptValidationError) bool { + if validation, ok := err.(environment.PromptValidationError); ok { + *target = validation + return true + } + return false +} + +var _ = prompt.Required{} diff --git a/console/shell/internal/prompts/search_suggest.go b/console/shell/internal/prompts/search_suggest.go new file mode 100644 index 00000000..ea591b65 --- /dev/null +++ b/console/shell/internal/prompts/search_suggest.go @@ -0,0 +1,728 @@ +package prompts + +import ( + "errors" + "reflect" + stdstrings "strings" + + "github.com/oullin/tui/internal/environment" + "github.com/oullin/tui/internal/key" + "github.com/oullin/tui/internal/prompt" + "github.com/oullin/tui/internal/theme" + "github.com/oullin/tui/internal/typedvalue" +) + +type SearchSource func(query string) []Choice + +type SearchOptions struct { + Message string + Choices []Choice + Source SearchSource + Default any + HasDefault bool + Required prompt.Required + Validate prompt.Validator[any] + Placeholder string + Scroll int + Transform func(any) (any, error) +} + +type MultiSearchOptions struct { + Message string + Choices []Choice + Source SearchSource + Defaults []any + Required prompt.Required + Validate prompt.Validator[[]any] + Placeholder string + Scroll int + Transform func([]any) ([]any, error) +} + +type SuggestSource func(query string) []string + +type SuggestOptions struct { + Message string + Options []string + Source SuggestSource + Placeholder string + Default string + Required prompt.Required + Validate prompt.Validator[string] + Hint string + Scroll int + Transform func(string) (string, error) +} + +func Search(options SearchOptions) (any, error) { + if options.Scroll == 0 { + options.Scroll = 5 + } + validate := preserveInvalidSearchDefault(&options) + defaultValue := options.Default + if options.HasDefault && options.Transform != nil { + transformed, err := options.Transform(defaultValue) + if err == nil { + defaultValue = transformed + } + } + + return prompt.UntilValid( + prompt.Options[any]{Message: options.Message, Default: defaultValue, Required: requiredDefault(options.Required, true), Validate: validate}, + func(attempt int) (any, error) { + value, label, submitted, err := readSearchChoice(options, attempt) + if err != nil { + return nil, err + } + if value == nil { + return nil, prompt.ValidationError("Please select a valid option.") + } + if options.Transform != nil { + value, err = options.Transform(value) + if err != nil { + return nil, err + } + } + if submitted { + renderSubmittedChoice(options.Message, label) + } + return value, nil + }, + nil, + ) +} + +func MultiSearch(options MultiSearchOptions) ([]any, error) { + if options.Scroll == 0 { + options.Scroll = 5 + } + return prompt.UntilValid( + prompt.Options[[]any]{Message: options.Message, Default: options.Defaults, Required: options.Required, Validate: options.Validate}, + func(_ int) ([]any, error) { + value, labels, submitted, err := readMultiSearchChoices(options) + if err != nil { + return nil, err + } + if options.Transform != nil { + value, err = options.Transform(value) + if err != nil { + return nil, err + } + } + if submitted { + renderSubmittedChoices(options.Message, labels) + } + return value, nil + }, + nil, + ) +} + +func Suggest(options SuggestOptions) (string, error) { + shouldRenderSubmittedFrame := false + validate := preserveInvalidSuggestDefault(&options) + + return prompt.UntilValid( + prompt.Options[string]{Message: options.Message, Default: transformedSuggestDefault(options), Required: options.Required, Validate: validate}, + func(_ int) (string, error) { + value, rendered, err := readSuggestionValue(options) + if err != nil { + return "", err + } + if value == "" { + value = options.Default + } + shouldRenderSubmittedFrame = rendered + if options.Transform != nil { + return options.Transform(value) + } + return value, nil + }, + func(value string) error { + if shouldRenderSubmittedFrame { + renderSubmittedTypedValue(options.Message, value) + } + return nil + }, + ) +} + +func Autocomplete(options SuggestOptions) (string, error) { + shouldRenderSubmittedFrame := false + validate := preserveInvalidSuggestDefault(&options) + + return prompt.UntilValid( + prompt.Options[string]{Message: options.Message, Default: transformedSuggestDefault(options), Required: options.Required, Validate: validate}, + func(_ int) (string, error) { + value, rendered, err := readAutocompleteValue(options) + if err != nil { + return "", err + } + if value == "" { + value = options.Default + } + shouldRenderSubmittedFrame = rendered + if options.Transform != nil { + return options.Transform(value) + } + return value, nil + }, + func(value string) error { + if shouldRenderSubmittedFrame { + renderSubmittedTypedValue(options.Message, value) + } + return nil + }, + ) +} + +func readSearchChoice(options SearchOptions, attempt int) (any, string, bool, error) { + env := environment.Current() + state := typedvalue.State{} + choices := resolveSearchChoices(options, "") + highlighted := -1 + if attempt > 0 && len(choices) > 0 { + highlighted = 0 + } + renderSearchChoices(options.Message, state.Value, choices, highlighted, nil, false, options.Placeholder, options.Scroll) + + for { + pressed, ok, err := env.Input.ReadKey() + if err != nil { + if errors.Is(err, environment.ErrKeyInputUnsupported) { + answer, lineErr := env.Input.ReadLine(theme.RenderQuestion(options.Message, "")) + if lineErr != nil { + return nil, "", false, lineErr + } + if stdstrings.TrimSpace(answer) == "" && options.HasDefault { + return options.Default, "", false, nil + } + choice := findChoice(resolveSearchChoices(options, answer), answer) + if choice == nil { + return nil, "", false, nil + } + return choice.Value, choice.Label, false, nil + } + return nil, "", false, err + } + if !ok { + return options.Default, "", false, nil + } + if pressed == key.First(key.CtrlC) { + renderCancelledSearch(options.Message, state.Value, options.Placeholder) + if highlighted >= 0 && highlighted < len(choices) && !choices[highlighted].Disabled { + return choices[highlighted].Value, choices[highlighted].Label, false, nil + } + return options.Default, "", false, nil + } + if action, ok := searchNavigationDirection(pressed); ok && (highlighted >= 0 || action > 0 || attempt > 0) { + choices = resolveSearchChoices(options, state.Value) + highlighted = moveSearchHighlight(choices, highlighted, action, options.Scroll) + renderSearchChoices(options.Message, state.Value, choices, highlighted, nil, false, options.Placeholder, options.Scroll) + continue + } + if clearsHighlight(pressed) && highlighted >= 0 { + highlighted = -1 + renderSearchChoices(options.Message, state.Value, choices, highlighted, nil, false, options.Placeholder, options.Scroll) + continue + } + if pressed == key.First(key.Enter) { + choices = resolveSearchChoices(options, state.Value) + if highlighted >= 0 && highlighted < len(choices) { + choice := choices[highlighted] + return choice.Value, choice.Label, !choice.Disabled, nil + } + if state.Value == "" && options.HasDefault { + choice := choiceByValue(choices, options.Default) + if choice != nil { + return choice.Value, choice.Label, true, nil + } + return options.Default, "", false, nil + } + highlighted = -1 + renderSearchChoices(options.Message, state.Value, choices, highlighted, nil, false, options.Placeholder, options.Scroll) + continue + } + next := typedvalue.ApplyKey(state, pressed, false) + if next.Cancelled { + renderCancelledSearch(options.Message, state.Value, options.Placeholder) + return options.Default, "", false, nil + } + state = typedvalue.State{Cursor: next.Cursor, Value: next.Value} + highlighted = -1 + choices = resolveSearchChoices(options, state.Value) + renderSearchChoices(options.Message, state.Value, choices, highlighted, nil, false, options.Placeholder, options.Scroll) + } +} + +func readMultiSearchChoices(options MultiSearchOptions) ([]any, []string, bool, error) { + env := environment.Current() + state := typedvalue.State{} + choices := resolveMultiSearchChoices(options, "") + highlighted := -1 + selected := map[any]string{} + for _, value := range options.Defaults { + if choice := choiceByValue(choices, value); choice != nil { + selected[choice.Value] = choice.Label + } + } + renderSearchChoices(options.Message, state.Value, choices, highlighted, searchMarked(choices, selected), true, options.Placeholder, options.Scroll) + + for { + pressed, ok, err := env.Input.ReadKey() + if err != nil { + if errors.Is(err, environment.ErrKeyInputUnsupported) { + answer, lineErr := env.Input.ReadLine(theme.RenderQuestion(options.Message, "")) + if lineErr != nil { + return nil, nil, false, lineErr + } + if stdstrings.TrimSpace(answer) == "" { + return options.Defaults, nil, false, nil + } + return choicesFromCommaSeparated(resolveMultiSearchChoices(options, answer), answer), nil, false, nil + } + return nil, nil, false, err + } + if !ok || pressed == key.First(key.Enter) { + values, labels := selectedSearchValues(selected) + return values, labels, true, nil + } + if pressed == key.First(key.CtrlC) { + renderCancelledSearch(options.Message, state.Value, options.Placeholder) + values, labels := selectedSearchValues(selected) + return values, labels, false, nil + } + if action, ok := searchNavigationDirection(pressed); ok && (highlighted >= 0 || action > 0) { + choices = resolveMultiSearchChoices(options, state.Value) + highlighted = moveSearchHighlight(choices, highlighted, action, options.Scroll) + renderSearchChoices(options.Message, state.Value, choices, highlighted, searchMarked(choices, selected), true, options.Placeholder, options.Scroll) + continue + } + if pressed == key.First(key.Space) && highlighted >= 0 && highlighted < len(choices) { + toggleSearchSelection(selected, choices[highlighted]) + renderSearchChoices(options.Message, state.Value, choices, highlighted, searchMarked(choices, selected), true, options.Placeholder, options.Scroll) + continue + } + next := typedvalue.ApplyKey(state, pressed, false) + if next.Cancelled { + renderCancelledSearch(options.Message, state.Value, options.Placeholder) + return options.Defaults, nil, false, nil + } + state = typedvalue.State{Cursor: next.Cursor, Value: next.Value} + highlighted = -1 + choices = resolveMultiSearchChoices(options, state.Value) + renderSearchChoices(options.Message, state.Value, choices, highlighted, searchMarked(choices, selected), true, options.Placeholder, options.Scroll) + } +} + +func readSuggestionValue(options SuggestOptions) (string, bool, error) { + env := environment.Current() + state := typedvalue.InitialState(options.Default) + highlighted := -1 + matches := resolveSuggestions(options, state.Value) + renderSuggestions(options.Message, state.Value, matches, highlighted, options.Placeholder, options.Scroll) + + for { + pressed, ok, err := env.Input.ReadKey() + if err != nil { + if errors.Is(err, environment.ErrKeyInputUnsupported) { + answer, lineErr := env.Input.ReadLine(theme.RenderQuestion(options.Message, options.Hint)) + return answer, false, lineErr + } + return "", false, err + } + if !ok { + return state.Value, true, nil + } + if action, ok := suggestionNavigationDirection(pressed); ok && (highlighted >= 0 || action > 0) { + matches = resolveSuggestions(options, state.Value) + highlighted = moveSuggestionHighlight(matches, highlighted, action, options.Scroll) + renderSuggestions(options.Message, state.Value, matches, highlighted, options.Placeholder, options.Scroll) + continue + } + if clearsHighlight(pressed) && highlighted >= 0 { + highlighted = -1 + renderSuggestions(options.Message, state.Value, matches, highlighted, options.Placeholder, options.Scroll) + continue + } + if pressed == key.First(key.Tab) && highlighted < 0 { + matches = resolveSuggestions(options, state.Value) + highlighted = moveSuggestionHighlight(matches, highlighted, 1, options.Scroll) + renderSuggestions(options.Message, state.Value, matches, highlighted, options.Placeholder, options.Scroll) + continue + } + if pressed == key.First(key.Enter) { + if highlighted >= 0 && highlighted < len(matches) { + return matches[highlighted], true, nil + } + return state.Value, true, nil + } + next := typedvalue.ApplyKey(state, pressed, false) + if next.Cancelled { + renderCancelledSuggestion(options.Message, state.Value, options.Placeholder) + value, cancelErr := environment.CancelPrompt(state.Value) + return value, true, cancelErr + } + state = typedvalue.State{Cursor: next.Cursor, Value: next.Value} + highlighted = -1 + matches = resolveSuggestions(options, state.Value) + renderSuggestions(options.Message, state.Value, matches, highlighted, options.Placeholder, options.Scroll) + } +} + +func readAutocompleteValue(options SuggestOptions) (string, bool, error) { + env := environment.Current() + state := typedvalue.InitialState(options.Default) + highlighted := 0 + matches := resolveSuggestions(options, state.Value) + renderAutocomplete(options.Message, state, matches, highlighted, options.Placeholder) + + for { + pressed, ok, err := env.Input.ReadKey() + if err != nil { + if errors.Is(err, environment.ErrKeyInputUnsupported) { + answer, lineErr := env.Input.ReadLine(theme.RenderQuestion(options.Message, options.Hint)) + return answer, false, lineErr + } + return "", false, err + } + if !ok { + return state.Value, true, nil + } + if direction, ok := autocompleteNavigationDirection(pressed); ok { + matches = resolveSuggestions(options, state.Value) + highlighted = moveSuggestionHighlight(matches, highlighted, direction, 0) + renderAutocomplete(options.Message, state, matches, highlighted, options.Placeholder) + continue + } + if pressed == key.First(key.Tab) && canAcceptAutocomplete(state) { + matches = resolveSuggestions(options, state.Value) + if highlighted >= 0 && highlighted < len(matches) && typedvalue.CharacterLength(matches[highlighted]) > typedvalue.CharacterLength(state.Value) { + state = typedvalue.InitialState(matches[highlighted]) + matches = resolveSuggestions(options, state.Value) + } else { + highlighted = 0 + } + renderAutocomplete(options.Message, state, matches, highlighted, options.Placeholder) + continue + } + if (pressed == key.First(key.Right) || pressed == key.First(key.RightArrow)) && canAcceptAutocomplete(state) { + matches = resolveSuggestions(options, state.Value) + if highlighted >= 0 && highlighted < len(matches) { + state = typedvalue.InitialState(matches[highlighted]) + } + renderAutocomplete(options.Message, state, matches, highlighted, options.Placeholder) + continue + } + next := typedvalue.ApplyKey(state, pressed, false) + if next.Submitted { + return state.Value, true, nil + } + if next.Cancelled { + renderCancelledSuggestion(options.Message, state.Value, options.Placeholder) + value, cancelErr := environment.CancelPrompt(state.Value) + return value, true, cancelErr + } + state = typedvalue.State{Cursor: next.Cursor, Value: next.Value} + highlighted = 0 + matches = resolveSuggestions(options, state.Value) + renderAutocomplete(options.Message, state, matches, highlighted, options.Placeholder) + } +} + +func renderSearchChoices(message string, query string, choices []Choice, highlighted int, marked map[int]bool, multi bool, placeholder string, scroll int) { + bodyLines := []string{queryOrPlaceholder(query, placeholder)} + if len(choices) == 0 { + bodyLines = append(bodyLines, theme.Dim(" No results.")) + } else { + rows := make([]string, len(choices)) + for index, choice := range choices { + pointer := " " + if index == highlighted { + pointer = "›" + } + prefix := pointer + " " + if multi { + if marked[index] { + prefix += "◼ " + } else { + prefix += "◻ " + } + } + label := choice.Label + if choice.Disabled { + label = theme.Dim(label) + } + if index == highlighted { + rows[index] = theme.Cyan(prefix) + label + } else { + rows[index] = " " + prefix + label + } + } + bodyLines = append(bodyLines, windowedRows(rows, max(highlighted, 0), scroll)) + } + environment.Current().Output.Write(theme.RenderBox(theme.BoxOptions{Body: stdstrings.Join(bodyLines, "\n"), BorderStyle: theme.Cyan, Title: theme.Cyan(message)}) + "\n") +} + +func renderCancelledSearch(message string, query string, placeholder string) { + env := environment.Current() + env.Output.Write(theme.RenderBox(theme.BoxOptions{Body: theme.Strikethrough(theme.Dim(queryOrPlaceholder(query, placeholder))), BorderStyle: theme.Red, Title: theme.Dim(message)}) + "\n") + env.ErrorOutput.Write(theme.Red(" ⚠ Cancelled.") + "\n") +} + +func renderSuggestions(message string, query string, matches []string, highlighted int, placeholder string, scroll int) { + bodyLines := []string{queryOrPlaceholder(query, placeholder)} + rows := make([]string, len(matches)) + for index, match := range matches { + pointer := " " + if index == highlighted { + pointer = "›" + rows[index] = theme.Cyan(pointer) + " " + match + } else { + rows[index] = " " + theme.Dim(match) + } + } + if len(rows) > 0 { + bodyLines = append(bodyLines, windowedRows(rows, max(highlighted, 0), scroll)) + } + environment.Current().Output.Write(theme.RenderBox(theme.BoxOptions{Body: stdstrings.Join(bodyLines, "\n"), BorderStyle: theme.Cyan, Title: theme.Cyan(message)}) + "\n") +} + +func renderAutocomplete(message string, state typedvalue.State, matches []string, highlighted int, placeholder string) { + body := queryOrPlaceholder(state.Value, placeholder) + if canAcceptAutocomplete(state) && highlighted >= 0 && highlighted < len(matches) && stdstrings.HasPrefix(stdstrings.ToLower(matches[highlighted]), stdstrings.ToLower(state.Value)) { + match := matches[highlighted] + if len(match) > len(state.Value) { + body = state.Value + theme.Inverse(match[len(state.Value):len(state.Value)+1]) + theme.Dim(match[len(state.Value)+1:]) + } else { + body = match + } + } + environment.Current().Output.Write(theme.RenderBox(theme.BoxOptions{Body: body, BorderStyle: theme.Cyan, Title: theme.Cyan(message)}) + "\n") +} + +func renderCancelledSuggestion(message string, value string, placeholder string) { + env := environment.Current() + env.Output.Write(theme.RenderBox(theme.BoxOptions{Body: theme.Strikethrough(theme.Dim(queryOrPlaceholder(value, placeholder))), BorderStyle: theme.Red, Title: message}) + "\n") + env.ErrorOutput.Write(theme.Red(" ⚠ Cancelled.") + "\n") +} + +func resolveSearchChoices(options SearchOptions, query string) []Choice { + if options.Source != nil { + return options.Source(query) + } + return options.Choices +} + +func resolveMultiSearchChoices(options MultiSearchOptions, query string) []Choice { + if options.Source != nil { + return options.Source(query) + } + return options.Choices +} + +func resolveSuggestions(options SuggestOptions, query string) []string { + values := options.Options + if options.Source != nil { + values = options.Source(query) + } + if query == "" { + return values + } + filtered := make([]string, 0, len(values)) + lower := stdstrings.ToLower(query) + for _, value := range values { + if stdstrings.Contains(stdstrings.ToLower(value), lower) { + filtered = append(filtered, value) + } + } + return filtered +} + +func queryOrPlaceholder(query string, placeholder string) string { + if query != "" { + return query + } + return theme.Dim(placeholder) +} + +func searchNavigationDirection(pressed string) (int, bool) { + switch pressed { + case key.First(key.Down), key.First(key.DownArrow), key.First(key.CtrlN): + return 1, true + case key.First(key.Up), key.First(key.UpArrow), key.First(key.CtrlP): + return -1, true + case key.First(key.PageDown), key.First(key.End): + return 5, true + case key.First(key.PageUp), key.Home[0]: + return -5, true + default: + return 0, false + } +} + +func suggestionNavigationDirection(pressed string) (int, bool) { + if direction, ok := searchNavigationDirection(pressed); ok { + return direction, true + } + switch pressed { + case key.First(key.Tab): + return 1, true + case key.First(key.ShiftTab): + return -1, true + default: + return 0, false + } +} + +func autocompleteNavigationDirection(pressed string) (int, bool) { + switch pressed { + case key.First(key.Down), key.First(key.DownArrow): + return 1, true + case key.First(key.Up), key.First(key.UpArrow): + return -1, true + default: + return 0, false + } +} + +func moveSearchHighlight(choices []Choice, highlighted int, direction int, scroll int) int { + if len(choices) == 0 { + return -1 + } + index := highlighted + if index < 0 { + if direction < 0 { + index = len(choices) + } else { + index = -1 + } + } + step := 1 + if direction < 0 { + step = -1 + } + attempts := abs(direction) + if attempts == 0 { + attempts = 1 + } + for range attempts { + index = (index + step + len(choices)) % len(choices) + for choices[index].Disabled { + index = (index + step + len(choices)) % len(choices) + } + } + return index +} + +func moveSuggestionHighlight(matches []string, highlighted int, direction int, _ int) int { + if len(matches) == 0 { + return -1 + } + if highlighted < 0 { + if direction < 0 { + highlighted = len(matches) + } else { + highlighted = -1 + } + } + next := (highlighted + direction) % len(matches) + if next < 0 { + next += len(matches) + } + return next +} + +func clearsHighlight(pressed string) bool { + return pressed == key.First(key.Left) || pressed == key.First(key.LeftArrow) || pressed == key.First(key.Right) || pressed == key.First(key.RightArrow) || pressed == key.First(key.CtrlB) || pressed == key.First(key.CtrlF) +} + +func canAcceptAutocomplete(state typedvalue.State) bool { + return state.Cursor >= typedvalue.CharacterLength(state.Value) +} + +func preserveInvalidSearchDefault(options *SearchOptions) prompt.Validator[any] { + return func(value any) (string, error) { + if options.Validate == nil { + return "", nil + } + message, err := options.Validate(value) + if err != nil { + return "", err + } + if message != "" { + options.Default = value + options.HasDefault = true + } + return message, nil + } +} + +func preserveInvalidSuggestDefault(options *SuggestOptions) prompt.Validator[string] { + return func(value string) (string, error) { + if options.Validate == nil { + return "", nil + } + message, err := options.Validate(value) + if err != nil { + return "", err + } + if message != "" { + options.Default = value + } + return message, nil + } +} + +func transformedSuggestDefault(options SuggestOptions) string { + if options.Transform == nil { + return options.Default + } + value, err := options.Transform(options.Default) + if err != nil { + return options.Default + } + return value +} + +func searchMarked(choices []Choice, selected map[any]string) map[int]bool { + marked := map[int]bool{} + for index, choice := range choices { + if _, ok := selected[choice.Value]; ok { + marked[index] = true + } + } + return marked +} + +func toggleSearchSelection(selected map[any]string, choice Choice) { + if choice.Disabled { + return + } + if _, ok := selected[choice.Value]; ok { + delete(selected, choice.Value) + return + } + selected[choice.Value] = choice.Label +} + +func selectedSearchValues(selected map[any]string) ([]any, []string) { + values := make([]any, 0, len(selected)) + labels := make([]string, 0, len(selected)) + for value, label := range selected { + values = append(values, value) + labels = append(labels, label) + } + return values, labels +} + +func abs(value int) int { + if value < 0 { + return -value + } + return value +} + +func choiceEqual(left any, right any) bool { + return reflect.DeepEqual(left, right) +} From 2d8020f26973b4046fd6fc433893c157e65c2a18 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Sat, 13 Jun 2026 17:12:16 +0800 Subject: [PATCH 277/675] feat(shell): port output helpers and tables --- console/shell/internal/output/datatable.go | 300 +++++++++++++++++++ console/shell/internal/output/notes.go | 96 ++++++ console/shell/internal/output/notify.go | 90 ++++++ console/shell/internal/output/output_test.go | 178 +++++++++++ console/shell/internal/output/table.go | 152 ++++++++++ console/shell/internal/output/terminal.go | 14 + 6 files changed, 830 insertions(+) create mode 100644 console/shell/internal/output/datatable.go create mode 100644 console/shell/internal/output/notes.go create mode 100644 console/shell/internal/output/notify.go create mode 100644 console/shell/internal/output/output_test.go create mode 100644 console/shell/internal/output/table.go create mode 100644 console/shell/internal/output/terminal.go diff --git a/console/shell/internal/output/datatable.go b/console/shell/internal/output/datatable.go new file mode 100644 index 00000000..0369d3a4 --- /dev/null +++ b/console/shell/internal/output/datatable.go @@ -0,0 +1,300 @@ +package output + +import ( + "errors" + "fmt" + stdstrings "strings" + + "github.com/oullin/tui/internal/environment" + "github.com/oullin/tui/internal/key" + "github.com/oullin/tui/internal/prompt" + "github.com/oullin/tui/internal/theme" + "github.com/oullin/tui/internal/typedvalue" +) + +type DataTableRow struct { + Cells map[string]any + Value any +} + +type DataTableOptions struct { + Message string + Headers []string + Rows []DataTableRow + Default any + HasDefault bool + Required prompt.Required + Validate prompt.Validator[any] + Transform func(any) (any, error) + Filter func(string, DataTableRow) bool + Scroll int +} + +func DataTable(options DataTableOptions) (any, error) { + if options.Scroll == 0 { + options.Scroll = 10 + } + if len(options.Headers) == 0 { + options.Headers = deriveHeaders(options.Rows) + } + defaultValue := options.Default + if options.HasDefault && options.Transform != nil { + transformed, err := options.Transform(defaultValue) + if err == nil { + defaultValue = transformed + } + } + var submitted *dataTableSelection + return prompt.UntilValid( + prompt.Options[any]{Message: options.Message, Default: defaultValue, Required: options.Required, Validate: options.Validate}, + func(_ int) (any, error) { + selection, err := readDataTableSelection(options) + if err != nil { + return nil, err + } + value := selection.Value + if options.Transform != nil { + value, err = options.Transform(value) + if err != nil { + return nil, err + } + } + if selection.Submitted && !selection.Cancelled { + submitted = &selection + } + return value, nil + }, + func(any) error { + if submitted != nil { + renderSubmittedDataTableFrame(options.Message, options.Headers, submitted.Row) + } + return nil + }, + ) +} + +type dataTableSelection struct { + Cancelled bool + Submitted bool + Row DataTableRow + Value any +} + +func readDataTableSelection(options DataTableOptions) (dataTableSelection, error) { + env := environment.Current() + rows := visibleRows(options, "") + selected := defaultDataTableIndex(rows, options.Default, options.HasDefault) + query := "" + mode := "browse" + renderDataTableFrame(options, rows, selected, mode, query) + + for { + pressed, ok, err := env.Input.ReadKey() + if err != nil { + if errors.Is(err, environment.ErrKeyInputUnsupported) { + answer, lineErr := env.Input.ReadLine(theme.RenderQuestion(options.Message, "")) + if lineErr != nil { + return dataTableSelection{}, lineErr + } + rows = visibleRows(options, answer) + if len(rows) == 0 { + return dataTableSelection{}, prompt.ValidationError("Please select a valid option.") + } + return dataTableSelection{Row: rows[0], Value: dataTableRowValue(rows[0], 0)}, nil + } + return dataTableSelection{}, err + } + if !ok || pressed == key.First(key.Enter) { + if len(rows) == 0 { + return dataTableSelection{}, prompt.ValidationError("Please select a valid option.") + } + row := rows[selected] + return dataTableSelection{Submitted: true, Row: row, Value: dataTableRowValue(row, selected)}, nil + } + if pressed == key.First(key.CtrlC) { + if len(rows) == 0 { + return dataTableSelection{}, prompt.ValidationError("Please select a valid option.") + } + row := rows[selected] + renderCancelledDataTableFrame(options, rows, selected) + return dataTableSelection{Cancelled: true, Row: row, Value: dataTableRowValue(row, selected)}, nil + } + if pressed == "/" { + mode = "search" + query = "" + renderDataTableFrame(options, rows, selected, mode, query) + continue + } + if mode == "search" { + if pressed == key.First(key.Escape) { + mode = "browse" + query = "" + rows = visibleRows(options, query) + selected = clampSelection(selected, rows) + renderDataTableFrame(options, rows, selected, mode, query) + continue + } + next := typedvalue.ApplyKey(typedvalue.State{Cursor: typedvalue.CharacterLength(query), Value: query}, pressed, false) + if !next.Submitted && !next.Cancelled { + query = next.Value + rows = visibleRows(options, query) + selected = clampSelection(0, rows) + renderDataTableFrame(options, rows, selected, mode, query) + continue + } + } + if direction, ok := dataTableNavigation(pressed); ok { + selected = clampSelection(selected+direction, rows) + renderDataTableFrame(options, rows, selected, mode, query) + continue + } + } +} + +func renderDataTableFrame(options DataTableOptions, rows []DataTableRow, selected int, mode string, query string) { + title := options.Message + if mode == "search" || query != "" { + title += " " + query + } + if len(rows) == 0 { + environment.Current().Output.Write(title + "\n" + theme.RenderTable(nil, [][]string{{"No results found."}}) + "\n") + return + } + windowStart := 0 + windowEnd := len(rows) + if options.Scroll > 0 && options.Scroll < len(rows) { + windowStart = selected - (options.Scroll-1)/2 + if windowStart < 0 { + windowStart = 0 + } + if windowStart+options.Scroll > len(rows) { + windowStart = len(rows) - options.Scroll + } + windowEnd = windowStart + options.Scroll + } + rendered := make([][]string, windowEnd-windowStart) + for index := windowStart; index < windowEnd; index++ { + pointer := " " + if index == selected { + pointer = "›" + } + rendered[index-windowStart] = append([]string{pointer}, dataTableRowCells(options.Headers, rows[index])...) + } + headers := append([]string{""}, options.Headers...) + environment.Current().Output.Write(title + "\n" + theme.RenderTable(headers, rendered) + "\n") +} + +func renderSubmittedDataTableFrame(message string, headers []string, row DataTableRow) { + environment.Current().Output.Write(message + "\n" + stdstrings.Join(dataTableRowCells(headers, row), ", ") + "\n") +} + +func renderCancelledDataTableFrame(options DataTableOptions, rows []DataTableRow, selected int) { + rendered := make([][]string, len(rows)) + for index, row := range rows { + pointer := " " + if index == selected { + pointer = "›" + } + cells := dataTableRowCells(options.Headers, row) + for cellIndex, cell := range cells { + cells[cellIndex] = theme.Strikethrough(theme.Dim(cell)) + } + rendered[index] = append([]string{pointer}, cells...) + } + env := environment.Current() + env.Output.Write(options.Message + "\n") + env.Output.Write(theme.Dim("/ Search") + "\n") + env.Output.Write(theme.RenderTable(append([]string{""}, options.Headers...), rendered) + "\n") + env.ErrorOutput.Write(theme.Red(" ⚠ Cancelled.") + "\n") +} + +func visibleRows(options DataTableOptions, query string) []DataTableRow { + rows := make([]DataTableRow, 0, len(options.Rows)) + for _, row := range options.Rows { + if query == "" { + rows = append(rows, row) + continue + } + if options.Filter != nil { + if options.Filter(query, row) { + rows = append(rows, row) + } + continue + } + if stdstrings.Contains(stdstrings.ToLower(stdstrings.Join(dataTableRowCells(options.Headers, row), " ")), stdstrings.ToLower(query)) { + rows = append(rows, row) + } + } + return rows +} + +func deriveHeaders(rows []DataTableRow) []string { + if len(rows) == 0 { + return nil + } + headers := make([]string, 0, len(rows[0].Cells)) + for key := range rows[0].Cells { + if key != "value" { + headers = append(headers, key) + } + } + return headers +} + +func dataTableRowCells(headers []string, row DataTableRow) []string { + if len(headers) == 0 { + cells := make([]string, 0, len(row.Cells)) + for _, value := range row.Cells { + cells = append(cells, stringifyCell(value)) + } + return cells + } + cells := make([]string, len(headers)) + for index, header := range headers { + cells[index] = stringifyCell(row.Cells[header]) + } + return cells +} + +func dataTableRowValue(row DataTableRow, index int) any { + if row.Value != nil { + return row.Value + } + return index +} + +func defaultDataTableIndex(rows []DataTableRow, value any, hasDefault bool) int { + if !hasDefault { + return 0 + } + for index, row := range rows { + if fmt.Sprint(dataTableRowValue(row, index)) == fmt.Sprint(value) { + return index + } + } + return 0 +} + +func dataTableNavigation(pressed string) (int, bool) { + switch pressed { + case key.First(key.Down), key.First(key.DownArrow), key.First(key.CtrlN): + return 1, true + case key.First(key.Up), key.First(key.UpArrow), key.First(key.CtrlP): + return -1, true + default: + return 0, false + } +} + +func clampSelection(selected int, rows []DataTableRow) int { + if len(rows) == 0 { + return 0 + } + if selected < 0 { + return 0 + } + if selected >= len(rows) { + return len(rows) - 1 + } + return selected +} diff --git a/console/shell/internal/output/notes.go b/console/shell/internal/output/notes.go new file mode 100644 index 00000000..df75486e --- /dev/null +++ b/console/shell/internal/output/notes.go @@ -0,0 +1,96 @@ +package output + +import ( + stdstrings "strings" + + "github.com/oullin/tui/internal/environment" + "github.com/oullin/tui/internal/strings" + "github.com/oullin/tui/internal/theme" +) + +type NoteType string + +const ( + NoteAlert NoteType = "alert" + NoteError NoteType = "error" + NoteInfo NoteType = "info" + NoteIntro NoteType = "intro" + NoteOutro NoteType = "outro" + NoteWarning NoteType = "warning" +) + +func Note(message string, noteType NoteType) { + environment.Current().Output.Write(stdstrings.Join(renderNoteLines(message, noteType), "\n") + "\n") +} + +func Error(message string) { + Note(message, NoteError) +} + +func Warning(message string) { + Note(message, NoteWarning) +} + +func Alert(message string) { + Note(message, NoteAlert) +} + +func Info(message string) { + Note(message, NoteInfo) +} + +func Intro(message string) { + Note(message, NoteIntro) +} + +func Outro(message string) { + Note(message, NoteOutro) +} + +func renderNoteLines(message string, noteType NoteType) []string { + lines := stdstrings.Split(message, "\n") + if noteType == NoteIntro || noteType == NoteOutro { + lines = paddedIntroLines(lines) + } + + for index, line := range lines { + lines[index] = renderNoteLine(line, noteType) + } + + return lines +} + +func paddedIntroLines(lines []string) []string { + padded := make([]string, len(lines)) + longest := 0 + for index, line := range lines { + padded[index] = " " + line + " " + if width := strings.VisibleWidth(padded[index]); width > longest { + longest = width + } + } + for index, line := range padded { + padding := longest - strings.VisibleWidth(line) + if padding > 0 { + padded[index] = line + stdstrings.Repeat(" ", padding) + } + } + return padded +} + +func renderNoteLine(line string, noteType NoteType) string { + switch noteType { + case NoteIntro, NoteOutro: + return " " + theme.BackgroundCyan(theme.Black(line)) + case NoteWarning: + return theme.Yellow(" " + line) + case NoteError: + return theme.Red(" " + line) + case NoteAlert: + return " " + theme.BackgroundRed(theme.White(" "+line+" ")) + case NoteInfo: + return theme.Green(" " + line) + default: + return " " + line + } +} diff --git a/console/shell/internal/output/notify.go b/console/shell/internal/output/notify.go new file mode 100644 index 00000000..600c4819 --- /dev/null +++ b/console/shell/internal/output/notify.go @@ -0,0 +1,90 @@ +package output + +import "fmt" + +type NotificationCommand struct { + Bin string + Args []string +} + +type NotificationRuntime struct { + CommandExists func(string) bool + Execute func(NotificationCommand) bool +} + +func NotificationCommandFor(platform string, title string, body string, subtitle string, sound string, icon string) *NotificationCommand { + commands := NotificationCommands(platform, title, body, subtitle, sound, icon) + if len(commands) == 0 { + return nil + } + return &commands[0] +} + +func NotificationCommands(platform string, title string, body string, subtitle string, sound string, icon string) []NotificationCommand { + switch platform { + case "darwin": + script := fmt.Sprintf("display notification %q with title %q", body, title) + if subtitle != "" { + script += fmt.Sprintf(" subtitle %q", subtitle) + } + if sound != "" { + script += fmt.Sprintf(" sound name %q", sound) + } + return []NotificationCommand{{Bin: "osascript", Args: []string{"-e", script}}} + case "linux": + args := []string{} + if icon != "" { + args = append(args, "--icon", icon) + } + args = append(args, title) + if body != "" { + args = append(args, body) + } + message := title + if body != "" { + message += ": " + body + } + return []NotificationCommand{ + {Bin: "notify-send", Args: args}, + {Bin: "kdialog", Args: []string{"--passivepopup", message, "5", "--title", title}}, + } + default: + return nil + } +} + +func NotifyForPlatform(platform string, title string, body string, subtitle string, sound string, icon string, runtime NotificationRuntime) bool { + commands := NotificationCommands(platform, title, body, subtitle, sound, icon) + command := availableNotificationCommand(platform, commands, runtime.CommandExists) + if command != nil { + if runtime.Execute == nil { + return false + } + return runtime.Execute(*command) + } + + if body != "" { + Note(title+": "+body, NoteInfo) + } else { + Note(title, NoteInfo) + } + return false +} + +func availableNotificationCommand(platform string, commands []NotificationCommand, exists func(string) bool) *NotificationCommand { + if len(commands) == 0 { + return nil + } + if platform != "linux" { + return &commands[0] + } + if exists == nil { + return nil + } + for index, command := range commands { + if exists(command.Bin) { + return &commands[index] + } + } + return nil +} diff --git a/console/shell/internal/output/output_test.go b/console/shell/internal/output/output_test.go new file mode 100644 index 00000000..53f3aca4 --- /dev/null +++ b/console/shell/internal/output/output_test.go @@ -0,0 +1,178 @@ +package output + +import ( + "reflect" + "strings" + "testing" + + "github.com/oullin/tui/internal/environment" + "github.com/oullin/tui/internal/key" + textutil "github.com/oullin/tui/internal/strings" +) + +func withOutput(callback func(output *environment.MemoryOutput) error) error { + output := environment.NewMemoryOutput() + return environment.With(environment.Patch{Output: output, ErrorOutput: output}, func() error { + return callback(output) + }) +} + +func TestNoteHelpersRenderStyledLines(t *testing.T) { + err := withOutput(func(output *environment.MemoryOutput) error { + Note("Hello\nWorld", "") + Warning("Careful") + Error("Nope") + Alert("Heads up") + Info("Facts") + Intro("東京\nA") + Outro("Done") + + rendered := output.Text() + if !strings.Contains(rendered, "Hello") || !strings.Contains(rendered, "\x1b[33m Careful\x1b[39m") || !strings.Contains(rendered, "\x1b[31m Nope\x1b[39m") { + t.Fatalf("note output missing styled lines:\n%s", rendered) + } + if !strings.Contains(textutil.ParseAnsiText(rendered), " 東京 ") || !strings.Contains(textutil.ParseAnsiText(rendered), " A ") { + t.Fatalf("intro output missing visible-width padding:\n%s", rendered) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestTableGridAndTerminalHelpers(t *testing.T) { + err := withOutput(func(output *environment.MemoryOutput) error { + Table([]string{"Name", "Runtime"}, [][]any{{"Ollin", "OpenTUI"}}) + Grid([]any{"A", "B"}, 80) + Title("Prompt") + Clear() + + rendered := output.Text() + for _, want := range []string{"Name", "Runtime", "Ollin", "OpenTUI", "┌───┬───┐", "│ A │ B │", "\x1b]0;Prompt\x07", "\x1b[H\x1b[J"} { + if !strings.Contains(rendered, want) { + t.Fatalf("output missing %q:\n%s", want, rendered) + } + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestRenderGridBalancedRows(t *testing.T) { + got := RenderGrid([]any{"component-alpha", "api-client", "theme-box", "status-task", "prompt-input", "stream-log"}, 50) + want := strings.Join([]string{ + " ┌─────────────────┬─────────────┐", + " │ component-alpha │ api-client │", + " ├─────────────────┼─────────────┤", + " │ theme-box │ status-task │", + " ├─────────────────┼─────────────┤", + " │ prompt-input │ stream-log │", + " └─────────────────┴─────────────┘", + }, "\n") + if got != want { + t.Fatalf("RenderGrid() =\n%s\nwant\n%s", got, want) + } +} + +func TestNotificationCommandsAndFallback(t *testing.T) { + command := NotificationCommandFor("darwin", "Deploy", "Done", "Preview", "Glass", "") + if command == nil || command.Bin != "osascript" || !strings.Contains(command.Args[1], `display notification "Done"`) || !strings.Contains(command.Args[1], `sound name "Glass"`) { + t.Fatalf("darwin command = %#v", command) + } + + commands := NotificationCommands("linux", "Deploy", "Done", "", "", "/tmp/icon.png") + wantFirst := NotificationCommand{Bin: "notify-send", Args: []string{"--icon", "/tmp/icon.png", "Deploy", "Done"}} + if !reflect.DeepEqual(commands[0], wantFirst) { + t.Fatalf("linux command = %#v, want %#v", commands[0], wantFirst) + } + + executed := []NotificationCommand{} + ok := NotifyForPlatform("linux", "Deploy", "Done", "", "", "", NotificationRuntime{ + CommandExists: func(bin string) bool { return bin == "kdialog" }, + Execute: func(command NotificationCommand) bool { + executed = append(executed, command) + return true + }, + }) + if !ok || len(executed) != 1 || executed[0].Bin != "kdialog" { + t.Fatalf("NotifyForPlatform linux ok=%v executed=%#v", ok, executed) + } + + err := withOutput(func(output *environment.MemoryOutput) error { + if NotifyForPlatform("freebsd", "Deploy", "Done", "", "", "", NotificationRuntime{}) { + t.Fatalf("unsupported notification returned true") + } + if !strings.Contains(output.Text(), "Deploy: Done") { + t.Fatalf("fallback note missing:\n%s", output.Text()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestDataTableSelectsFiltersAndCancels(t *testing.T) { + rows := []DataTableRow{ + {Cells: map[string]any{"Name": "Alpha", "Runtime": "Node"}, Value: "alpha"}, + {Cells: map[string]any{"Name": "Beta", "Runtime": "OpenTUI"}, Value: "beta"}, + } + err := withDataTableEnvironment(environment.NewScriptedInput([]string{key.First(key.Down), key.First(key.Enter)}), func(output *environment.MemoryOutput) error { + result, err := DataTable(DataTableOptions{Message: "Pick project", Headers: []string{"Name", "Runtime"}, Rows: rows}) + if err != nil { + return err + } + if result != "beta" { + t.Fatalf("DataTable() = %#v", result) + } + if !strings.Contains(output.Text(), "Pick project") || !strings.Contains(output.Text(), "›") || !strings.Contains(output.Text(), "Bob, Developer") && !strings.Contains(output.Text(), "Beta, OpenTUI") { + t.Fatalf("datatable output missing expected frame/summary:\n%s", output.Text()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } + + err = withDataTableEnvironment(environment.NewScriptedInput([]string{"/", "B", key.First(key.Enter)}), func(output *environment.MemoryOutput) error { + result, err := DataTable(DataTableOptions{Message: "Pick project", Headers: []string{"Name", "Runtime"}, Rows: rows}) + if err != nil { + return err + } + if result != "beta" { + t.Fatalf("filtered DataTable() = %#v", result) + } + if !strings.Contains(output.Text(), "Pick project B") { + t.Fatalf("datatable output missing search query:\n%s", output.Text()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } + + err = withDataTableEnvironment(environment.NewScriptedInput([]string{key.First(key.Down), key.First(key.CtrlC)}), func(output *environment.MemoryOutput) error { + result, err := DataTable(DataTableOptions{Message: "Pick project", Headers: []string{"Name", "Runtime"}, Rows: rows}) + if err != nil { + return err + } + if result != "beta" || !strings.Contains(output.Text(), "Cancelled.") || !strings.Contains(output.Text(), "/ Search") { + t.Fatalf("cancelled DataTable() = %#v output:\n%s", result, output.Text()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func withDataTableEnvironment(input environment.Input, callback func(output *environment.MemoryOutput) error) error { + output := environment.NewMemoryOutput() + interactive := true + return environment.With(environment.Patch{Input: input, Output: output, ErrorOutput: output, Interactive: &interactive}, func() error { + return callback(output) + }) +} diff --git a/console/shell/internal/output/table.go b/console/shell/internal/output/table.go new file mode 100644 index 00000000..2089ccf3 --- /dev/null +++ b/console/shell/internal/output/table.go @@ -0,0 +1,152 @@ +package output + +import ( + "fmt" + "math" + stdstrings "strings" + + "github.com/oullin/tui/internal/environment" + text "github.com/oullin/tui/internal/strings" + "github.com/oullin/tui/internal/theme" +) + +type TableRow map[string]any + +func Table(headers []string, rows [][]any) { + renderedRows := make([][]string, len(rows)) + for rowIndex, row := range rows { + renderedRows[rowIndex] = make([]string, len(row)) + for cellIndex, cell := range row { + renderedRows[rowIndex][cellIndex] = stringifyCell(cell) + } + } + + environment.Current().Output.Write(theme.RenderTable(headers, renderedRows) + "\n") +} + +func TableObjects(headers []string, rows []TableRow) { + if len(headers) == 0 && len(rows) > 0 { + for key := range rows[0] { + headers = append(headers, key) + } + } + + renderedRows := make([][]any, len(rows)) + for rowIndex, row := range rows { + renderedRows[rowIndex] = make([]any, len(headers)) + for headerIndex, header := range headers { + renderedRows[rowIndex][headerIndex] = row[header] + } + } + Table(headers, renderedRows) +} + +func Grid(items []any, maxWidth int) { + rendered := RenderGrid(items, maxWidth) + if rendered == "" { + return + } + + environment.Current().Output.Write(rendered + "\n") +} + +func RenderGrid(items []any, maxWidth int) string { + if len(items) == 0 { + return "" + } + + values := make([]string, len(items)) + for index, item := range items { + values[index] = fmt.Sprint(item) + } + + width := maxWidth + if width <= 0 { + width = 1 + } + availableWidth := width - 2 + cellWidth := 0 + for _, value := range values { + if valueWidth := text.VisibleWidth(value) + 4; valueWidth > cellWidth { + cellWidth = valueWidth + } + } + maxColumns := int(math.Max(1, math.Floor(float64((availableWidth-1)/(cellWidth+1))))) + columnCount := max(1, balancedColumnCount(len(values), maxColumns)) + rows := chunkItems(values, columnCount) + widths := make([]int, columnCount) + for column := range columnCount { + for _, row := range rows { + if width := text.VisibleWidth(row[column]); width > widths[column] { + widths[column] = width + } + } + } + + renderedRows := []string{horizontal("┌", "┬", "┐", widths)} + for index, row := range rows { + if index > 0 { + renderedRows = append(renderedRows, horizontal("├", "┼", "┤", widths)) + } + cells := make([]string, len(widths)) + for column, width := range widths { + cells[column] = padVisible(row[column], width) + } + renderedRows = append(renderedRows, "│ "+stdstrings.Join(cells, " │ ")+" │") + } + renderedRows = append(renderedRows, horizontal("└", "┴", "┘", widths)) + + for index, line := range renderedRows { + renderedRows[index] = " " + line + } + + return stdstrings.Join(renderedRows, "\n") +} + +func stringifyCell(value any) string { + if value == nil { + return "" + } + return fmt.Sprint(value) +} + +func padVisible(value string, width int) string { + padding := width - text.VisibleWidth(value) + if padding < 0 { + padding = 0 + } + return value + stdstrings.Repeat(" ", padding) +} + +func balancedColumnCount(itemCount int, maxColumns int) int { + if itemCount <= maxColumns { + return itemCount + } + for columns := maxColumns; columns >= 1; columns-- { + remainder := itemCount % columns + if remainder == 0 || remainder >= int(math.Ceil(float64(columns)/2)) { + return columns + } + } + return maxColumns +} + +func chunkItems(items []string, columnCount int) [][]string { + rows := make([][]string, 0) + for index := 0; index < len(items); index += columnCount { + row := append([]string{}, items[index:min(index+columnCount, len(items))]...) + for len(row) < columnCount { + row = append(row, "") + } + rows = append(rows, row) + } + return rows +} + +func horizontal(left string, middle string, right string, widths []int) string { + parts := make([]string, len(widths)) + for index, width := range widths { + parts[index] = stdstrings.Repeat("─", width+2) + } + return left + stdstrings.Join(parts, middle) + right +} diff --git a/console/shell/internal/output/terminal.go b/console/shell/internal/output/terminal.go new file mode 100644 index 00000000..ad42c6bd --- /dev/null +++ b/console/shell/internal/output/terminal.go @@ -0,0 +1,14 @@ +package output + +import ( + "github.com/oullin/tui/internal/environment" + "github.com/oullin/tui/internal/terminal" +) + +func Clear() { + environment.Current().Output.Write(terminal.ClearSequence()) +} + +func Title(value string) { + environment.Current().Output.Write(terminal.TitleSequence(value)) +} From 62af37babe7fa8ca104115bcdb864ce9d39ea8c7 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Sat, 13 Jun 2026 17:14:21 +0800 Subject: [PATCH 278/675] feat(shell): port status helpers --- console/shell/internal/status/frame.go | 21 ++ console/shell/internal/status/progress.go | 154 ++++++++++++++ console/shell/internal/status/spinner.go | 52 +++++ console/shell/internal/status/status_test.go | 194 ++++++++++++++++++ console/shell/internal/status/stream.go | 133 ++++++++++++ console/shell/internal/status/task.go | 205 +++++++++++++++++++ 6 files changed, 759 insertions(+) create mode 100644 console/shell/internal/status/frame.go create mode 100644 console/shell/internal/status/progress.go create mode 100644 console/shell/internal/status/spinner.go create mode 100644 console/shell/internal/status/status_test.go create mode 100644 console/shell/internal/status/stream.go create mode 100644 console/shell/internal/status/task.go diff --git a/console/shell/internal/status/frame.go b/console/shell/internal/status/frame.go new file mode 100644 index 00000000..91c96708 --- /dev/null +++ b/console/shell/internal/status/frame.go @@ -0,0 +1,21 @@ +package status + +import ( + stdstrings "strings" + + "github.com/oullin/tui/internal/environment" + "github.com/oullin/tui/internal/terminal" +) + +func RenderedFrameLineCount(frame string) int { + trimmed := stdstrings.TrimSuffix(frame, "\n") + if trimmed == "" { + return 0 + } + return len(stdstrings.Split(trimmed, "\n")) +} + +func EraseRenderedFrame(frame string) { + count := RenderedFrameLineCount(frame) + environment.Current().Output.Write(terminal.ErasePreviousLinesSequence(count)) +} diff --git a/console/shell/internal/status/progress.go b/console/shell/internal/status/progress.go new file mode 100644 index 00000000..8f1a6991 --- /dev/null +++ b/console/shell/internal/status/progress.go @@ -0,0 +1,154 @@ +package status + +import ( + "fmt" + "math" + + "github.com/oullin/tui/internal/environment" + "github.com/oullin/tui/internal/terminal" + "github.com/oullin/tui/internal/theme" +) + +type Progress struct { + current int + label string + hint string + renderedFrame string + cursorHidden bool + Total int +} + +func NewProgress(total int, label string, hint string) *Progress { + if total < 1 { + total = 1 + } + return &Progress{Total: total, label: label, hint: hint} +} + +func (progress *Progress) Start() { + progress.render("active") +} + +func (progress *Progress) Advance(step int) { + progress.current += step + if progress.current < 0 { + progress.current = 0 + } + if progress.current > progress.Total { + progress.current = progress.Total + } + progress.render("active") +} + +func (progress *Progress) Finish() { + progress.render("submit") + progress.restore() +} + +func (progress *Progress) Fail() { + progress.render("error") + progress.restore() +} + +func (progress *Progress) Label(value string) *Progress { + progress.label = value + return progress +} + +func (progress *Progress) Hint(value string) *Progress { + progress.hint = value + return progress +} + +func (progress *Progress) Current() int { + return progress.current +} + +func (progress *Progress) Percentage() float64 { + return float64(progress.current) / float64(progress.Total) +} + +func (progress *Progress) Value() bool { + return true +} + +func (progress *Progress) render(state string) { + output := environment.Current().Output + if !progress.cursorHidden { + output.Write(terminal.HideCursorSequence()) + progress.cursorHidden = true + } + if progress.renderedFrame != "" { + EraseRenderedFrame(progress.renderedFrame) + } + frame := RenderProgressFrame(progress.current, progress.Total, progress.label, progress.hint, state, 60) + output.Write(frame) + progress.renderedFrame = frame +} + +func (progress *Progress) restore() { + if progress.renderedFrame != "" { + EraseRenderedFrame(progress.renderedFrame) + progress.renderedFrame = "" + } + if progress.cursorHidden { + environment.Current().Output.Write(terminal.ShowCursorSequence()) + progress.cursorHidden = false + } +} + +func RunProgress[T any, R any](label string, steps []T, callback func(T, *Progress) (R, error)) ([]R, error) { + bar := NewProgress(len(steps), label, "") + results := make([]R, 0, len(steps)) + bar.Start() + for _, step := range steps { + result, err := callback(step, bar) + if err != nil { + bar.Fail() + return results, err + } + results = append(results, result) + bar.Advance(1) + } + bar.Finish() + return results, nil +} + +func RenderProgressFrame(current int, total int, label string, hint string, state string, width int) string { + if total < 1 { + total = 1 + } + filled := int(math.Ceil(float64(width) * float64(current) / float64(total))) + body := "" + for range filled { + body += "█" + } + lines := []string{theme.RenderBox(theme.BoxOptions{Body: body, Info: FormatProgressFraction(current, total), Title: label, Width: width})} + if state == "active" { + if hint != "" { + lines = append(lines, " "+hint) + } else { + lines = append(lines, "") + } + } + return joinLines(lines) + "\n" +} + +func FormatProgressFraction(current int, total int) string { + return fmt.Sprintf("%s / %s", formatProgressNumber(current), formatProgressNumber(total)) +} + +func formatProgressNumber(value int) string { + text := fmt.Sprintf("%d", value) + if len(text) <= 3 { + return text + } + out := "" + for index, char := range text { + if index > 0 && (len(text)-index)%3 == 0 { + out += "," + } + out += string(char) + } + return out +} diff --git a/console/shell/internal/status/spinner.go b/console/shell/internal/status/spinner.go new file mode 100644 index 00000000..b59de372 --- /dev/null +++ b/console/shell/internal/status/spinner.go @@ -0,0 +1,52 @@ +package status + +import ( + "github.com/oullin/tui/internal/environment" + "github.com/oullin/tui/internal/terminal" +) + +var SpinnerFrames = []string{"⠂", "⠒", "⠐", "⠰", "⠠", "⠤", "⠄", "⠆"} + +const StaticSpinnerFrame = "⠶" + +func SpinnerFrame(count int) string { + index := count % len(SpinnerFrames) + if index < 0 { + index += len(SpinnerFrames) + } + return SpinnerFrames[index] +} + +func RenderSpinnerFrame(message string, count ...int) string { + frame := StaticSpinnerFrame + if len(count) > 0 { + frame = SpinnerFrame(count[0]) + } + return " " + frame + " " + message + "\n" +} + +func Spin[T any](message string, callback func() (T, error)) (T, error) { + var zero T + if callback == nil { + return zero, errMissingCallback("spinner") + } + + output := environment.Current().Output + frame := RenderSpinnerFrame(message) + output.Write(terminal.HideCursorSequence()) + output.Write(frame) + defer output.Write(terminal.ShowCursorSequence()) + defer EraseRenderedFrame(frame) + + return callback() +} + +type missingCallbackError string + +func (err missingCallbackError) Error() string { + return string(err) +} + +func errMissingCallback(kind string) error { + return missingCallbackError("a " + kind + " callback is required") +} diff --git a/console/shell/internal/status/status_test.go b/console/shell/internal/status/status_test.go new file mode 100644 index 00000000..40fc718b --- /dev/null +++ b/console/shell/internal/status/status_test.go @@ -0,0 +1,194 @@ +package status + +import ( + "errors" + "strings" + "testing" + + "github.com/oullin/tui/internal/environment" + textutil "github.com/oullin/tui/internal/strings" +) + +func withStatusOutput(callback func(output *environment.MemoryOutput) error) error { + output := environment.NewMemoryOutput() + return environment.With(environment.Patch{Output: output, ErrorOutput: output}, func() error { + return callback(output) + }) +} + +func TestSpinnerFramesAndCleanup(t *testing.T) { + if got := SpinnerFrame(3); got != "⠰" { + t.Fatalf("SpinnerFrame() = %q", got) + } + if got := RenderSpinnerFrame("Working", 3); got != " ⠰ Working\n" { + t.Fatalf("RenderSpinnerFrame() = %q", got) + } + + err := withStatusOutput(func(output *environment.MemoryOutput) error { + result, err := Spin("Working", func() (string, error) { return "done", nil }) + if err != nil { + return err + } + if result != "done" { + t.Fatalf("Spin() = %q", result) + } + rendered := output.Text() + for _, want := range []string{"\x1b[?25l", " ⠶ Working", "\x1b[1A\x1b[2K", "\x1b[?25h"} { + if !strings.Contains(rendered, want) { + t.Fatalf("spinner output missing %q:\n%s", want, rendered) + } + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestProgressManualAndRunner(t *testing.T) { + err := withStatusOutput(func(output *environment.MemoryOutput) error { + bar := NewProgress(2, "Adding States", "") + bar.Start() + bar.Label("ALABAMA").Hint("alabama").Advance(1) + if bar.Current() != 1 || !bar.Value() { + t.Fatalf("progress current/value = %d/%v", bar.Current(), bar.Value()) + } + bar.Finish() + if !strings.Contains(output.Text(), "ALABAMA") || !strings.Contains(output.Text(), "1 / 2") || !strings.Contains(output.Text(), "\x1b[?25h") { + t.Fatalf("progress output missing expected frame:\n%s", output.Text()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } + + err = withStatusOutput(func(output *environment.MemoryOutput) error { + result, err := RunProgress("Uppercasing States", []string{"Alabama", "Alaska"}, func(step string, bar *Progress) (string, error) { + return strings.ToUpper(step), nil + }) + if err != nil { + return err + } + if strings.Join(result, ",") != "ALABAMA,ALASKA" { + t.Fatalf("RunProgress() = %#v", result) + } + if !strings.Contains(output.Text(), "2 / 2") { + t.Fatalf("progress runner output missing completion:\n%s", output.Text()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestStreamAppendsClosesAndRenders(t *testing.T) { + err := withStatusOutput(func(output *environment.MemoryOutput) error { + stream := NewStream() + if err := stream.Append("hello"); err != nil { + return err + } + if err := stream.Write(" world"); err != nil { + return err + } + stream.Close() + if stream.Value() != "hello world" || !stream.Closed() { + t.Fatalf("stream value/closed = %q/%v", stream.Value(), stream.Closed()) + } + if err := stream.Append(" later"); err == nil { + t.Fatalf("expected append after close to fail") + } + if !strings.Contains(output.Text(), "\x1b[?25l") || !strings.Contains(output.Text(), "\x1b[?25h") || !strings.Contains(textutil.ParseAnsiText(output.Text()), " hello world") { + t.Fatalf("stream output missing expected frame:\n%s", output.Text()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestTaskLoggerAndFrames(t *testing.T) { + err := withStatusOutput(func(output *environment.MemoryOutput) error { + result, err := Task("Running...", func(logger *Logger) (string, error) { + logger.Log("line one") + logger.Log("line two") + logger.Log("line three") + return "done", nil + }, 2, false, "") + if err != nil { + return err + } + if result != "done" { + t.Fatalf("Task() = %q", result) + } + rendered := output.Text() + if strings.Contains(rendered, "line one") || !strings.Contains(rendered, "line two") || !strings.Contains(rendered, "line three") || !strings.Contains(rendered, "\x1b[?25h") { + t.Fatalf("task output missing bounded logs:\n%s", rendered) + } + return nil + }) + if err != nil { + t.Fatal(err) + } + + err = withStatusOutput(func(output *environment.MemoryOutput) error { + _, err := Task("Running...", func(logger *Logger) (string, error) { + logger.Success("created") + logger.Warning("check this") + logger.Error("failed optional step") + return "done", nil + }, 10, true, "") + if err != nil { + return err + } + rendered := output.Text() + for _, want := range []string{" • Running...", "✔ created", "⚠ check this", "✘ failed optional step"} { + if !strings.Contains(rendered, want) { + t.Fatalf("task summary missing %q:\n%s", want, rendered) + } + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestTaskFailureRestoresCursor(t *testing.T) { + failure := errors.New("failed") + err := withStatusOutput(func(output *environment.MemoryOutput) error { + _, err := Task("Running...", func(logger *Logger) (string, error) { + logger.Line("before\x1b[1G\x1b[2Kafter") + return "", failure + }, 10, false, "") + if !errors.Is(err, failure) { + t.Fatalf("Task() error = %v", err) + } + if !strings.Contains(output.Text(), "beforeafter") || !strings.Contains(output.Text(), "\x1b[?25h") { + t.Fatalf("failed task output missing cleanup/sanitized log:\n%s", output.Text()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestFrameLineCountingAndErasing(t *testing.T) { + frame := RenderTaskFrame(TaskFrameOptions{Label: "Running...", Limit: 2, Lines: []string{"line one"}}) + if got := RenderedFrameLineCount(frame); got != 4 { + t.Fatalf("RenderedFrameLineCount() = %d", got) + } + err := withStatusOutput(func(output *environment.MemoryOutput) error { + EraseRenderedFrame(frame) + if output.Text() != strings.Repeat("\x1b[1A\x1b[2K", 4) { + t.Fatalf("EraseRenderedFrame() output = %q", output.Text()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} diff --git a/console/shell/internal/status/stream.go b/console/shell/internal/status/stream.go new file mode 100644 index 00000000..90960933 --- /dev/null +++ b/console/shell/internal/status/stream.go @@ -0,0 +1,133 @@ +package status + +import ( + "errors" + stdstrings "strings" + + "github.com/oullin/tui/internal/environment" + text "github.com/oullin/tui/internal/strings" + "github.com/oullin/tui/internal/terminal" + "github.com/oullin/tui/internal/theme" +) + +type Stream struct { + closed bool + buffer *StreamBuffer +} + +func NewStream() *Stream { + environment.Current().Output.Write(terminal.HideCursorSequence()) + return &Stream{buffer: NewStreamBuffer(10)} +} + +func (stream *Stream) Write(content string) error { + return stream.Append(content) +} + +func (stream *Stream) Append(content string) error { + if stream.closed { + return errors.New("stream is closed") + } + stream.buffer.Append(content) + stream.render() + return nil +} + +func (stream *Stream) Close() { + if stream.closed { + return + } + for stream.buffer.FlushNext() { + stream.render() + } + stream.closed = true + environment.Current().Output.Write(terminal.ShowCursorSequence()) +} + +func (stream *Stream) Closed() bool { + return stream.closed +} + +func (stream *Stream) Value() string { + return stream.buffer.Value() +} + +func (stream *Stream) Lines() []string { + return StreamLines(stream.Value(), 60) +} + +func (stream *Stream) render() { + environment.Current().Output.Write(RenderStreamFrame(stream.buffer.StableValue(), stream.buffer.Fading, StreamFadeStyles(false), 60)) +} + +type StreamBuffer struct { + Fading []string + message string + fadeLimit int +} + +func NewStreamBuffer(fadeLimit int) *StreamBuffer { + return &StreamBuffer{fadeLimit: fadeLimit} +} + +func (buffer *StreamBuffer) Append(message string) { + buffer.Fading = append(buffer.Fading, message) + for len(buffer.Fading) > buffer.fadeLimit { + buffer.message += buffer.Fading[0] + buffer.Fading = buffer.Fading[1:] + } +} + +func (buffer *StreamBuffer) FlushNext() bool { + if len(buffer.Fading) == 0 { + return false + } + buffer.message += buffer.Fading[0] + buffer.Fading = buffer.Fading[1:] + return true +} + +func (buffer *StreamBuffer) Value() string { + return buffer.message + stdstrings.Join(buffer.Fading, "") +} + +func (buffer *StreamBuffer) StableValue() string { + return buffer.message +} + +type FadeStyle func(string) string + +func StreamFadeStyles(trueColor bool) []FadeStyle { + if trueColor { + return []FadeStyle{ + func(value string) string { return theme.ForegroundRGB(value, 100, 50, 0) }, + func(value string) string { return theme.ForegroundRGB(value, 50, 25, 0) }, + } + } + return []FadeStyle{func(value string) string { return value }, theme.Dim} +} + +func RenderStreamFrame(value string, fading []string, styles []FadeStyle, width int) string { + rendered := value + for index, chunk := range fading { + style := styles[len(styles)-1] + if index < len(styles) { + style = styles[index] + } + rendered += style(chunk) + } + lines := StreamLines(rendered, width) + for index, line := range lines { + lines[index] = " " + line + } + return stdstrings.Join(lines, "\n") + "\n" +} + +func StreamLines(value string, width int) []string { + parts := stdstrings.Split(value, "\n") + lines := make([]string, 0) + for _, part := range parts { + lines = append(lines, text.Wrap(part, width)...) + } + return lines +} diff --git a/console/shell/internal/status/task.go b/console/shell/internal/status/task.go new file mode 100644 index 00000000..0bd753db --- /dev/null +++ b/console/shell/internal/status/task.go @@ -0,0 +1,205 @@ +package status + +import ( + "regexp" + stdstrings "strings" + + "github.com/oullin/tui/internal/environment" + "github.com/oullin/tui/internal/terminal" +) + +type StableMessage struct { + Type string + Message string +} + +type Logger struct { + LabelValue string + SubLabelValue string + Lines []string + Stable []StableMessage + limit int + stableLimit int + partialBuffer string + partialStart int + hasPartial bool +} + +func NewLogger(limit int, label string, subLabel string) *Logger { + if limit < 0 { + limit = 0 + } + if limit == 0 { + limit = 10 + } + return &Logger{LabelValue: label, SubLabelValue: subLabel, limit: limit, stableLimit: limit} +} + +func (logger *Logger) Limit() int { + return logger.limit +} + +func (logger *Logger) Line(message string) { + logger.writeLines(stdstrings.TrimRight(message, " \t\r\n")) +} + +func (logger *Logger) Log(message string) { + logger.Line(message) +} + +func (logger *Logger) Label(message string) { + logger.LabelValue = message +} + +func (logger *Logger) SubLabel(message string) { + logger.SubLabelValue = message +} + +func (logger *Logger) Partial(chunk string) { + logger.partialBuffer += chunk + if !logger.hasPartial { + logger.partialStart = len(logger.Lines) + logger.hasPartial = true + } + logger.Lines = logger.Lines[:logger.partialStart] + logger.writeLines(logger.partialBuffer) + if logger.partialStart > len(logger.Lines) { + logger.partialStart = len(logger.Lines) + } +} + +func (logger *Logger) CommitPartial() { + logger.partialBuffer = "" + logger.partialStart = 0 + logger.hasPartial = false +} + +func (logger *Logger) Info(message string) { + logger.Line(message) +} + +func (logger *Logger) Success(message string) { + logger.stable("success", message) +} + +func (logger *Logger) Warning(message string) { + logger.stable("warning", message) +} + +func (logger *Logger) Error(message string) { + logger.stable("error", message) +} + +func (logger *Logger) stable(kind string, message string) { + logger.Stable = append(logger.Stable, StableMessage{Type: kind, Message: message}) + logger.Lines = nil + logger.partialBuffer = "" + logger.hasPartial = false + for len(logger.Stable) > logger.stableLimit { + logger.Stable = logger.Stable[1:] + } +} + +func (logger *Logger) writeLines(message string) { + for _, line := range stdstrings.Split(message, "\n") { + if line != "" { + logger.Lines = append(logger.Lines, sanitizeTaskLine(line)) + } + } + for len(logger.Lines) > logger.limit { + logger.Lines = logger.Lines[1:] + } +} + +func Task[T any](label string, callback func(*Logger) (T, error), limit int, keepSummary bool, subLabel string) (T, error) { + var zero T + if callback == nil { + return zero, errMissingCallback("task") + } + logger := NewLogger(limit, label, subLabel) + output := environment.Current().Output + output.Write(terminal.HideCursorSequence()) + defer output.Write(terminal.ShowCursorSequence()) + + frame := RenderTaskFrame(TaskFrameOptions{Label: logger.LabelValue, SubLabel: logger.SubLabelValue, Limit: logger.Limit(), Lines: logger.Lines, StableMessages: logger.Stable}) + output.Write(frame) + + result, err := callback(logger) + EraseRenderedFrame(frame) + if err != nil { + output.Write(RenderTaskFrame(TaskFrameOptions{Label: logger.LabelValue, SubLabel: logger.SubLabelValue, Limit: logger.Limit(), Lines: logger.Lines, StableMessages: logger.Stable})) + return zero, err + } + output.Write(RenderTaskFrame(TaskFrameOptions{Finished: true, KeepSummary: keepSummary, Label: logger.LabelValue, SubLabel: logger.SubLabelValue, Limit: logger.Limit(), Lines: logger.Lines, StableMessages: logger.Stable})) + return result, nil +} + +type TaskFrameOptions struct { + FrameCount int + Finished bool + KeepSummary bool + Label string + Limit int + Lines []string + StableMessages []StableMessage + SubLabel string +} + +func RenderTaskFrame(options TaskFrameOptions) string { + if options.Finished && options.KeepSummary && len(options.StableMessages) > 0 { + lines := []string{" • " + options.Label} + for _, message := range options.StableMessages { + lines = append(lines, renderStableMessage(message)) + } + lines = append(lines, "") + return joinLines(lines) + "\n" + } + + output := []string{stdstrings.TrimRight(RenderSpinnerFrame(options.Label, options.FrameCount), "\n")} + if options.FrameCount == 0 { + output[0] = stdstrings.TrimRight(RenderSpinnerFrame(options.Label), "\n") + } + if options.SubLabel != "" { + output = append(output, " "+options.SubLabel) + } + for _, message := range options.StableMessages { + output = append(output, renderStableMessage(message)) + } + if len(options.StableMessages) > 0 || len(options.Lines) > 0 { + output = append(output, " "+stdstrings.Repeat("─", 60)) + } else { + output = append(output, "") + } + visible := options.Lines + if len(visible) > options.Limit { + visible = visible[len(visible)-options.Limit:] + } + for _, line := range visible { + output = append(output, " "+line) + } + for remaining := options.Limit - len(visible); remaining > 0; remaining-- { + output = append(output, "") + } + return joinLines(output) + "\n" +} + +func renderStableMessage(message StableMessage) string { + symbol := "⚠" + if message.Type == "success" { + symbol = "✔" + } + if message.Type == "error" { + symbol = "✘" + } + return " " + symbol + " " + message.Message +} + +var taskControlPattern = regexp.MustCompile(`\x1b\[1G|\x1b\[2K|\r`) + +func sanitizeTaskLine(line string) string { + return taskControlPattern.ReplaceAllString(line, "") +} + +func joinLines(lines []string) string { + return stdstrings.Join(lines, "\n") +} From 53af67d16dd76163201e533f8457878a05683857 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Sat, 13 Jun 2026 17:17:50 +0800 Subject: [PATCH 279/675] feat(shell): port form builder --- console/shell/internal/form/builder.go | 148 ++++++++++++++++ console/shell/internal/form/form_test.go | 214 +++++++++++++++++++++++ console/shell/internal/form/output.go | 87 +++++++++ console/shell/internal/form/previous.go | 55 ++++++ console/shell/internal/form/prompts.go | 111 ++++++++++++ console/shell/internal/form/responses.go | 70 ++++++++ console/shell/internal/form/revert.go | 58 ++++++ console/shell/internal/form/status.go | 30 ++++ 8 files changed, 773 insertions(+) create mode 100644 console/shell/internal/form/builder.go create mode 100644 console/shell/internal/form/form_test.go create mode 100644 console/shell/internal/form/output.go create mode 100644 console/shell/internal/form/previous.go create mode 100644 console/shell/internal/form/prompts.go create mode 100644 console/shell/internal/form/responses.go create mode 100644 console/shell/internal/form/revert.go create mode 100644 console/shell/internal/form/status.go diff --git a/console/shell/internal/form/builder.go b/console/shell/internal/form/builder.go new file mode 100644 index 00000000..a2e58169 --- /dev/null +++ b/console/shell/internal/form/builder.go @@ -0,0 +1,148 @@ +package form + +import "errors" + +type StepFunc func(responses *Responses, previous any, name string) (any, error) +type ConditionFunc func(responses *Responses) (bool, error) + +type Step struct { + Condition ConditionFunc + IgnoreWhenReverting bool + Name string + Run StepFunc +} + +type Builder struct { + responses *Responses + steps []Step +} + +func New() *Builder { + return &Builder{responses: NewResponses()} +} + +func (builder *Builder) Add(run StepFunc, name ...string) *Builder { + return builder.addStep(trueCondition, run, firstName(name), false) +} + +func (builder *Builder) AddSideEffect(run func() error, name ...string) *Builder { + return builder.addStep(trueCondition, SideEffect(run), firstName(name), true) +} + +func (builder *Builder) AddIf(condition ConditionFunc, run StepFunc, name ...string) *Builder { + return builder.addStep(condition, run, firstName(name), false) +} + +func (builder *Builder) AddSideEffectIf(condition ConditionFunc, run func() error, name ...string) *Builder { + return builder.addStep(condition, SideEffect(run), firstName(name), true) +} + +func (builder *Builder) Submit() (*Responses, error) { + index := 0 + wasReverted := false + + for index < len(builder.steps) { + step := builder.steps[index] + + if wasReverted && index > 0 { + ignore, err := shouldIgnoreWhenReverting(step, builder.responses) + if err != nil { + return nil, err + } + if ignore { + index-- + continue + } + } + + wasReverted = false + shouldRun, err := shouldRun(step, builder.responses) + if err != nil { + return nil, err + } + if !shouldRun { + builder.responses.set(index, step.Name, nil) + index++ + continue + } + + previous := builder.responses.previous(index, step.Name) + value, err := runWithRevert(index > 0, func() (any, error) { + return step.Run(builder.responses, previous, step.Name) + }) + if err != nil { + if !errors.Is(err, ErrReverted) { + return nil, err + } + wasReverted = true + } else { + builder.responses.set(index, step.Name, value) + } + + if wasReverted { + index-- + } else { + index++ + } + } + + return builder.responses, nil +} + +func (builder *Builder) addStep(condition ConditionFunc, run StepFunc, name string, ignoreWhenReverting bool) *Builder { + if condition == nil { + condition = trueCondition + } + builder.steps = append(builder.steps, Step{ + Condition: condition, + IgnoreWhenReverting: ignoreWhenReverting, + Name: name, + Run: run, + }) + return builder +} + +func SideEffect(callback func() error) StepFunc { + return func(*Responses, any, string) (any, error) { + if callback == nil { + return nil, nil + } + + return nil, callback() + } +} + +func Bool(condition bool) ConditionFunc { + return func(*Responses) (bool, error) { + return condition, nil + } +} + +func trueCondition(*Responses) (bool, error) { + return true, nil +} + +func shouldRun(step Step, responses *Responses) (bool, error) { + if step.Condition == nil { + return true, nil + } + + return step.Condition(responses) +} + +func shouldIgnoreWhenReverting(step Step, responses *Responses) (bool, error) { + run, err := shouldRun(step, responses) + if err != nil || !run { + return true, err + } + + return step.IgnoreWhenReverting, nil +} + +func firstName(values []string) string { + if len(values) == 0 { + return "" + } + + return values[0] +} diff --git a/console/shell/internal/form/form_test.go b/console/shell/internal/form/form_test.go new file mode 100644 index 00000000..2fa96e86 --- /dev/null +++ b/console/shell/internal/form/form_test.go @@ -0,0 +1,214 @@ +package form + +import ( + "reflect" + "strings" + "testing" + + "github.com/oullin/tui/internal/environment" + "github.com/oullin/tui/internal/key" + "github.com/oullin/tui/internal/output" + "github.com/oullin/tui/internal/prompts" +) + +func withFormEnvironment(keys []string, callback func(*environment.MemoryOutput) error) error { + memoryOutput := environment.NewMemoryOutput() + interactive := true + return environment.With( + environment.Patch{ + Input: environment.NewScriptedInput(keys), + Output: memoryOutput, + ErrorOutput: memoryOutput, + Interactive: &interactive, + }, + func() error { + return callback(memoryOutput) + }, + ) +} + +func TestBuilderRunsChainedStepsAndReturnsPositionalResponses(t *testing.T) { + err := withFormEnvironment([]string{"A", "d", "a", key.First(key.Enter), key.First(key.Enter), key.First(key.Enter)}, func(*environment.MemoryOutput) error { + responses, err := New(). + Text(prompts.TextOptions{Message: "Name"}). + Select(prompts.ChoiceOptions{Message: "Runtime", Choices: prompts.NewStringChoices("TS", "JS")}). + Confirm(prompts.ConfirmOptions{Message: "Active"}). + Submit() + if err != nil { + return err + } + + if responses.At(0) != "Ada" || responses.At(1) != "TS" || responses.At(2) != true { + t.Fatalf("responses = %#v", responses.Values()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestBuilderKeysNamedResponsesAndPassesPreviousAndName(t *testing.T) { + err := withFormEnvironment([]string{"A", "d", "a", key.First(key.Enter)}, func(*environment.MemoryOutput) error { + responses, err := New(). + Text(prompts.TextOptions{Message: "Name"}, "name"). + Add(func(values *Responses, previous any, name string) (any, error) { + if previous == nil { + previous = "new" + } + return name + ":" + previous.(string) + ":Hello " + values.Named("name").(string), nil + }, "greeting"). + Submit() + if err != nil { + return err + } + + if responses.Named("name") != "Ada" || responses.Named("greeting") != "greeting:new:Hello Ada" { + t.Fatalf("named responses = %#v", responses.Names()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestBuilderStoresNilForSkippedConditionalSteps(t *testing.T) { + err := withFormEnvironment([]string{"n"}, func(*environment.MemoryOutput) error { + responses, err := New(). + Confirm(prompts.ConfirmOptions{Message: "Include details?", Default: false, HasDefault: true}, "include"). + AddIf(func(values *Responses) (bool, error) { + return values.Named("include") == true, nil + }, func(*Responses, any, string) (any, error) { + return "details", nil + }, "details"). + Submit() + if err != nil { + return err + } + + if responses.Named("include") != false || responses.Named("details") != nil { + t.Fatalf("named responses = %#v", responses.Names()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestBuilderRevertsToPreviousStepAndReusesPriorResponse(t *testing.T) { + err := withFormEnvironment([]string{ + "A", key.First(key.Enter), + "B", key.First(key.CtrlU), + key.First(key.Backspace), "C", key.First(key.Enter), + "D", key.First(key.Enter), + }, func(*environment.MemoryOutput) error { + responses, err := New(). + Text(prompts.TextOptions{Message: "First"}). + Text(prompts.TextOptions{Message: "Second"}). + Submit() + if err != nil { + return err + } + + if responses.At(0) != "C" || responses.At(1) != "D" { + t.Fatalf("responses = %#v", responses.Values()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestBuilderReusesPreviousPromptDefaultsByType(t *testing.T) { + err := withFormEnvironment([]string{ + "4", key.First(key.Enter), + key.First(key.Space), key.First(key.Enter), + key.First(key.CtrlU), + key.First(key.Enter), + "D", key.First(key.Enter), + }, func(*environment.MemoryOutput) error { + responses, err := New(). + Number(prompts.NumberOptions{Message: "Count"}). + Multiselect(prompts.MultiChoiceOptions{Message: "Flags", Choices: prompts.NewStringChoices("alpha", "beta")}). + Text(prompts.TextOptions{Message: "Done"}). + Submit() + if err != nil { + return err + } + + if responses.At(0) != 4 || !reflect.DeepEqual(responses.At(1), []any{"alpha"}) || responses.At(2) != "D" { + t.Fatalf("responses = %#v", responses.Values()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestBuilderRevertsPastIgnoredSideEffectSteps(t *testing.T) { + err := withFormEnvironment([]string{ + "A", key.First(key.Enter), + "B", key.First(key.CtrlU), + key.First(key.Backspace), "C", key.First(key.Enter), + "D", key.First(key.Enter), + }, func(*environment.MemoryOutput) error { + responses, err := New(). + Text(prompts.TextOptions{Message: "First"}). + Note("Skipping back over this", output.NoteType(""), "note"). + Text(prompts.TextOptions{Message: "Second"}). + Submit() + if err != nil { + return err + } + + if responses.At(0) != "C" || responses.Named("note") != nil || responses.At(2) != "D" { + t.Fatalf("positional=%#v named=%#v", responses.Values(), responses.Names()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestBuilderDoesNotRevertTheFirstStep(t *testing.T) { + err := withFormEnvironment([]string{key.First(key.CtrlU), "A", key.First(key.Enter)}, func(output *environment.MemoryOutput) error { + responses, err := New().Text(prompts.TextOptions{Message: "First"}).Submit() + if err != nil { + return err + } + + if responses.At(0) != "A" || !strings.Contains(output.Text(), "This cannot be reverted.") { + t.Fatalf("response=%#v output=%q", responses.Values(), output.Text()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestBuilderRunsOutputAndStatusAdapters(t *testing.T) { + err := withFormEnvironment(nil, func(memoryOutput *environment.MemoryOutput) error { + responses, err := New(). + Info("Preparing", "info"). + Spin("Working", func() (any, error) { return "done", nil }, "spin"). + Submit() + if err != nil { + return err + } + + rendered := memoryOutput.Text() + if responses.Named("info") != nil || responses.Named("spin") != "done" || !strings.Contains(rendered, "Preparing") || !strings.Contains(rendered, "Working") { + t.Fatalf("named=%#v output=%q", responses.Names(), rendered) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} diff --git a/console/shell/internal/form/output.go b/console/shell/internal/form/output.go new file mode 100644 index 00000000..d3cf86b5 --- /dev/null +++ b/console/shell/internal/form/output.go @@ -0,0 +1,87 @@ +package form + +import "github.com/oullin/tui/internal/output" + +func (builder *Builder) Alert(message string, name ...string) *Builder { + return builder.AddSideEffect(func() error { + output.Alert(message) + return nil + }, name...) +} + +func (builder *Builder) Clear(name ...string) *Builder { + return builder.AddSideEffect(func() error { + output.Clear() + return nil + }, name...) +} + +func (builder *Builder) Error(message string, name ...string) *Builder { + return builder.AddSideEffect(func() error { + output.Error(message) + return nil + }, name...) +} + +func (builder *Builder) Grid(items []any, maxWidth int, name ...string) *Builder { + return builder.AddSideEffect(func() error { + output.Grid(items, maxWidth) + return nil + }, name...) +} + +func (builder *Builder) Info(message string, name ...string) *Builder { + return builder.AddSideEffect(func() error { + output.Info(message) + return nil + }, name...) +} + +func (builder *Builder) Intro(message string, name ...string) *Builder { + return builder.AddSideEffect(func() error { + output.Intro(message) + return nil + }, name...) +} + +func (builder *Builder) Note(message string, noteType output.NoteType, name ...string) *Builder { + return builder.AddSideEffect(func() error { + output.Note(message, noteType) + return nil + }, name...) +} + +func (builder *Builder) Outro(message string, name ...string) *Builder { + return builder.AddSideEffect(func() error { + output.Outro(message) + return nil + }, name...) +} + +func (builder *Builder) Table(headers []string, rows [][]any, name ...string) *Builder { + return builder.AddSideEffect(func() error { + output.Table(headers, rows) + return nil + }, name...) +} + +func (builder *Builder) TableObjects(headers []string, rows []output.TableRow, name ...string) *Builder { + return builder.AddSideEffect(func() error { + output.TableObjects(headers, rows) + return nil + }, name...) +} + +func (builder *Builder) Title(value string, name ...string) *Builder { + return builder.AddSideEffect(func() error { + output.Title(value) + return nil + }, name...) +} + +func (builder *Builder) Warning(message string, name ...string) *Builder { + return builder.AddSideEffect(func() error { + output.Warning(message) + return nil + }, name...) +} diff --git a/console/shell/internal/form/previous.go b/console/shell/internal/form/previous.go new file mode 100644 index 00000000..a448fea0 --- /dev/null +++ b/console/shell/internal/form/previous.go @@ -0,0 +1,55 @@ +package form + +import "fmt" + +func PreviousString(previous any, defaultValue string) string { + value, ok := previous.(string) + if !ok { + return defaultValue + } + + return value +} + +func PreviousNumber(previous any, defaultValue string) string { + switch value := previous.(type) { + case nil: + return defaultValue + case string: + return value + case int: + return fmt.Sprint(value) + case int64: + return fmt.Sprint(value) + case float64: + return fmt.Sprint(value) + default: + return defaultValue + } +} + +func PreviousBool(previous any, defaultValue bool) bool { + value, ok := previous.(bool) + if !ok { + return defaultValue + } + + return value +} + +func PreviousSlice(previous any, defaultValue []any) []any { + value, ok := previous.([]any) + if !ok { + return append([]any{}, defaultValue...) + } + + return append([]any{}, value...) +} + +func PreviousValue(previous any, defaultValue any) any { + if previous == nil { + return defaultValue + } + + return previous +} diff --git a/console/shell/internal/form/prompts.go b/console/shell/internal/form/prompts.go new file mode 100644 index 00000000..3dfc719e --- /dev/null +++ b/console/shell/internal/form/prompts.go @@ -0,0 +1,111 @@ +package form + +import ( + "github.com/oullin/tui/internal/output" + "github.com/oullin/tui/internal/prompt" + "github.com/oullin/tui/internal/prompts" +) + +func (builder *Builder) Text(options prompts.TextOptions, name ...string) *Builder { + return builder.Add(func(_ *Responses, previous any, _ string) (any, error) { + options.Default = PreviousString(previous, options.Default) + return prompts.Text(options) + }, name...) +} + +func (builder *Builder) Textarea(options prompts.TextOptions, name ...string) *Builder { + return builder.Add(func(_ *Responses, previous any, _ string) (any, error) { + options.Default = PreviousString(previous, options.Default) + return prompts.Textarea(options) + }, name...) +} + +func (builder *Builder) Password(options prompts.TextOptions, name ...string) *Builder { + return builder.Add(func(_ *Responses, previous any, _ string) (any, error) { + options.Default = PreviousString(previous, options.Default) + return prompts.Password(options) + }, name...) +} + +func (builder *Builder) Number(options prompts.NumberOptions, name ...string) *Builder { + return builder.Add(func(_ *Responses, previous any, _ string) (any, error) { + previousValue := PreviousNumber(previous, options.Default) + options.Default = previousValue + options.HasDefault = options.HasDefault || previousValue != "" + return prompts.Number(options) + }, name...) +} + +func (builder *Builder) Pause(message string, name ...string) *Builder { + return builder.Add(func(*Responses, any, string) (any, error) { + return prompts.Pause(message) + }, name...) +} + +func (builder *Builder) Confirm(options prompts.ConfirmOptions, name ...string) *Builder { + return builder.Add(func(_ *Responses, previous any, _ string) (any, error) { + if previous != nil { + options.Default = PreviousBool(previous, options.Default) + } else if !options.HasDefault { + options.Default = true + } + options.HasDefault = true + return prompts.Confirm(options) + }, name...) +} + +func (builder *Builder) Select(options prompts.ChoiceOptions, name ...string) *Builder { + return builder.Add(func(_ *Responses, previous any, _ string) (any, error) { + options.Default = PreviousValue(previous, options.Default) + options.HasDefault = options.HasDefault || previous != nil + return prompts.Select(options) + }, name...) +} + +func (builder *Builder) Multiselect(options prompts.MultiChoiceOptions, name ...string) *Builder { + return builder.Add(func(_ *Responses, previous any, _ string) (any, error) { + options.Defaults = PreviousSlice(previous, options.Defaults) + return prompts.Multiselect(options) + }, name...) +} + +func (builder *Builder) Search(options prompts.SearchOptions, name ...string) *Builder { + return builder.Add(func(_ *Responses, previous any, _ string) (any, error) { + options.Default = PreviousValue(previous, options.Default) + options.HasDefault = options.HasDefault || previous != nil + return prompts.Search(options) + }, name...) +} + +func (builder *Builder) Multisearch(options prompts.MultiSearchOptions, name ...string) *Builder { + return builder.Add(func(_ *Responses, previous any, _ string) (any, error) { + options.Defaults = PreviousSlice(previous, options.Defaults) + return prompts.MultiSearch(options) + }, name...) +} + +func (builder *Builder) Suggest(options prompts.SuggestOptions, name ...string) *Builder { + return builder.Add(func(_ *Responses, previous any, _ string) (any, error) { + options.Default = PreviousString(previous, options.Default) + return prompts.Suggest(options) + }, name...) +} + +func (builder *Builder) Autocomplete(options prompts.SuggestOptions, name ...string) *Builder { + return builder.Add(func(_ *Responses, previous any, _ string) (any, error) { + options.Default = PreviousString(previous, options.Default) + return prompts.Autocomplete(options) + }, name...) +} + +func (builder *Builder) DataTable(options output.DataTableOptions, name ...string) *Builder { + return builder.Add(func(_ *Responses, previous any, _ string) (any, error) { + options.Default = PreviousValue(previous, options.Default) + options.HasDefault = options.HasDefault || previous != nil + return output.DataTable(options) + }, name...) +} + +func Required(value bool) prompt.Required { + return prompt.Required{Enabled: value} +} diff --git a/console/shell/internal/form/responses.go b/console/shell/internal/form/responses.go new file mode 100644 index 00000000..dbb4c73f --- /dev/null +++ b/console/shell/internal/form/responses.go @@ -0,0 +1,70 @@ +package form + +type Responses struct { + named map[string]any + positional []any +} + +func NewResponses() *Responses { + return &Responses{named: map[string]any{}} +} + +func (responses *Responses) At(index int) any { + if responses == nil || index < 0 || index >= len(responses.positional) { + return nil + } + + return responses.positional[index] +} + +func (responses *Responses) Named(name string) any { + if responses == nil || name == "" { + return nil + } + + return responses.named[name] +} + +func (responses *Responses) Values() []any { + if responses == nil { + return nil + } + + values := make([]any, len(responses.positional)) + copy(values, responses.positional) + + return values +} + +func (responses *Responses) Names() map[string]any { + if responses == nil { + return nil + } + + values := make(map[string]any, len(responses.named)) + for key, value := range responses.named { + values[key] = value + } + + return values +} + +func (responses *Responses) previous(index int, name string) any { + if name != "" { + return responses.Named(name) + } + + return responses.At(index) +} + +func (responses *Responses) set(index int, name string, value any) { + if name != "" { + responses.named[name] = value + return + } + + for len(responses.positional) <= index { + responses.positional = append(responses.positional, nil) + } + responses.positional[index] = value +} diff --git a/console/shell/internal/form/revert.go b/console/shell/internal/form/revert.go new file mode 100644 index 00000000..7a21ab4c --- /dev/null +++ b/console/shell/internal/form/revert.go @@ -0,0 +1,58 @@ +package form + +import ( + "errors" + + "github.com/oullin/tui/internal/environment" + "github.com/oullin/tui/internal/key" + "github.com/oullin/tui/internal/theme" +) + +var ErrReverted = errors.New("form reverted") + +type revertInput struct { + canRevert bool + previous environment.Input +} + +func runWithRevert(canRevert bool, callback func() (any, error)) (any, error) { + env := environment.Current() + if env.Input == nil { + return callback() + } + + return callbackWithInput(revertInput{canRevert: canRevert, previous: env.Input}, callback) +} + +func callbackWithInput(input environment.Input, callback func() (any, error)) (any, error) { + returnValue := any(nil) + err := environment.With(environment.Patch{Input: input}, func() error { + value, callbackErr := callback() + returnValue = value + return callbackErr + }) + + return returnValue, err +} + +func (input revertInput) ReadKey() (string, bool, error) { + pressed, ok, err := input.previous.ReadKey() + if err != nil || !ok { + return pressed, ok, err + } + + if pressed != key.First(key.CtrlU) { + return pressed, ok, nil + } + + if input.canRevert { + return "", false, ErrReverted + } + + environment.Current().ErrorOutput.Write(theme.Red(" ⚠ This cannot be reverted.") + "\n") + return input.ReadKey() +} + +func (input revertInput) ReadLine(message string) (string, error) { + return input.previous.ReadLine(message) +} diff --git a/console/shell/internal/form/status.go b/console/shell/internal/form/status.go new file mode 100644 index 00000000..38c6d7bb --- /dev/null +++ b/console/shell/internal/form/status.go @@ -0,0 +1,30 @@ +package form + +import "github.com/oullin/tui/internal/status" + +func (builder *Builder) Progress(total int, message string, name ...string) *Builder { + return builder.AddSideEffect(func() error { + bar := status.NewProgress(total, message, "") + bar.Start() + bar.Finish() + return nil + }, name...) +} + +func (builder *Builder) Spin(message string, callback func() (any, error), name ...string) *Builder { + return builder.Add(func(*Responses, any, string) (any, error) { + return status.Spin(message, callback) + }, name...) +} + +func (builder *Builder) Stream(name ...string) *Builder { + return builder.Add(func(*Responses, any, string) (any, error) { + return status.NewStream(), nil + }, name...) +} + +func (builder *Builder) Task(label string, callback func(*status.Logger) (any, error), limit int, keepSummary bool, subLabel string, name ...string) *Builder { + return builder.Add(func(*Responses, any, string) (any, error) { + return status.Task(label, callback, limit, keepSummary, subLabel) + }, name...) +} From 80207285693068d0c4d14a2cf562d968026a0d1b Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Sat, 13 Jun 2026 17:19:13 +0800 Subject: [PATCH 280/675] feat(shell): expose public Go shell API --- console/shell/exports.go | 174 ++++++++++++++++++++++++++++++++++ console/shell/exports_test.go | 50 ++++++++++ console/shell/portal/main.go | 23 ++++- 3 files changed, 245 insertions(+), 2 deletions(-) create mode 100644 console/shell/exports.go create mode 100644 console/shell/exports_test.go diff --git a/console/shell/exports.go b/console/shell/exports.go new file mode 100644 index 00000000..ec4e0572 --- /dev/null +++ b/console/shell/exports.go @@ -0,0 +1,174 @@ +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 + +var CurrentEnvironment = environment.Current +var Configure = environment.Configure +var WithEnvironment = environment.With +var NewMemoryOutput = environment.NewMemoryOutput +var NewScriptedInput = environment.NewScriptedInput +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/console/shell/exports_test.go b/console/shell/exports_test.go new file mode 100644 index 00000000..6ade9760 --- /dev/null +++ b/console/shell/exports_test.go @@ -0,0 +1,50 @@ +package tui_test + +import ( + "strings" + "testing" + + tui "github.com/oullin/tui" +) + +func TestPublicAPIConsumesFormAndOutput(t *testing.T) { + memoryOutput := tui.NewMemoryOutput() + interactive := true + err := tui.WithEnvironment(tui.Patch{ + Input: tui.NewScriptedInput([]string{"A", "d", "a", tui.KeyFirst(tui.KeyEnter)}), + Output: memoryOutput, + ErrorOutput: memoryOutput, + Interactive: &interactive, + }, func() error { + responses, err := tui.Form(). + Text(tui.TextOptions{Message: "Name"}). + Info("Saved", "note"). + Submit() + if err != nil { + return err + } + if responses.At(0) != "Ada" || responses.Named("note") != nil { + t.Fatalf("responses positional=%#v named=%#v", responses.Values(), responses.Names()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } + + if !strings.Contains(memoryOutput.Text(), "Saved") { + t.Fatalf("output missing note: %q", memoryOutput.Text()) + } +} + +func TestPublicAPIExportsCoreHelpers(t *testing.T) { + if tui.KeyFromEvent(tui.KeyEvent{Name: "u", Ctrl: true}) != tui.KeyFirst(tui.KeyCtrlU) { + t.Fatalf("ctrl-u key mapping mismatch") + } + if tui.VisibleWidth("\x1b[31mTokyo\x1b[39m") != 5 { + t.Fatalf("visible width mismatch") + } + if tui.ClearTerminalSequence() == "" || tui.HideCursorSequence() == "" { + t.Fatalf("terminal sequences should be exported") + } +} diff --git a/console/shell/portal/main.go b/console/shell/portal/main.go index 6480e315..daa1b660 100644 --- a/console/shell/portal/main.go +++ b/console/shell/portal/main.go @@ -1,7 +1,26 @@ package main -import "fmt" +import ( + "fmt" + "os" + + tui "github.com/oullin/tui" +) + +type stdOutput struct{} func main() { - fmt.Println("Oullin TUI shell") + tui.Configure(tui.Patch{Output: stdOutput{}, ErrorOutput: stdOutput{}}) + + if len(os.Args) > 1 && os.Args[1] == "version" { + fmt.Println("@ollin/shell 0.1.0") + return + } + + tui.Intro("Oullin TUI shell") + tui.Info("Go console shell workspace is ready.") +} + +func (stdOutput) Write(content string) { + fmt.Print(content) } From 0a0e9b08197d75b94c5fd12467c446a4d54d0dd5 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Sat, 13 Jun 2026 17:24:29 +0800 Subject: [PATCH 281/675] feat(shell): wire portal to real stdio prompts --- console/shell/exports.go | 4 + console/shell/go.mod | 6 +- console/shell/go.sum | 4 + console/shell/internal/environment/stdio.go | 89 +++++++++++++++++++ .../shell/internal/environment/stdio_test.go | 43 +++++++++ console/shell/portal/main.go | 74 +++++++++++++-- console/shell/portal/main_test.go | 49 ++++++++++ 7 files changed, 260 insertions(+), 9 deletions(-) create mode 100644 console/shell/internal/environment/stdio.go create mode 100644 console/shell/internal/environment/stdio_test.go create mode 100644 console/shell/portal/main_test.go diff --git a/console/shell/exports.go b/console/shell/exports.go index ec4e0572..545c15bf 100644 --- a/console/shell/exports.go +++ b/console/shell/exports.go @@ -19,12 +19,16 @@ 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 diff --git a/console/shell/go.mod b/console/shell/go.mod index efdb8bef..baf0c3bd 100644 --- a/console/shell/go.mod +++ b/console/shell/go.mod @@ -4,4 +4,8 @@ go 1.26.3 require github.com/mattn/go-runewidth v0.0.24 -require github.com/clipperhouse/uax29/v2 v2.2.0 // indirect +require ( + github.com/clipperhouse/uax29/v2 v2.2.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/term v0.44.0 // indirect +) diff --git a/console/shell/go.sum b/console/shell/go.sum index dfdcfc87..967f6c6b 100644 --- a/console/shell/go.sum +++ b/console/shell/go.sum @@ -2,3 +2,7 @@ github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdoh github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU= github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +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= diff --git a/console/shell/internal/environment/stdio.go b/console/shell/internal/environment/stdio.go new file mode 100644 index 00000000..bc23b4c6 --- /dev/null +++ b/console/shell/internal/environment/stdio.go @@ -0,0 +1,89 @@ +package environment + +import ( + "bufio" + "errors" + "io" + stdstrings "strings" +) + +type StdioInput struct { + prompt io.Writer + reader *bufio.Reader +} + +type WriterOutput struct { + writer io.Writer +} + +func NewStdioInput(reader io.Reader, prompt io.Writer) *StdioInput { + return &StdioInput{prompt: prompt, reader: bufio.NewReader(reader)} +} + +func NewWriterOutput(writer io.Writer) WriterOutput { + return WriterOutput{writer: writer} +} + +func (input *StdioInput) ReadKey() (string, bool, error) { + value, err := input.reader.ReadByte() + if errors.Is(err, io.EOF) { + return "", false, nil + } + if err != nil { + return "", false, err + } + + if value == '\r' { + return "\n", true, nil + } + + if value == '\x1b' { + return input.readEscapeSequence(value), true, nil + } + + return string(value), true, nil +} + +func (input *StdioInput) ReadLine(message string) (string, error) { + if input.prompt != nil && message != "" { + if _, err := io.WriteString(input.prompt, message); err != nil { + return "", err + } + } + + value, err := input.reader.ReadString('\n') + if err != nil && !errors.Is(err, io.EOF) { + return "", err + } + + return stdstrings.TrimRight(value, "\r\n"), nil +} + +func (input *StdioInput) readEscapeSequence(first byte) string { + sequence := []byte{first} + + for input.reader.Buffered() > 0 { + next, err := input.reader.ReadByte() + if err != nil { + break + } + sequence = append(sequence, next) + if isEscapeSequenceFinal(next) { + break + } + } + + return string(sequence) +} + +func isEscapeSequenceFinal(value byte) bool { + return (value >= 'A' && value <= 'Z') || (value >= 'a' && value <= 'z') || value == '~' +} + +func (output WriterOutput) Write(content string) { + if output.writer == nil || content == "" { + return + } + + _, _ = io.WriteString(output.writer, content) +} diff --git a/console/shell/internal/environment/stdio_test.go b/console/shell/internal/environment/stdio_test.go new file mode 100644 index 00000000..15a007b4 --- /dev/null +++ b/console/shell/internal/environment/stdio_test.go @@ -0,0 +1,43 @@ +package environment + +import ( + "bytes" + "testing" +) + +func TestStdioInputReadsKeysLinesAndEscapeSequences(t *testing.T) { + var prompt bytes.Buffer + input := NewStdioInput(bytes.NewBufferString("A\r\x1b[Ahello\n"), &prompt) + + pressed, ok, err := input.ReadKey() + if err != nil || !ok || pressed != "A" { + t.Fatalf("first key = %q ok=%v err=%v", pressed, ok, err) + } + + pressed, ok, err = input.ReadKey() + if err != nil || !ok || pressed != "\n" { + t.Fatalf("enter key = %q ok=%v err=%v", pressed, ok, err) + } + + pressed, ok, err = input.ReadKey() + if err != nil || !ok || pressed != "\x1b[A" { + t.Fatalf("escape key = %q ok=%v err=%v", pressed, ok, err) + } + + line, err := input.ReadLine("Name: ") + if err != nil || line != "hello" { + t.Fatalf("line = %q err=%v", line, err) + } + if prompt.String() != "Name: " { + t.Fatalf("prompt = %q", prompt.String()) + } +} + +func TestWriterOutputWritesToWriter(t *testing.T) { + var output bytes.Buffer + NewWriterOutput(&output).Write("hello") + + if output.String() != "hello" { + t.Fatalf("output = %q", output.String()) + } +} diff --git a/console/shell/portal/main.go b/console/shell/portal/main.go index daa1b660..3be99276 100644 --- a/console/shell/portal/main.go +++ b/console/shell/portal/main.go @@ -5,22 +5,80 @@ import ( "os" tui "github.com/oullin/tui" + "golang.org/x/term" ) -type stdOutput struct{} - func main() { - tui.Configure(tui.Patch{Output: stdOutput{}, ErrorOutput: stdOutput{}}) + if err := run(os.Args[1:]); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} - if len(os.Args) > 1 && os.Args[1] == "version" { +func run(args []string) error { + if len(args) > 0 && args[0] == "version" { fmt.Println("@ollin/shell 0.1.0") - return + return nil + } + + configureStdioEnvironment() + + if len(args) > 0 && args[0] == "form" { + return runFormCommand() } tui.Intro("Oullin TUI shell") - tui.Info("Go console shell workspace is ready.") + tui.Info("Commands: version, form") + return nil +} + +func configureStdioEnvironment() { + interactive := true + tui.Configure(tui.Patch{ + Input: tui.NewStdioInput(os.Stdin, os.Stdout), + Output: tui.NewWriterOutput(os.Stdout), + ErrorOutput: tui.NewWriterOutput(os.Stderr), + Interactive: &interactive, + }) } -func (stdOutput) Write(content string) { - fmt.Print(content) +func runFormCommand() error { + restore, err := enableRawInput() + if err != nil { + return err + } + defer restore() + + responses, err := tui.Form(). + Text(tui.TextOptions{Message: "Project name", Required: tui.Required{Enabled: true}}). + Select(tui.ChoiceOptions{Message: "Runtime", Choices: tui.NewStringChoices("Go", "TypeScript"), Default: "Go", HasDefault: true}). + Confirm(tui.ConfirmOptions{Message: "Create project?", Default: true, 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/console/shell/portal/main_test.go b/console/shell/portal/main_test.go new file mode 100644 index 00000000..373c37f0 --- /dev/null +++ b/console/shell/portal/main_test.go @@ -0,0 +1,49 @@ +package main + +import ( + "bytes" + "os/exec" + "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 runPortal(t *testing.T, stdin []byte, args ...string) string { + t.Helper() + + commandArgs := append([]string{"run", "."}, args...) + command := exec.Command("go", commandArgs...) + command.Stdin = bytes.NewReader(stdin) + + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("go run . %s failed: %v\n%s", strings.Join(args, " "), err, output) + } + + return string(output) +} From f154363b841ff99f6ed193118723b025fbed6a54 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Sat, 13 Jun 2026 17:31:11 +0800 Subject: [PATCH 282/675] feat(shell): load portal config with viper --- console/shell/go.mod | 12 +++ console/shell/go.sum | 25 +++++ console/shell/portal/config.go | 157 ++++++++++++++++++++++++++++++ console/shell/portal/main.go | 25 +++-- console/shell/portal/main_test.go | 55 +++++++++++ 5 files changed, 264 insertions(+), 10 deletions(-) create mode 100644 console/shell/portal/config.go diff --git a/console/shell/go.mod b/console/shell/go.mod index baf0c3bd..40c58976 100644 --- a/console/shell/go.mod +++ b/console/shell/go.mod @@ -6,6 +6,18 @@ 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/console/shell/go.sum b/console/shell/go.sum index 967f6c6b..47f62dab 100644 --- a/console/shell/go.sum +++ b/console/shell/go.sum @@ -1,8 +1,33 @@ github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY= github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU= github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= +github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/console/shell/portal/config.go b/console/shell/portal/config.go new file mode 100644 index 00000000..9fc40811 --- /dev/null +++ b/console/shell/portal/config.go @@ -0,0 +1,157 @@ +package main + +import ( + "errors" + "fmt" + "io" + "os" + "path/filepath" + stdstrings "strings" + + "github.com/spf13/pflag" + "github.com/spf13/viper" +) + +const ( + defaultCommand = "home" + versionCommand = "version" + formCommand = "form" +) + +type portalConfig struct { + Command string + ProjectDefault string + RuntimeDefault string + CreateDefault bool + Interactive bool +} + +func loadPortalConfig(args []string) (portalConfig, error) { + settings := viper.New() + settings.SetEnvPrefix("OLLIN_SHELL") + settings.SetEnvKeyReplacer(stdstrings.NewReplacer(".", "_", "-", "_")) + settings.AutomaticEnv() + + settings.SetDefault("command", defaultCommand) + settings.SetDefault("form.project", "") + settings.SetDefault("form.runtime", "Go") + settings.SetDefault("form.create", true) + settings.SetDefault("interactive", true) + + flags := pflag.NewFlagSet("shell", pflag.ContinueOnError) + flags.SetOutput(io.Discard) + flags.String("config", "", "Path to a shell config file.") + flags.String("command", "", "Command to run.") + flags.String("project", "", "Default project name for the form command.") + flags.String("runtime", "", "Default runtime for the form command.") + flags.Bool("create", true, "Default create confirmation for the form command.") + flags.Bool("interactive", true, "Enable interactive prompts.") + flags.Bool("version", false, "Print the shell version.") + + if err := flags.Parse(args); err != nil { + return portalConfig{}, err + } + + if err := settings.BindPFlag("config", flags.Lookup("config")); err != nil { + return portalConfig{}, err + } + if err := settings.BindPFlag("command", flags.Lookup("command")); err != nil { + return portalConfig{}, err + } + if err := settings.BindPFlag("form.project", flags.Lookup("project")); err != nil { + return portalConfig{}, err + } + if err := settings.BindPFlag("form.runtime", flags.Lookup("runtime")); err != nil { + return portalConfig{}, err + } + if err := settings.BindPFlag("form.create", flags.Lookup("create")); err != nil { + return portalConfig{}, err + } + if err := settings.BindPFlag("interactive", flags.Lookup("interactive")); err != nil { + return portalConfig{}, err + } + + if configPath := settings.GetString("config"); configPath != "" { + settings.SetConfigFile(configPath) + if !hasKnownConfigExtension(configPath) { + settings.SetConfigType("yaml") + } + if err := settings.ReadInConfig(); err != nil { + return portalConfig{}, err + } + } else { + settings.SetConfigName("shell") + settings.SetConfigType("yaml") + settings.AddConfigPath(".") + if home, err := os.UserHomeDir(); err == nil { + settings.AddConfigPath(home + "/.config/ollin") + } + if err := settings.ReadInConfig(); err != nil { + var notFound viper.ConfigFileNotFoundError + if !errors.As(err, ¬Found) { + return portalConfig{}, err + } + } + } + + if flags.Changed("version") && settings.GetBool("version") { + settings.Set("command", versionCommand) + } + + if command := firstPositionalCommand(flags.Args()); command != "" && !flags.Changed("command") { + settings.Set("command", command) + } + + config := portalConfig{ + Command: normalizeCommand(settings.GetString("command")), + ProjectDefault: settings.GetString("form.project"), + RuntimeDefault: settings.GetString("form.runtime"), + CreateDefault: settings.GetBool("form.create"), + Interactive: settings.GetBool("interactive"), + } + if err := config.validate(); err != nil { + return portalConfig{}, err + } + + return config, nil +} + +func firstPositionalCommand(args []string) string { + if len(args) == 0 { + return "" + } + + return args[0] +} + +func normalizeCommand(command string) string { + command = stdstrings.TrimSpace(stdstrings.ToLower(command)) + if command == "" { + return defaultCommand + } + + return command +} + +func hasKnownConfigExtension(path string) bool { + switch stdstrings.ToLower(filepath.Ext(path)) { + case ".json", ".toml", ".yaml", ".yml", ".env", ".hcl", ".ini", ".properties", ".props", ".prop": + return true + default: + return false + } +} + +func (config portalConfig) validate() error { + switch config.Command { + case defaultCommand, versionCommand, formCommand: + default: + return fmt.Errorf("unknown command %q", config.Command) + } + + if config.RuntimeDefault != "Go" && config.RuntimeDefault != "TypeScript" { + return fmt.Errorf("unsupported runtime %q", config.RuntimeDefault) + } + + return nil +} diff --git a/console/shell/portal/main.go b/console/shell/portal/main.go index 3be99276..78c6f1a3 100644 --- a/console/shell/portal/main.go +++ b/console/shell/portal/main.go @@ -16,15 +16,20 @@ func main() { } func run(args []string) error { - if len(args) > 0 && args[0] == "version" { + config, err := loadPortalConfig(args) + if err != nil { + return err + } + + if config.Command == versionCommand { fmt.Println("@ollin/shell 0.1.0") return nil } - configureStdioEnvironment() + configureStdioEnvironment(config) - if len(args) > 0 && args[0] == "form" { - return runFormCommand() + if config.Command == formCommand { + return runFormCommand(config) } tui.Intro("Oullin TUI shell") @@ -32,8 +37,8 @@ func run(args []string) error { return nil } -func configureStdioEnvironment() { - interactive := true +func configureStdioEnvironment(config portalConfig) { + interactive := config.Interactive tui.Configure(tui.Patch{ Input: tui.NewStdioInput(os.Stdin, os.Stdout), Output: tui.NewWriterOutput(os.Stdout), @@ -42,7 +47,7 @@ func configureStdioEnvironment() { }) } -func runFormCommand() error { +func runFormCommand(config portalConfig) error { restore, err := enableRawInput() if err != nil { return err @@ -50,9 +55,9 @@ func runFormCommand() error { defer restore() responses, err := tui.Form(). - Text(tui.TextOptions{Message: "Project name", Required: tui.Required{Enabled: true}}). - Select(tui.ChoiceOptions{Message: "Runtime", Choices: tui.NewStringChoices("Go", "TypeScript"), Default: "Go", HasDefault: true}). - Confirm(tui.ConfirmOptions{Message: "Create project?", Default: true, HasDefault: true}). + 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 diff --git a/console/shell/portal/main_test.go b/console/shell/portal/main_test.go index 373c37f0..9baadaa4 100644 --- a/console/shell/portal/main_test.go +++ b/console/shell/portal/main_test.go @@ -2,7 +2,9 @@ package main import ( "bytes" + "os" "os/exec" + "path/filepath" "strings" "testing" ) @@ -33,12 +35,65 @@ func TestPortalFormCommandUsesRealStdio(t *testing.T) { } } +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 { From 6055e3bc70c3ef2d7381bf8030e15369526eae53 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Sat, 13 Jun 2026 17:33:39 +0800 Subject: [PATCH 283/675] chore(shell): port make dev and format targets --- Makefile | 20 ++++++++++++++------ package.json | 3 +++ 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/Makefile b/Makefile index 556706e9..5b32f14e 100644 --- a/Makefile +++ b/Makefile @@ -9,7 +9,7 @@ FMT_RUN := docker run --rm \ -e HOST_PROJECT_PATH=$(ROOT_PATH) \ $(FMT_IMAGE) -TS_GLOBS := '*.ts' '*.tsx' '*.mts' '*.cts' +TS_GLOBS := '*.ts' '*.tsx' '*.vue' '*.mts' '*.cts' .DEFAULT_GOAL := help @@ -18,10 +18,14 @@ define step 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 files to format."; rm -f "$$tmp"; else xargs -0 $(FMT_RUN) format < "$$tmp"; rc=$$?; rm -f "$$tmp"; exit $$rc; fi +@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 -.PHONY: help format format-all fresh +define run_in + @cd "$(1)" && $(2) +endef + +.PHONY: help dev format format-all fresh help: @awk 'BEGIN {FS = ":.*?## "; printf "\nUsage: \033[36mmake \033[0m\n"} \ @@ -29,12 +33,16 @@ help: /^[a-zA-Z0-9_.-]+:.*?##/ { printf " \033[36m%-28s\033[0m %s\n", $$1, $$2 }' $(MAKEFILE_LIST) @echo "" +##@ Development +dev: ## Run the docs dev server + $(call run_in,$(ROOT_PATH),pnpm --filter docs dev) + ##@ Formatting -format: ## Format untracked + modified TS files via oxfmt/oxlint +format: ## Format + lint untracked + modified TS/Vue files via oxfmt/oxlint $(call run_oxfmt,--others --modified) -format-all: ## Format every non-ignored TS 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 files to format."; rm -f "$$tmp"; else xargs -0 $(FMT_RUN) format < "$$tmp"; rc=$$?; rm -f "$$tmp"; exit $$rc; fi +format-all: ## Format + lint every non-ignored TS/Vue file in the repo via oxfmt/oxlint + @cd "$(ROOT_PATH)" && tmp=$$(mktemp); git ls-files -z --cached --others --exclude-standard -- $(TS_GLOBS) | while IFS= read -r -d '' f; do [ -f "$$f" ] && printf '%s\0' "$$f"; done > "$$tmp"; if [ ! -s "$$tmp" ]; then echo "No TS/Vue files to format."; rm -f "$$tmp"; else xargs -0 $(FMT_RUN) format < "$$tmp"; rc=$$?; rm -f "$$tmp"; exit $$rc; fi ##@ Maintenance fresh: ## Clean generated state, reinstall dependencies, then run local gates diff --git a/package.json b/package.json index 5c4cbe50..12299eda 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,9 @@ "type": "module", "scripts": { "build": "turbo build", + "dev": "make dev", + "format": "make format", + "format-all": "make format-all", "test": "turbo test", "typecheck": "turbo typecheck" }, From 453d4afff26a7bd3f331502edf4a76805493b92e Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Sat, 13 Jun 2026 21:13:01 +0800 Subject: [PATCH 284/675] feat(tui): wire prompt lifecycle and provision caches --- .gitignore | 4 + .npmrc | 1 + Makefile | 2 +- UPSTREAM.md | 22 +- console/.gitignore | 1 - console/shell/package.json | 10 - package.json | 6 +- packages/artefacts/src/index.ts | 6 +- packages/artefacts/tsconfig.json | 2 +- packages/artefacts/tsconfig/acceptance.json | 2 +- packages/artefacts/tsconfig/tui.json | 2 +- .../theme/components/TerminalOutput.vue | 19 +- .../theme/components/ui/card/Card.vue | 5 +- .../theme/components/ui/card/CardHeader.vue | 7 +- .../components/ui/separator/Separator.vue | 7 +- packages/docs/tsconfig.json | 2 +- packages/tui/src/contracts/output.ts | 2 +- packages/tui/src/index.ts | 4 +- packages/tui/src/output/data-table.ts | 32 +- packages/tui/src/output/data-table/rows.ts | 2 +- packages/tui/src/prompt.ts | 4 + packages/tui/src/prompt/fallback.ts | 45 + packages/tui/src/prompt/lifecycle.ts | 16 +- packages/tui/src/prompt/validate-using.ts | 17 + packages/tui/src/prompt/validation.ts | 7 +- packages/tui/src/prompts/number.ts | 67 +- packages/tui/src/prompts/number/input.ts | 16 +- packages/tui/src/prompts/number/render.ts | 19 +- packages/tui/src/prompts/password.ts | 47 +- packages/tui/src/prompts/password/input.ts | 9 +- packages/tui/src/prompts/password/render.ts | 13 +- packages/tui/src/prompts/password/types.ts | 1 + packages/tui/src/prompts/pause.ts | 6 +- packages/tui/src/prompts/search/index.ts | 91 +- packages/tui/src/prompts/search/read-multi.ts | 13 +- .../src/prompts/search/read-multi/result.ts | 1 + .../tui/src/prompts/search/read-single.ts | 21 +- .../src/prompts/search/read-single/result.ts | 1 + packages/tui/src/prompts/search/render.ts | 24 +- packages/tui/src/prompts/select/confirm.ts | 28 +- packages/tui/src/prompts/select/index.ts | 83 +- .../tui/src/prompts/select/read-multiple.ts | 21 +- .../tui/src/prompts/select/read-selected.ts | 12 +- packages/tui/src/prompts/select/render.ts | 14 +- .../tui/src/prompts/suggest/ghost-text.ts | 8 +- packages/tui/src/prompts/suggest/index.ts | 87 +- .../src/prompts/suggest/read-autocomplete.ts | 19 +- packages/tui/src/prompts/suggest/read.ts | 18 +- .../prompts/suggest/render-autocomplete.ts | 10 +- packages/tui/src/prompts/suggest/render.ts | 21 +- packages/tui/src/prompts/text.ts | 53 +- packages/tui/src/prompts/textarea.ts | 55 +- packages/tui/src/status/progress.ts | 11 +- packages/tui/src/status/signals.ts | 57 + packages/tui/src/status/spinner.ts | 10 +- packages/tui/src/status/stream.ts | 6 + packages/tui/src/status/task.ts | 8 + packages/tui/src/theme.ts | 24 +- packages/tui/src/theme/box.ts | 6 +- packages/tui/src/typed-value.ts | 11 +- packages/tui/src/typed-value/cursor.ts | 16 + packages/tui/src/typed-value/lines.ts | 27 + packages/tui/src/typed-value/render.ts | 20 +- .../tui/src/typed-value/textarea-frame.ts | 12 +- packages/tui/tsconfig.build.json | 2 +- pnpm-lock.yaml | 2460 ++++++++++++++++- pnpm-workspace.yaml | 7 +- provision/.gitignore | 1 + {console/shell => provision/go}/.gitignore | 0 {console/shell => provision/go}/exports.go | 0 .../shell => provision/go}/exports_test.go | 0 {console/shell => provision/go}/go.mod | 0 {console/shell => provision/go}/go.sum | 0 .../go}/internal/environment/environment.go | 0 .../internal/environment/environment_test.go | 0 .../go}/internal/environment/errors.go | 0 .../go}/internal/environment/memory_output.go | 0 .../internal/environment/scripted_input.go | 0 .../go}/internal/environment/stdio.go | 0 .../go}/internal/environment/stdio_test.go | 0 .../go}/internal/form/builder.go | 0 .../go}/internal/form/form_test.go | 0 .../go}/internal/form/output.go | 0 .../go}/internal/form/previous.go | 0 .../go}/internal/form/prompts.go | 0 .../go}/internal/form/responses.go | 0 .../go}/internal/form/revert.go | 0 .../go}/internal/form/status.go | 0 .../go}/internal/key/key.go | 0 .../go}/internal/key/key_test.go | 0 .../go}/internal/output/datatable.go | 0 .../go}/internal/output/notes.go | 0 .../go}/internal/output/notify.go | 0 .../go}/internal/output/output_test.go | 0 .../go}/internal/output/table.go | 0 .../go}/internal/output/terminal.go | 0 .../go}/internal/prompt/lifecycle.go | 0 .../go}/internal/prompt/validation.go | 0 .../go}/internal/prompts/basic_test.go | 0 .../go}/internal/prompts/choices.go | 0 .../go}/internal/prompts/choices_test.go | 0 .../go}/internal/prompts/number.go | 0 .../go}/internal/prompts/render.go | 0 .../go}/internal/prompts/search_suggest.go | 0 .../go}/internal/prompts/text.go | 0 .../go}/internal/status/frame.go | 0 .../go}/internal/status/progress.go | 0 .../go}/internal/status/spinner.go | 0 .../go}/internal/status/status_test.go | 0 .../go}/internal/status/stream.go | 0 .../go}/internal/status/task.go | 0 .../go}/internal/strings/strings.go | 0 .../go}/internal/strings/strings_test.go | 0 .../go}/internal/terminal/terminal.go | 0 .../go}/internal/terminal/terminal_test.go | 0 .../go}/internal/theme/box.go | 0 .../go}/internal/theme/styles.go | 0 .../go}/internal/theme/theme.go | 0 .../go}/internal/theme/theme_test.go | 0 .../go}/internal/typedvalue/lines.go | 0 .../go}/internal/typedvalue/typedvalue.go | 0 .../internal/typedvalue/typedvalue_test.go | 0 provision/go/package.json | 10 + .../shell => provision/go}/portal/config.go | 0 .../shell => provision/go}/portal/main.go | 0 .../go}/portal/main_test.go | 0 provision/ts/package.json | 14 + provision/ts/src/cache.ts | 14 + provision/ts/src/index.ts | 1 + provision/ts/tsconfig.json | 11 + turbo.json | 10 +- 131 files changed, 3242 insertions(+), 481 deletions(-) create mode 100644 .npmrc delete mode 100644 console/.gitignore delete mode 100644 console/shell/package.json create mode 100644 packages/tui/src/prompt/fallback.ts create mode 100644 packages/tui/src/prompt/validate-using.ts create mode 100644 packages/tui/src/status/signals.ts create mode 100644 packages/tui/src/typed-value/cursor.ts create mode 100644 provision/.gitignore rename {console/shell => provision/go}/.gitignore (100%) rename {console/shell => provision/go}/exports.go (100%) rename {console/shell => provision/go}/exports_test.go (100%) rename {console/shell => provision/go}/go.mod (100%) rename {console/shell => provision/go}/go.sum (100%) rename {console/shell => provision/go}/internal/environment/environment.go (100%) rename {console/shell => provision/go}/internal/environment/environment_test.go (100%) rename {console/shell => provision/go}/internal/environment/errors.go (100%) rename {console/shell => provision/go}/internal/environment/memory_output.go (100%) rename {console/shell => provision/go}/internal/environment/scripted_input.go (100%) rename {console/shell => provision/go}/internal/environment/stdio.go (100%) rename {console/shell => provision/go}/internal/environment/stdio_test.go (100%) rename {console/shell => provision/go}/internal/form/builder.go (100%) rename {console/shell => provision/go}/internal/form/form_test.go (100%) rename {console/shell => provision/go}/internal/form/output.go (100%) rename {console/shell => provision/go}/internal/form/previous.go (100%) rename {console/shell => provision/go}/internal/form/prompts.go (100%) rename {console/shell => provision/go}/internal/form/responses.go (100%) rename {console/shell => provision/go}/internal/form/revert.go (100%) rename {console/shell => provision/go}/internal/form/status.go (100%) rename {console/shell => provision/go}/internal/key/key.go (100%) rename {console/shell => provision/go}/internal/key/key_test.go (100%) rename {console/shell => provision/go}/internal/output/datatable.go (100%) rename {console/shell => provision/go}/internal/output/notes.go (100%) rename {console/shell => provision/go}/internal/output/notify.go (100%) rename {console/shell => provision/go}/internal/output/output_test.go (100%) rename {console/shell => provision/go}/internal/output/table.go (100%) rename {console/shell => provision/go}/internal/output/terminal.go (100%) rename {console/shell => provision/go}/internal/prompt/lifecycle.go (100%) rename {console/shell => provision/go}/internal/prompt/validation.go (100%) rename {console/shell => provision/go}/internal/prompts/basic_test.go (100%) rename {console/shell => provision/go}/internal/prompts/choices.go (100%) rename {console/shell => provision/go}/internal/prompts/choices_test.go (100%) rename {console/shell => provision/go}/internal/prompts/number.go (100%) rename {console/shell => provision/go}/internal/prompts/render.go (100%) rename {console/shell => provision/go}/internal/prompts/search_suggest.go (100%) rename {console/shell => provision/go}/internal/prompts/text.go (100%) rename {console/shell => provision/go}/internal/status/frame.go (100%) rename {console/shell => provision/go}/internal/status/progress.go (100%) rename {console/shell => provision/go}/internal/status/spinner.go (100%) rename {console/shell => provision/go}/internal/status/status_test.go (100%) rename {console/shell => provision/go}/internal/status/stream.go (100%) rename {console/shell => provision/go}/internal/status/task.go (100%) rename {console/shell => provision/go}/internal/strings/strings.go (100%) rename {console/shell => provision/go}/internal/strings/strings_test.go (100%) rename {console/shell => provision/go}/internal/terminal/terminal.go (100%) rename {console/shell => provision/go}/internal/terminal/terminal_test.go (100%) rename {console/shell => provision/go}/internal/theme/box.go (100%) rename {console/shell => provision/go}/internal/theme/styles.go (100%) rename {console/shell => provision/go}/internal/theme/theme.go (100%) rename {console/shell => provision/go}/internal/theme/theme_test.go (100%) rename {console/shell => provision/go}/internal/typedvalue/lines.go (100%) rename {console/shell => provision/go}/internal/typedvalue/typedvalue.go (100%) rename {console/shell => provision/go}/internal/typedvalue/typedvalue_test.go (100%) create mode 100644 provision/go/package.json rename {console/shell => provision/go}/portal/config.go (100%) rename {console/shell => provision/go}/portal/main.go (100%) rename {console/shell => provision/go}/portal/main_test.go (100%) create mode 100644 provision/ts/package.json create mode 100644 provision/ts/src/cache.ts create mode 100644 provision/ts/src/index.ts create mode 100644 provision/ts/tsconfig.json diff --git a/.gitignore b/.gitignore index 584c0313..989f5fe0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules/ .turbo/ +provision/.cache/ .DS_Store coverage/ *.tsbuildinfo @@ -7,3 +8,6 @@ packages/artefacts/.cache/ packages/artefacts/.logs/ packages/artefacts/dist/ packages/tui/dist/ +packages/docs/src/.vitepress/cache/ +packages/docs/src/.vitepress/dist/ +packages/docs/src/api/ diff --git a/.npmrc b/.npmrc new file mode 100644 index 00000000..94c622a1 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +store-dir=provision/.cache/pnpm/store diff --git a/Makefile b/Makefile index 5b32f14e..d5e20598 100644 --- a/Makefile +++ b/Makefile @@ -48,7 +48,7 @@ format-all: ## Format + lint every non-ignored TS/Vue file in the repo via oxfmt fresh: ## Clean generated state, reinstall dependencies, then run local gates $(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)/.turbo" "$(ROOT_PATH)/packages/artefacts/.cache" "$(ROOT_PATH)/packages/artefacts/.logs" "$(ROOT_PATH)/packages/artefacts/dist" "$(ROOT_PATH)/packages/tui/dist" + rm -rf "$(ROOT_PATH)/node_modules" "$(ROOT_PATH)"/packages/*/node_modules "$(ROOT_PATH)/provision/.cache" "$(ROOT_PATH)/packages/artefacts/.logs" "$(ROOT_PATH)/packages/artefacts/dist" "$(ROOT_PATH)/packages/tui/dist" $(call step,Removing TypeScript build info files) find "$(ROOT_PATH)" -name '*.tsbuildinfo' -type f -delete $(call step,Installing dependencies) diff --git a/UPSTREAM.md b/UPSTREAM.md index 450495e7..60f3d9d5 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -13,20 +13,24 @@ TypeScript and OpenTUI internals. | --- | --- | | 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 and predicate condition lifecycle | `packages/tui/src/prompt/fallback.ts`, `packages/tui/src/prompt.ts`, `packages/tui/src/index.ts` | | Prompt cancellation callback lifecycle | `packages/tui/src/prompt/cancel.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 | `packages/tui/src/prompt/lifecycle.ts`, `packages/tui/tests/text.test.ts` | | Prompt post-validation submitted-frame lifecycle | `packages/tui/src/prompt/lifecycle.ts`, `packages/tui/src/prompts/text.ts`, `packages/tui/src/prompts/number.ts`, `packages/tui/tests/text.test.ts`, `packages/tui/tests/number.test.ts` | -| Prompt non-interactive transform lifecycle before validation | `packages/tui/src/prompt/lifecycle.ts`, `packages/tui/tests/text.test.ts`, `packages/tui/tests/number.test.ts`, `packages/tui/tests/choices.test.ts`, `packages/tui/tests/search.test.ts` | +| Prompt non-interactive raw-default validation lifecycle | `packages/tui/src/prompt/lifecycle.ts` | | 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` | | Key constants 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/prompt/lifecycle.ts`, `packages/tui/src/typed-value.ts`, `packages/tui/tests/text.test.ts` | | Text active, submitted, cancelled, placeholder, and validation-safe rendering | `packages/tui/src/prompts/text.ts`, `packages/tui/src/typed-value.ts`, `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` | | 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/tests/textarea.test.ts` | | Textarea active, submitted, cancelled, scrollbar, placeholder, and validation-safe rendering | `packages/tui/src/prompts/textarea.ts`, `packages/tui/src/typed-value.ts`, `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/textarea-frame.ts`, `packages/tui/src/prompts/textarea.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/tests/textarea.test.ts` | @@ -35,8 +39,11 @@ TypeScript and OpenTUI internals. | 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/input.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/prompts/password.ts`, `packages/tui/src/prompts/password/input.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/input.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/prompts/number.ts`, `packages/tui/src/prompts/number/input.ts`, `packages/tui/src/prompts/number/render.ts`, `packages/tui/tests/number.test.ts` | +| Number default renderer right-aligned arrows | `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/render.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/render-confirm.ts`, `packages/tui/tests/choices.test.ts` | | Confirm submitted frame renders only after validation passes | `packages/tui/src/prompts/select/confirm.ts`, `packages/tui/src/prompts/select/read-confirm.ts`, `packages/tui/tests/choices.test.ts` | @@ -47,19 +54,24 @@ TypeScript and OpenTUI internals. | Select line-mode and exhausted-input default fallback with validation retry | `packages/tui/src/prompts/select/index.ts`, `packages/tui/src/prompts/select/read-selected.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/tests/choices.test.ts` | | Select submitted frame renders only after validation passes | `packages/tui/src/prompts/select/index.ts`, `packages/tui/src/prompts/select/read-selected.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/read-selected.ts`, `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/tests/choices.test.ts` | | Multi-select active boxed layout and row styling | `packages/tui/src/prompts/select/render.ts`, `packages/tui/tests/choices.test.ts` | | Multi-select submitted final frame | `packages/tui/src/prompts/select/render.ts`, `packages/tui/src/prompts/select/read-multiple.ts`, `packages/tui/tests/choices.test.ts` | | Multi-select submitted frame renders only after validation passes | `packages/tui/src/prompts/select/index.ts`, `packages/tui/src/prompts/select/read-multiple.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/read-multiple.ts`, `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/tests/choices.test.ts` | | Suggest, autocomplete | `packages/tui/src/prompts/suggest/*` | | Suggest active, submitted, cancelled, placeholder, info, scrolling, and validation-safe rendering | `packages/tui/src/prompts/suggest/index.ts`, `packages/tui/src/prompts/suggest/read.ts`, `packages/tui/src/prompts/suggest/render.ts`, `packages/tui/tests/suggest.test.ts` | +| Suggest active-frame redraw and cursor rendering | `packages/tui/src/prompts/suggest/index.ts`, `packages/tui/src/prompts/suggest/read.ts`, `packages/tui/src/prompts/suggest/render.ts` | | Suggest and autocomplete label-first info rendering | `packages/tui/src/prompts/suggest/index.ts`, `packages/tui/src/prompts/suggest/options.ts`, `packages/tui/src/prompts/suggest/read-autocomplete.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/prompts/suggest/index.ts`, `packages/tui/src/prompts/suggest/read-autocomplete.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/index.ts`, `packages/tui/src/prompts/suggest/read-autocomplete.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/prompts/search/render.ts`, `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/read-single.ts`, `packages/tui/src/prompts/search/render.ts` | | Search and multisearch empty-result rendering | `packages/tui/src/prompts/search/render.ts`, `packages/tui/tests/search.test.ts` | | Search highlighted-result row styling | `packages/tui/src/prompts/search/render.ts`, `packages/tui/src/theme/styles.ts`, `packages/tui/tests/search.test.ts` | | Search submitted final frame | `packages/tui/src/prompts/search/render.ts`, `packages/tui/src/prompts/search/read-single.ts`, `packages/tui/tests/search.test.ts` | @@ -68,6 +80,7 @@ TypeScript and OpenTUI internals. | Multi-search active boxed layout and selected-count footer | `packages/tui/src/prompts/search/render.ts`, `packages/tui/tests/search.test.ts` | | Multi-search submitted final frame | `packages/tui/src/prompts/search/render.ts`, `packages/tui/src/prompts/search/read-multi.ts`, `packages/tui/tests/search.test.ts` | | Multi-search submitted frame renders only after validation passes | `packages/tui/src/prompts/search/index.ts`, `packages/tui/src/prompts/search/read-multi.ts`, `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/read-multi.ts`, `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/tests/search.test.ts` | | Pause | `packages/tui/src/prompts/pause.ts` | | Pause themed key-driven frame | `packages/tui/src/prompts/pause.ts`, `packages/tui/src/prompts/pause/render.ts`, `packages/tui/tests/pause.test.ts` | @@ -131,26 +144,33 @@ TypeScript and OpenTUI internals. | Stream frame rendering, wrapping, fade styles, and close flushing | `packages/tui/src/status/stream.ts`, `packages/tui/src/status/stream/*` | | Form builder root-aligned manual stream overload | `packages/tui/src/form/builder/status.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.ts`, `packages/tui/src/status/frame.ts` | | Progress signal cleanup | `packages/tui/src/status/progress.ts`, `packages/tui/tests/progress.test.ts` | +| Progress prompt-disable and manual advance semantics | `packages/tui/src/status/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` | | 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/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/tests/strings.test.ts` | | ANSI truncation style cleanup and visible-width markers | `packages/tui/src/strings.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/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 and type-consumption surface | `packages/tui/src/index.ts`, `packages/tui/src/form/builder/index.ts`, `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/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.ts` | | Table row cell normalization validation layer | `packages/tui/src/output/validators/table.ts`, `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/choices.ts`, `packages/tui/tests/choices.test.ts` | | Required value validation layer | `packages/tui/src/validators/required.ts`, `packages/tui/tests/prompt.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/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` | +| Boxed default table rendering | `packages/tui/src/theme.ts`, `packages/tui/src/output/table.ts`, `packages/tui/src/output/data-table/render.ts` | ## Remaining Exact-Parity Work diff --git a/console/.gitignore b/console/.gitignore deleted file mode 100644 index e660fd93..00000000 --- a/console/.gitignore +++ /dev/null @@ -1 +0,0 @@ -bin/ diff --git a/console/shell/package.json b/console/shell/package.json deleted file mode 100644 index ab6212f2..00000000 --- a/console/shell/package.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "@ollin/shell", - "version": "0.1.0", - "private": true, - "scripts": { - "build": "go build -o ../bin/shell ./portal", - "test": "go test ./...", - "typecheck": "go test ./... -run '^$'" - } -} diff --git a/package.json b/package.json index 12299eda..db47d0bd 100644 --- a/package.json +++ b/package.json @@ -4,12 +4,12 @@ "private": true, "type": "module", "scripts": { - "build": "turbo build", + "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", "dev": "make dev", "format": "make format", "format-all": "make format-all", - "test": "turbo test", - "typecheck": "turbo typecheck" + "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", + "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" }, "devDependencies": { "@ollin/artefacts": "workspace:*", diff --git a/packages/artefacts/src/index.ts b/packages/artefacts/src/index.ts index 479bc05a..c838b1e5 100644 --- a/packages/artefacts/src/index.ts +++ b/packages/artefacts/src/index.ts @@ -3,9 +3,13 @@ import { fileURLToPath } from 'node:url'; export const artefactsRoot = resolve(fileURLToPath(new URL('..', import.meta.url))); +export const workspaceRoot = resolve(artefactsRoot, '../..'); + +export const provisionRoot = resolve(workspaceRoot, 'provision'); + export const artefactsPath = (...segments: string[]): string => resolve(artefactsRoot, ...segments); -export const cacheDir = (...segments: string[]): string => artefactsPath('.cache', ...segments); +export const cacheDir = (...segments: string[]): string => resolve(provisionRoot, '.cache', ...segments); export const distDir = (...segments: string[]): string => artefactsPath('dist', ...segments); diff --git a/packages/artefacts/tsconfig.json b/packages/artefacts/tsconfig.json index 1d7ebf18..7519ca98 100644 --- a/packages/artefacts/tsconfig.json +++ b/packages/artefacts/tsconfig.json @@ -2,7 +2,7 @@ "extends": "@ollin/tsconfig/base.json", "compilerOptions": { "baseUrl": ".", - "tsBuildInfoFile": ".cache/tsbuild/artefacts.tsbuildinfo", + "tsBuildInfoFile": "../../provision/.cache/tsbuild/artefacts.tsbuildinfo", "paths": { "#artefacts/*": ["./src/*"] } diff --git a/packages/artefacts/tsconfig/acceptance.json b/packages/artefacts/tsconfig/acceptance.json index fe189ce5..7de753e2 100644 --- a/packages/artefacts/tsconfig/acceptance.json +++ b/packages/artefacts/tsconfig/acceptance.json @@ -1,5 +1,5 @@ { "compilerOptions": { - "tsBuildInfoFile": "../.cache/tsbuild/acceptance.tsbuildinfo" + "tsBuildInfoFile": "../../../provision/.cache/tsbuild/acceptance.tsbuildinfo" } } diff --git a/packages/artefacts/tsconfig/tui.json b/packages/artefacts/tsconfig/tui.json index 70bc18e1..058cde9e 100644 --- a/packages/artefacts/tsconfig/tui.json +++ b/packages/artefacts/tsconfig/tui.json @@ -1,5 +1,5 @@ { "compilerOptions": { - "tsBuildInfoFile": "../.cache/tsbuild/tui.tsbuildinfo" + "tsBuildInfoFile": "../../../provision/.cache/tsbuild/tui.tsbuildinfo" } } diff --git a/packages/docs/src/.vitepress/theme/components/TerminalOutput.vue b/packages/docs/src/.vitepress/theme/components/TerminalOutput.vue index ec1558b1..87dc66ef 100644 --- a/packages/docs/src/.vitepress/theme/components/TerminalOutput.vue +++ b/packages/docs/src/.vitepress/theme/components/TerminalOutput.vue @@ -33,6 +33,7 @@ const props = withDefaults( ); const viewport = ref(null); + let terminal: Terminal | null = null; let observer: IntersectionObserver | null = null; let timers: ReturnType[] = []; @@ -230,21 +231,9 @@ watch(output, () => {