diff --git a/.changeset/reorganize-ui-components.md b/.changeset/reorganize-ui-components.md new file mode 100644 index 00000000..f5ee9ec5 --- /dev/null +++ b/.changeset/reorganize-ui-components.md @@ -0,0 +1,5 @@ +--- +'@kidd-cli/core': minor +--- + +Reorganize UI components into prompts, display, and layout modules. Add new prompt components (Autocomplete, GroupMultiSelect, PathInput, SelectKey), display components (Alert, ProgressBar, Spinner, StatusMessage), and extract screen module with provider and context. diff --git a/codspeed.yml b/codspeed.yml index 5cacd739..599ab4d7 100644 --- a/codspeed.yml +++ b/codspeed.yml @@ -1,6 +1,6 @@ benchmarks: - - name: "icons --help" + - name: 'icons --help' exec: node examples/icons/dist/index.mjs --help - - name: "icons status" + - name: 'icons status' exec: node examples/icons/dist/index.mjs status diff --git a/examples/tui/package.json b/examples/tui/package.json index 5147ffdd..4bff35ad 100644 --- a/examples/tui/package.json +++ b/examples/tui/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "kidd dev", "build": "kidd build", + "stories": "kidd stories --cwd ../../packages/core", "typecheck": "tsgo --noEmit" }, "dependencies": { diff --git a/package.json b/package.json index 5a2659dc..0cdbd187 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "lint:fix": "oxlint --fix --ignore-pattern node_modules", "build": "turbo run build --filter='./packages/*'", "dev": "turbo run dev", + "stories": "pnpm build && kidd stories --cwd packages/core", "test": "turbo run test test:integration", "test:integration": "vitest run --config vitest.exports.config.ts", "test:coverage": "turbo run test:coverage --filter='./packages/*'", @@ -33,6 +34,7 @@ "devDependencies": { "@changesets/cli": "^2.30.0", "@clack/prompts": "^1.1.0", + "@kidd-cli/cli": "workspace:*", "@types/node": "catalog:", "@typescript/native-preview": "7.0.0-dev.20260323.1", "@vitest/coverage-v8": "catalog:", diff --git a/packages/core/src/context/prompts.ts b/packages/core/src/context/prompts.ts index f8ea7da7..822492b8 100644 --- a/packages/core/src/context/prompts.ts +++ b/packages/core/src/context/prompts.ts @@ -1,9 +1,3 @@ -/** - * Factory for creating the interactive prompt methods on the context. - * - * @module - */ - import type { Readable, Writable } from 'node:stream' import * as clack from '@clack/prompts' diff --git a/packages/core/src/context/resolve-defaults.ts b/packages/core/src/context/resolve-defaults.ts index 3d0ce90d..d0caf773 100644 --- a/packages/core/src/context/resolve-defaults.ts +++ b/packages/core/src/context/resolve-defaults.ts @@ -1,9 +1,3 @@ -/** - * Shared utilities for resolving clack per-call defaults from display config. - * - * @module - */ - import type { Readable, Writable } from 'node:stream' // --------------------------------------------------------------------------- diff --git a/packages/core/src/context/status.ts b/packages/core/src/context/status.ts index e25f6647..a01e35f7 100644 --- a/packages/core/src/context/status.ts +++ b/packages/core/src/context/status.ts @@ -1,9 +1,3 @@ -/** - * Factory for creating the {@link Status} indicator methods on the context. - * - * @module - */ - import type { Writable } from 'node:stream' import * as clack from '@clack/prompts' diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 36d1d45b..a280733e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -5,6 +5,8 @@ export { autoload } from './autoload.js' export { decorateContext } from './context/decorate.js' export { middleware } from './middleware.js' export { defineConfig } from '@kidd-cli/config' +export { screen, useScreenContext } from './screen/index.js' +export type { ScreenDef, ScreenExit } from './screen/index.js' export type { CliConfig, Command, @@ -29,6 +31,7 @@ export type { ProgressBar, ProgressOptions, Prompts, + ScreenContext, SelectOptions, Spinner, Status, diff --git a/packages/core/src/lib/log.ts b/packages/core/src/lib/log.ts index adc7f191..13d0b68b 100644 --- a/packages/core/src/lib/log.ts +++ b/packages/core/src/lib/log.ts @@ -1,9 +1,3 @@ -/** - * Factory for creating a {@link Log} instance backed by `@clack/prompts`. - * - * @module - */ - import type { Writable } from 'node:stream' import * as clack from '@clack/prompts' diff --git a/packages/core/src/middleware/auth/oauth-server.ts b/packages/core/src/middleware/auth/oauth-server.ts index 06228e70..85471c54 100644 --- a/packages/core/src/middleware/auth/oauth-server.ts +++ b/packages/core/src/middleware/auth/oauth-server.ts @@ -1,12 +1,3 @@ -/** - * Shared utilities for OAuth-based auth resolvers. - * - * Extracted from the local HTTP server, browser-launch, and - * lifecycle patterns shared by the PKCE and device-code flows. - * - * @module - */ - import { execFile } from 'node:child_process' import { createServer } from 'node:http' import type { IncomingMessage, Server, ServerResponse } from 'node:http' diff --git a/packages/core/src/middleware/icons/definitions.ts b/packages/core/src/middleware/icons/definitions.ts index 2c6ce884..27e4d6d9 100644 --- a/packages/core/src/middleware/icons/definitions.ts +++ b/packages/core/src/middleware/icons/definitions.ts @@ -1,12 +1,3 @@ -/** - * Predefined icon definitions organized by category. - * - * Each icon has a Nerd Font glyph and an emoji fallback. The middleware - * resolves to one or the other based on font detection. - * - * @module - */ - import { match } from 'ts-pattern' import type { IconCategory, IconDefinition } from './types.js' diff --git a/packages/core/src/middleware/icons/icons.ts b/packages/core/src/middleware/icons/icons.ts index fbe15fa6..03486881 100644 --- a/packages/core/src/middleware/icons/icons.ts +++ b/packages/core/src/middleware/icons/icons.ts @@ -1,12 +1,3 @@ -/** - * Icons middleware factory. - * - * Detects Nerd Font availability, optionally prompts for installation, - * and decorates `ctx.icons` with an icon resolver. - * - * @module - */ - import { decorateContext } from '@/context/decorate.js' import { middleware } from '@/middleware.js' import type { Middleware } from '@/types/index.js' diff --git a/packages/core/src/middleware/report/report.ts b/packages/core/src/middleware/report/report.ts index f6957a05..f112b985 100644 --- a/packages/core/src/middleware/report/report.ts +++ b/packages/core/src/middleware/report/report.ts @@ -1,12 +1,3 @@ -/** - * Report middleware factory. - * - * Decorates `ctx.report` with a structured reporting API for checks, - * findings, and summaries backed by format functions. - * - * @module - */ - import { decorateContext } from '@/context/decorate.js' import { formatCheck } from '@/lib/format/check.js' import { formatFinding } from '@/lib/format/finding.js' diff --git a/packages/core/src/middleware/report/types.ts b/packages/core/src/middleware/report/types.ts index 3dc72c91..9fc677e0 100644 --- a/packages/core/src/middleware/report/types.ts +++ b/packages/core/src/middleware/report/types.ts @@ -1,9 +1,3 @@ -/** - * Types for the report middleware. - * - * @module - */ - import type { CheckInput, FindingInput, SummaryInput } from '@/lib/format/types.js' // --------------------------------------------------------------------------- diff --git a/packages/core/src/screen/index.ts b/packages/core/src/screen/index.ts new file mode 100644 index 00000000..7469f4ee --- /dev/null +++ b/packages/core/src/screen/index.ts @@ -0,0 +1,4 @@ +export { screen } from './screen.js' +export type { ScreenDef, ScreenExit } from './screen.js' + +export { useScreenContext } from './provider.js' diff --git a/packages/core/src/ui/output/index.ts b/packages/core/src/screen/output/index.ts similarity index 72% rename from packages/core/src/ui/output/index.ts rename to packages/core/src/screen/output/index.ts index 3795fc91..e33807cf 100644 --- a/packages/core/src/ui/output/index.ts +++ b/packages/core/src/screen/output/index.ts @@ -1,12 +1,3 @@ -/** - * Screen output system for rendering `ctx.log`, `ctx.status.spinner`, and - * `ctx.report` inside React/Ink screen components. - * - * @module - */ - -export { Output } from './output.js' - export { useOutputStore } from './use-output-store.js' export { createOutputStore } from './store.js' diff --git a/packages/core/src/ui/output/screen-log.ts b/packages/core/src/screen/output/screen-log.ts similarity index 95% rename from packages/core/src/ui/output/screen-log.ts rename to packages/core/src/screen/output/screen-log.ts index c2295803..842bf839 100644 --- a/packages/core/src/ui/output/screen-log.ts +++ b/packages/core/src/screen/output/screen-log.ts @@ -1,10 +1,3 @@ -/** - * Screen-backed {@link Log} implementation that pushes entries - * to an {@link OutputStore} instead of writing to stderr. - * - * @module - */ - import { match } from 'ts-pattern' import type { Log, LogMessageOptions, NoteOptions, StreamLog } from '@/context/types.js' diff --git a/packages/core/src/ui/output/screen-report.ts b/packages/core/src/screen/output/screen-report.ts similarity index 86% rename from packages/core/src/ui/output/screen-report.ts rename to packages/core/src/screen/output/screen-report.ts index 7966bdff..7a2f7d07 100644 --- a/packages/core/src/ui/output/screen-report.ts +++ b/packages/core/src/screen/output/screen-report.ts @@ -1,10 +1,3 @@ -/** - * Screen-backed {@link Report} implementation that pushes entries - * to an {@link OutputStore} instead of writing to stderr. - * - * @module - */ - import type { CheckInput, FindingInput, SummaryInput } from '@/lib/format/types.js' import type { Report } from '@/middleware/report/types.js' diff --git a/packages/core/src/ui/output/screen-spinner.ts b/packages/core/src/screen/output/screen-spinner.ts similarity index 90% rename from packages/core/src/ui/output/screen-spinner.ts rename to packages/core/src/screen/output/screen-spinner.ts index 4fd60691..553ed4de 100644 --- a/packages/core/src/ui/output/screen-spinner.ts +++ b/packages/core/src/screen/output/screen-spinner.ts @@ -1,10 +1,3 @@ -/** - * Screen-backed {@link Spinner} implementation that updates - * spinner state in an {@link OutputStore} instead of writing to stderr. - * - * @module - */ - import type { Spinner } from '@/context/types.js' import type { OutputStore } from './types.js' diff --git a/packages/core/src/ui/output/store-key.ts b/packages/core/src/screen/output/store-key.ts similarity index 89% rename from packages/core/src/ui/output/store-key.ts rename to packages/core/src/screen/output/store-key.ts index 1257404e..5dafa8e9 100644 --- a/packages/core/src/ui/output/store-key.ts +++ b/packages/core/src/screen/output/store-key.ts @@ -1,10 +1,3 @@ -/** - * Utility functions for attaching and retrieving the {@link OutputStore} - * on a screen context object via a hidden Symbol key. - * - * @module - */ - import type { ScreenContext } from '../../context/types.js' import type { OutputStore } from './types.js' @@ -27,9 +20,13 @@ const OUTPUT_STORE_KEY: unique symbol = Symbol('kidd.outputStore') * Options for {@link injectOutputStore}. */ interface InjectOutputStoreOptions { - /** The screen context record to extend. */ + /** + * The screen context record to extend. + */ readonly ctx: Record - /** The output store to attach. */ + /** + * The output store to attach. + */ readonly store: OutputStore } diff --git a/packages/core/src/ui/output/store.ts b/packages/core/src/screen/output/store.ts similarity index 90% rename from packages/core/src/ui/output/store.ts rename to packages/core/src/screen/output/store.ts index 53241ec9..7dbad25b 100644 --- a/packages/core/src/ui/output/store.ts +++ b/packages/core/src/screen/output/store.ts @@ -1,11 +1,3 @@ -/** - * External store for screen output entries and spinner state. - * - * Compatible with React's `useSyncExternalStore` for reactive rendering. - * - * @module - */ - import type { OutputEntry, OutputEntryInput, diff --git a/packages/core/src/ui/output/types.ts b/packages/core/src/screen/output/types.ts similarity index 98% rename from packages/core/src/ui/output/types.ts rename to packages/core/src/screen/output/types.ts index be700a06..d614080f 100644 --- a/packages/core/src/ui/output/types.ts +++ b/packages/core/src/screen/output/types.ts @@ -1,9 +1,3 @@ -/** - * Types for the screen output system. - * - * @module - */ - import type { CheckInput, FindingInput, SummaryInput } from '@/lib/format/types.js' // --------------------------------------------------------------------------- diff --git a/packages/core/src/ui/output/use-output-store.ts b/packages/core/src/screen/output/use-output-store.ts similarity index 89% rename from packages/core/src/ui/output/use-output-store.ts rename to packages/core/src/screen/output/use-output-store.ts index 9ae4276e..8914a23b 100644 --- a/packages/core/src/ui/output/use-output-store.ts +++ b/packages/core/src/screen/output/use-output-store.ts @@ -1,9 +1,3 @@ -/** - * Hook to access the {@link OutputStore} from the screen context. - * - * @module - */ - import { useScreenContext } from '../provider.js' import { getOutputStore } from './store-key.js' import type { OutputStore } from './types.js' diff --git a/packages/core/src/ui/provider.tsx b/packages/core/src/screen/provider.tsx similarity index 100% rename from packages/core/src/ui/provider.tsx rename to packages/core/src/screen/provider.tsx diff --git a/packages/core/src/ui/screen.test.ts b/packages/core/src/screen/screen.test.ts similarity index 100% rename from packages/core/src/ui/screen.test.ts rename to packages/core/src/screen/screen.test.ts diff --git a/packages/core/src/ui/screen.tsx b/packages/core/src/screen/screen.tsx similarity index 99% rename from packages/core/src/ui/screen.tsx rename to packages/core/src/screen/screen.tsx index 3a85c138..8d6db032 100644 --- a/packages/core/src/ui/screen.tsx +++ b/packages/core/src/screen/screen.tsx @@ -7,7 +7,7 @@ import { match } from 'ts-pattern' import type { CommandContext, ImperativeContextKeys, ScreenContext } from '../context/types.js' import type { ArgsDef, Command, InferArgsMerged, Resolvable } from '../types/index.js' -import { FullScreen, LEAVE_ALT_SCREEN } from './fullscreen.js' +import { FullScreen, LEAVE_ALT_SCREEN } from '../ui/layout/fullscreen.js' import { createScreenLog } from './output/screen-log.js' import { createScreenReport } from './output/screen-report.js' import { createScreenSpinner } from './output/screen-spinner.js' diff --git a/packages/core/src/stories/decorators.tsx b/packages/core/src/stories/decorators.tsx index c40d0985..b7e47ed1 100644 --- a/packages/core/src/stories/decorators.tsx +++ b/packages/core/src/stories/decorators.tsx @@ -3,8 +3,8 @@ import type { ComponentType, ReactElement } from 'react' import React from 'react' import type { ScreenContext } from '../context/types.js' -import { FullScreen } from '../ui/fullscreen.js' -import { KiddProvider } from '../ui/provider.js' +import { KiddProvider } from '../screen/provider.js' +import { FullScreen } from '../ui/layout/fullscreen.js' import type { Decorator } from './types.js' // --------------------------------------------------------------------------- diff --git a/packages/core/src/stories/importer.ts b/packages/core/src/stories/importer.ts index fde313f5..25a74eb7 100644 --- a/packages/core/src/stories/importer.ts +++ b/packages/core/src/stories/importer.ts @@ -1,3 +1,5 @@ +import Module from 'node:module' + import { toError } from '@kidd-cli/utils/fp' import { hasTag } from '@kidd-cli/utils/tag' import { createJiti } from 'jiti' @@ -14,9 +16,14 @@ export interface StoryImporter { /** * Create a story importer backed by jiti with cache disabled for hot reload. * + * Installs a TypeScript extension resolution hook so that ESM-style `.js` + * imports (e.g. `from './alert.js'`) resolve to `.ts` / `.tsx` source files. + * * @returns A frozen {@link StoryImporter} instance. */ export function createStoryImporter(): StoryImporter { + installTsExtensionResolution() + const jiti = createJiti(import.meta.url, { fsCache: false, moduleCache: false, @@ -44,6 +51,103 @@ export function createStoryImporter(): StoryImporter { // --------------------------------------------------------------------------- +/** + * TypeScript extensions to try when a `.js` import fails to resolve. + * + * @private + */ +const TS_EXTENSIONS: readonly string[] = ['.ts', '.tsx'] + +/** + * TypeScript extensions to try when a `.jsx` import fails to resolve. + * + * @private + */ +const TSX_EXTENSIONS: readonly string[] = ['.tsx', '.ts', '.jsx'] + +/** + * Name used to tag the patched `_resolveFilename` function so the + * patch can detect itself and remain idempotent. + * + * @private + */ +const PATCHED_FN_NAME = '__kidd_ts_resolve' + +/** + * Patch `Module._resolveFilename` so that ESM-style `.js` / `.jsx` specifiers + * fall back to their TypeScript equivalents (`.ts`, `.tsx`). + * + * This is the same strategy used by `tsx` and `ts-node`. The patch is + * idempotent — calling it more than once is a no-op. + * + * @private + */ +function installTsExtensionResolution(): void { + const mod = Module as unknown as Record + const current = mod._resolveFilename as (...args: unknown[]) => string + + if (current.name === PATCHED_FN_NAME) { + return + } + + const original = current + + const patched = { + [PATCHED_FN_NAME]: ( + request: string, + parent: unknown, + isMain: boolean, + options: unknown + ): string => { + try { + return original.call(mod, request, parent, isMain, options) + } catch (error) { + const alternates = resolveAlternateExtensions(request) + + const resolved = alternates.reduce((found, alt) => { + if (found) { + return found + } + try { + return original.call(mod, alt, parent, isMain, options) + } catch { + return null + } + }, null) + + if (resolved) { + return resolved + } + throw error + } + }, + } + + mod._resolveFilename = patched[PATCHED_FN_NAME] +} + +/** + * Build a list of alternate file paths to try when a `.js` or `.jsx` + * import fails to resolve. + * + * @private + * @param request - The original module specifier. + * @returns Alternate specifiers to try, or an empty array if not applicable. + */ +function resolveAlternateExtensions(request: string): readonly string[] { + if (request.endsWith('.js')) { + const base = request.slice(0, -3) + return [...TS_EXTENSIONS.map((ext) => base + ext), base] + } + + if (request.endsWith('.jsx')) { + const base = request.slice(0, -4) + return TSX_EXTENSIONS.map((ext) => base + ext) + } + + return [] +} + /** * Check whether a value is a valid {@link StoryEntry} (tagged as `Story` or `StoryGroup`). * diff --git a/packages/core/src/stories/index.ts b/packages/core/src/stories/index.ts index bc56a10d..13a0f8b9 100644 --- a/packages/core/src/stories/index.ts +++ b/packages/core/src/stories/index.ts @@ -1,12 +1,3 @@ -/** - * Storybook-like TUI component browser for kidd CLI screens. - * - * Define stories alongside components, then run `kidd stories` to - * preview and interactively edit props in the terminal. - * - * @module - */ - export { story, stories } from './story.js' export { withContext, withFullScreen, withLayout } from './decorators.js' diff --git a/packages/core/src/stories/viewer/components/field-control.tsx b/packages/core/src/stories/viewer/components/field-control.tsx index cb1b74d2..c5f1910d 100644 --- a/packages/core/src/stories/viewer/components/field-control.tsx +++ b/packages/core/src/stories/viewer/components/field-control.tsx @@ -1,12 +1,9 @@ import type { Option } from '@inkjs/ui' +import { ConfirmInput, MultiSelect, Select, TextInput } from '@inkjs/ui' import { Box, Text } from 'ink' import type { ReactElement } from 'react' import { match } from 'ts-pattern' -import { ConfirmInput } from '../../../ui/confirm.js' -import { MultiSelect } from '../../../ui/multi-select.js' -import { Select } from '../../../ui/select.js' -import { TextInput } from '../../../ui/text-input.js' import type { FieldControlKind } from '../../types.js' // --------------------------------------------------------------------------- diff --git a/packages/core/src/stories/viewer/components/preview.tsx b/packages/core/src/stories/viewer/components/preview.tsx index a200a7d1..4b2b57ae 100644 --- a/packages/core/src/stories/viewer/components/preview.tsx +++ b/packages/core/src/stories/viewer/components/preview.tsx @@ -5,8 +5,8 @@ import { useMemo, useRef } from 'react' import { match } from 'ts-pattern' import { InputBarrier } from '../../../ui/input-barrier.js' -import { ScrollArea } from '../../../ui/scroll-area.js' -import { useSize } from '../../../ui/use-size.js' +import { ScrollArea } from '../../../ui/layout/scroll-area.js' +import { useSize } from '../../../ui/layout/use-size.js' import type { FieldDescriptor, Story } from '../../types.js' import type { FieldError } from '../../validate.js' import { applyDecorators } from '../utils.js' diff --git a/packages/core/src/stories/viewer/components/props-editor.tsx b/packages/core/src/stories/viewer/components/props-editor.tsx index cea69840..d37238a6 100644 --- a/packages/core/src/stories/viewer/components/props-editor.tsx +++ b/packages/core/src/stories/viewer/components/props-editor.tsx @@ -2,8 +2,8 @@ import { Box, Text } from 'ink' import type { ReactElement } from 'react' import { match } from 'ts-pattern' -import type { TabItem } from '../../../ui/tabs.js' -import { Tabs } from '../../../ui/tabs.js' +import type { TabItem } from '../../../ui/layout/tabs.js' +import { Tabs } from '../../../ui/layout/tabs.js' import type { FieldControlKind, FieldDescriptor } from '../../types.js' import type { FieldError } from '../../validate.js' import { FieldControl } from './field-control.js' diff --git a/packages/core/src/stories/viewer/components/sidebar.tsx b/packages/core/src/stories/viewer/components/sidebar.tsx index 0ec04b6a..f939e804 100644 --- a/packages/core/src/stories/viewer/components/sidebar.tsx +++ b/packages/core/src/stories/viewer/components/sidebar.tsx @@ -5,8 +5,8 @@ import type { ReactElement } from 'react' import { useEffect, useMemo, useRef, useState } from 'react' import { match } from 'ts-pattern' -import { ScrollArea } from '../../../ui/scroll-area.js' -import { useSize } from '../../../ui/use-size.js' +import { ScrollArea } from '../../../ui/layout/scroll-area.js' +import { useSize } from '../../../ui/layout/use-size.js' import type { Story, StoryEntry, StoryGroup } from '../../types.js' // --------------------------------------------------------------------------- diff --git a/packages/core/src/stories/viewer/hooks/use-double-escape.ts b/packages/core/src/stories/viewer/hooks/use-double-escape.ts index 91038365..0abaa530 100644 --- a/packages/core/src/stories/viewer/hooks/use-double-escape.ts +++ b/packages/core/src/stories/viewer/hooks/use-double-escape.ts @@ -1,11 +1,3 @@ -/** - * Thin wrapper hook that listens for a double-press Escape sequence - * and invokes an exit callback. Used by the stories viewer to exit - * interactive mode without intercepting single Escape presses. - * - * @module - */ - import { useKeyBinding } from '../../../ui/use-key-binding.js' // --------------------------------------------------------------------------- diff --git a/packages/core/src/stories/viewer/stories-app.tsx b/packages/core/src/stories/viewer/stories-app.tsx index ec71f8d5..aba084c8 100644 --- a/packages/core/src/stories/viewer/stories-app.tsx +++ b/packages/core/src/stories/viewer/stories-app.tsx @@ -8,7 +8,7 @@ import type { ReactElement } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react' import { match } from 'ts-pattern' -import { FullScreen } from '../../ui/fullscreen.js' +import { FullScreen } from '../../ui/layout/fullscreen.js' import type { StoryRegistry } from '../registry.js' import { schemaToFieldDescriptors } from '../schema.js' import type { Story, StoryEntry, StoryGroup } from '../types.js' diff --git a/packages/core/src/stories/viewer/stories-check.tsx b/packages/core/src/stories/viewer/stories-check.tsx index a2c38617..814cb478 100644 --- a/packages/core/src/stories/viewer/stories-check.tsx +++ b/packages/core/src/stories/viewer/stories-check.tsx @@ -5,8 +5,8 @@ import type { ReactElement } from 'react' import { useEffect, useRef } from 'react' import { match } from 'ts-pattern' -import { Output } from '../../ui/output/index.js' -import { useScreenContext } from '../../ui/provider.js' +import { useScreenContext } from '../../screen/provider.js' +import { Output } from '../../ui/output.js' import { checkStories } from '../check.js' import { discoverStories } from '../discover.js' import { createStoryImporter } from '../importer.js' diff --git a/packages/core/src/stories/viewer/utils.ts b/packages/core/src/stories/viewer/utils.ts index 362eb0e8..098182d9 100644 --- a/packages/core/src/stories/viewer/utils.ts +++ b/packages/core/src/stories/viewer/utils.ts @@ -1,9 +1,3 @@ -/** - * Shared utilities for the stories viewer components. - * - * @module - */ - import type { ComponentType } from 'react' import type { Decorator } from '../types.js' diff --git a/packages/core/src/ui/confirm.tsx b/packages/core/src/ui/confirm.tsx deleted file mode 100644 index 476608aa..00000000 --- a/packages/core/src/ui/confirm.tsx +++ /dev/null @@ -1,11 +0,0 @@ -/** - * ConfirmInput UI component. - * - * A thin wrapper that re-exports the `ConfirmInput` component from `@inkjs/ui`. - * Renders a confirm/cancel prompt in the terminal, allowing the user to accept - * or reject an action. - * - * @module - */ -export { ConfirmInput } from '@inkjs/ui' -export type { ConfirmInputProps } from '@inkjs/ui' diff --git a/packages/core/src/ui/display/alert.stories.tsx b/packages/core/src/ui/display/alert.stories.tsx new file mode 100644 index 00000000..fe8ee23a --- /dev/null +++ b/packages/core/src/ui/display/alert.stories.tsx @@ -0,0 +1,56 @@ +import type { ComponentType } from 'react' +import { z } from 'zod' + +import { stories } from '../../stories/story.js' +import type { StoryGroup } from '../../stories/types.js' +import { Alert } from './alert.js' + +const schema = z.object({ + children: z.string().describe('The content to display inside the alert box'), + variant: z.enum(['info', 'success', 'error', 'warning']).describe('Alert variant'), + title: z.string().optional().describe('Title rendered in the top border'), + rounded: z.boolean().optional().describe('Use rounded border corners'), + contentAlign: z + .enum(['left', 'center', 'right']) + .optional() + .describe('Horizontal alignment of content'), + titleAlign: z + .enum(['left', 'center', 'right']) + .optional() + .describe('Horizontal alignment of title'), +}) + +const storyGroup: StoryGroup = stories({ + title: 'Alert', + component: Alert as unknown as ComponentType>, + schema, + defaults: {}, + stories: { + Info: { + props: { children: 'This is an informational message.', variant: 'info' }, + description: 'Info variant alert with blue border.', + }, + Success: { + props: { children: 'Operation completed successfully.', variant: 'success' }, + description: 'Success variant alert with green border.', + }, + Error: { + props: { children: 'Something went wrong.', variant: 'error' }, + description: 'Error variant alert with red border.', + }, + Warning: { + props: { children: 'Config file not found.', variant: 'warning' }, + description: 'Warning variant alert with yellow border.', + }, + WithTitle: { + props: { + children: 'Please check your configuration.', + variant: 'warning', + title: 'Warning', + }, + description: 'Alert with a title rendered in the top border.', + }, + }, +}) + +export default storyGroup diff --git a/packages/core/src/ui/display/alert.tsx b/packages/core/src/ui/display/alert.tsx new file mode 100644 index 00000000..6231d11a --- /dev/null +++ b/packages/core/src/ui/display/alert.tsx @@ -0,0 +1,277 @@ +import { Text } from 'ink' +import type { ReactElement } from 'react' +import { match } from 'ts-pattern' + +import type { Variant } from '../theme.js' +import { resolveVariantColor, symbols } from '../theme.js' + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** + * The visual variant of an alert, determining color and icon. + */ +export type AlertVariant = Variant + +/** + * Props for the {@link Alert} component. + */ +export interface AlertProps { + /** The text content to display inside the alert box. */ + readonly children: string + + /** The variant determines the border color and icon. */ + readonly variant: AlertVariant + + /** Optional title rendered in the top border. */ + readonly title?: string + + /** Box width. Defaults to `'auto'` which sizes to content. */ + readonly width?: number | 'auto' + + /** Use rounded border corners when `true`. */ + readonly rounded?: boolean + + /** Horizontal alignment of content lines. */ + readonly contentAlign?: 'left' | 'center' | 'right' + + /** Horizontal alignment of the title within the top border. */ + readonly titleAlign?: 'left' | 'center' | 'right' +} + +// --------------------------------------------------------------------------- +// Exports +// --------------------------------------------------------------------------- + +/** + * A bordered alert box with variant-colored borders and icon. + * + * Renders a box constructed from text characters with a colored border + * matching the alert variant. An icon (from the theme symbols) is prepended + * to the content. The title, when provided, is inset into the top border. + * + * @param props - The alert props. + * @returns A rendered alert element. + */ +export function Alert({ + children, + variant, + title, + width = 'auto', + rounded = true, + contentAlign = 'left', + titleAlign = 'left', +}: AlertProps): ReactElement { + const variantColor = resolveVariantColor(variant) + const icon = resolveVariantIcon(variant) + const border = resolveBorderChars(rounded) + const contentStr = `${icon} ${children}` + const innerWidth = resolveInnerWidth({ width, title, contentStr }) + const topLine = buildTopBorder({ border, title, innerWidth, titleAlign }) + const bottomLine = `${border.bottomLeft}${border.horizontal.repeat(innerWidth + 2)}${border.bottomRight}` + const contentLines = buildContentLines({ contentStr, innerWidth, border, contentAlign }) + + return {`${topLine}\n${contentLines}\n${bottomLine}`} +} + +// --------------------------------------------------------------------------- +// Private +// --------------------------------------------------------------------------- + +/** + * Border character set for box rendering. + * + * @private + */ +interface BorderChars { + readonly topLeft: string + readonly topRight: string + readonly bottomLeft: string + readonly bottomRight: string + readonly horizontal: string + readonly vertical: string +} + +/** + * Options for resolving the inner width of the alert box. + * + * @private + */ +interface InnerWidthOptions { + readonly width: number | 'auto' + readonly title: string | undefined + readonly contentStr: string +} + +/** + * Options for building the top border line. + * + * @private + */ +interface TopBorderOptions { + readonly border: BorderChars + readonly title: string | undefined + readonly innerWidth: number + readonly titleAlign: 'left' | 'center' | 'right' +} + +/** + * Options for building content lines. + * + * @private + */ +interface ContentLineOptions { + readonly contentStr: string + readonly innerWidth: number + readonly border: BorderChars + readonly contentAlign: 'left' | 'center' | 'right' +} + +/** + * Resolve the icon symbol for a given alert variant. + * + * @private + * @param variant - The alert variant. + * @returns The icon string. + */ +function resolveVariantIcon(variant: AlertVariant): string { + return match(variant) + .with('info', () => symbols.info) + .with('success', () => symbols.tick) + .with('error', () => symbols.cross) + .with('warning', () => symbols.warning) + .exhaustive() +} + +/** + * Resolve the border character set based on the rounded flag. + * + * @private + * @param rounded - Whether to use rounded corners. + * @returns The border character set. + */ +function resolveBorderChars(rounded: boolean): BorderChars { + return match(rounded) + .with(true, () => ({ + topLeft: '\u256D', + topRight: '\u256E', + bottomLeft: '\u2570', + bottomRight: '\u256F', + horizontal: '\u2500', + vertical: '\u2502', + })) + .with(false, () => ({ + topLeft: '\u250C', + topRight: '\u2510', + bottomLeft: '\u2514', + bottomRight: '\u2518', + horizontal: '\u2500', + vertical: '\u2502', + })) + .exhaustive() +} + +/** + * Compute the inner width of the alert box (excluding border and padding). + * + * @private + * @param options - The width resolution options. + * @returns The inner width in characters. + */ +function resolveInnerWidth({ width, title, contentStr }: InnerWidthOptions): number { + if (width !== 'auto') { + return Math.max(0, width - 4) + } + const maxLineWidth = contentStr.split('\n').reduce((max, line) => Math.max(max, line.length), 0) + const titleWidth = match(title) + .with(undefined, () => 0) + .otherwise((t) => t.length + 4) + return Math.max(maxLineWidth, titleWidth) +} + +/** + * Build the top border string with an optional inset title. + * + * @private + * @param options - The top border options. + * @returns The top border string. + */ +function buildTopBorder({ border, title, innerWidth, titleAlign }: TopBorderOptions): string { + if (!title) { + return `${border.topLeft}${border.horizontal.repeat(innerWidth + 2)}${border.topRight}` + } + + const titleSegment = ` ${title} ` + const remainingWidth = Math.max(0, innerWidth + 2 - titleSegment.length - 1) + + return match(titleAlign) + .with( + 'left', + () => + `${border.topLeft}${border.horizontal}${titleSegment}${border.horizontal.repeat(remainingWidth)}${border.topRight}` + ) + .with( + 'right', + () => + `${border.topLeft}${border.horizontal.repeat(remainingWidth)}${titleSegment}${border.horizontal}${border.topRight}` + ) + .with('center', () => { + const leftPad = Math.floor(remainingWidth / 2) + const rightPad = remainingWidth - leftPad + return `${border.topLeft}${border.horizontal.repeat(leftPad + 1)}${titleSegment}${border.horizontal.repeat(rightPad)}${border.topRight}` + }) + .exhaustive() +} + +/** + * Build the padded content lines for the alert body. + * + * @private + * @param options - The content line options. + * @returns The formatted content lines as a single string. + */ +function buildContentLines({ + contentStr, + innerWidth, + border, + contentAlign, +}: ContentLineOptions): string { + return contentStr + .split('\n') + .map((line) => alignLine({ line, innerWidth, border, contentAlign })) + .join('\n') +} + +/** + * Options for aligning a single content line. + * + * @private + */ +interface AlignLineOptions { + readonly line: string + readonly innerWidth: number + readonly border: BorderChars + readonly contentAlign: 'left' | 'center' | 'right' +} + +/** + * Align a single content line within the alert box. + * + * @private + * @param options - The line alignment options. + * @returns The aligned line with border characters. + */ +function alignLine({ line, innerWidth, border, contentAlign }: AlignLineOptions): string { + const padding = Math.max(0, innerWidth - line.length) + + return match(contentAlign) + .with('left', () => `${border.vertical} ${line}${' '.repeat(padding)} ${border.vertical}`) + .with('right', () => `${border.vertical} ${' '.repeat(padding)}${line} ${border.vertical}`) + .with('center', () => { + const leftPad = Math.floor(padding / 2) + const rightPad = padding - leftPad + return `${border.vertical} ${' '.repeat(leftPad)}${line}${' '.repeat(rightPad)} ${border.vertical}` + }) + .exhaustive() +} diff --git a/packages/core/src/ui/display/error-message.tsx b/packages/core/src/ui/display/error-message.tsx new file mode 100644 index 00000000..64115daf --- /dev/null +++ b/packages/core/src/ui/display/error-message.tsx @@ -0,0 +1,29 @@ +import { Text } from 'ink' +import type { ReactElement } from 'react' +import { match } from 'ts-pattern' + +import { colors } from '../theme.js' + +// --------------------------------------------------------------------------- +// Exports +// --------------------------------------------------------------------------- + +/** + * Props for the {@link ErrorMessage} component. + */ +export interface ErrorMessageProps { + /** The error message to display. When `undefined`, renders nothing. */ + readonly message: string | undefined +} + +/** + * Render a validation error message in red, or nothing when absent. + * + * @param props - The error message props. + * @returns A rendered error text element, or `null`. + */ +export function ErrorMessage({ message }: ErrorMessageProps): ReactElement | null { + return match(message) + .with(undefined, () => null) + .otherwise((msg) => {msg}) +} diff --git a/packages/core/src/ui/display/index.ts b/packages/core/src/ui/display/index.ts new file mode 100644 index 00000000..7c17248f --- /dev/null +++ b/packages/core/src/ui/display/index.ts @@ -0,0 +1,14 @@ +export { Alert } from './alert.js' +export type { AlertProps, AlertVariant } from './alert.js' + +export { ErrorMessage } from './error-message.js' +export type { ErrorMessageProps } from './error-message.js' + +export { ProgressBar } from './progress-bar.js' +export type { ProgressBarProps, ProgressBarStyle } from './progress-bar.js' + +export { Spinner } from './spinner.js' +export type { SpinnerProps } from './spinner.js' + +export { StatusMessage } from './status-message.js' +export type { StatusMessageProps, StatusMessageVariant } from './status-message.js' diff --git a/packages/core/src/ui/display/progress-bar.stories.tsx b/packages/core/src/ui/display/progress-bar.stories.tsx new file mode 100644 index 00000000..a20acabd --- /dev/null +++ b/packages/core/src/ui/display/progress-bar.stories.tsx @@ -0,0 +1,45 @@ +import type { ComponentType } from 'react' +import { z } from 'zod' + +import { stories } from '../../stories/story.js' +import type { StoryGroup } from '../../stories/types.js' +import { ProgressBar } from './progress-bar.js' + +const schema = z.object({ + value: z.number().describe('Current progress value'), + max: z.number().optional().describe('Maximum value'), + label: z.string().optional().describe('Label displayed after the percentage'), + style: z.enum(['light', 'heavy', 'block']).optional().describe('Visual style of the bar'), + size: z.number().optional().describe('Width of the bar in characters'), +}) + +const storyGroup: StoryGroup = stories({ + title: 'ProgressBar', + component: ProgressBar as unknown as ComponentType>, + schema, + defaults: {}, + stories: { + Empty: { + props: { value: 0 }, + description: 'Empty progress bar at 0%.', + }, + Half: { + props: { value: 50 }, + description: 'Progress bar at 50%.', + }, + Full: { + props: { value: 100 }, + description: 'Completed progress bar at 100%.', + }, + WithLabel: { + props: { value: 65, label: 'Installing dependencies' }, + description: 'Progress bar with a descriptive label.', + }, + StyleVariants: { + props: { value: 40, style: 'heavy', size: 30 }, + description: 'Heavy style progress bar with custom width.', + }, + }, +}) + +export default storyGroup diff --git a/packages/core/src/ui/display/progress-bar.tsx b/packages/core/src/ui/display/progress-bar.tsx new file mode 100644 index 00000000..a1725b99 --- /dev/null +++ b/packages/core/src/ui/display/progress-bar.tsx @@ -0,0 +1,110 @@ +import { Text } from 'ink' +import type { ReactElement } from 'react' +import { match } from 'ts-pattern' + +import { colors } from '../theme.js' + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** + * Visual style for the progress bar characters. + */ +export type ProgressBarStyle = 'light' | 'heavy' | 'block' + +/** + * Props for the {@link ProgressBar} component. + */ +export interface ProgressBarProps { + /** Current progress value. */ + readonly value: number + + /** Maximum value (defaults to 100). */ + readonly max?: number + + /** Optional label displayed after the percentage. */ + readonly label?: string + + /** Visual style of the bar characters. */ + readonly style?: ProgressBarStyle + + /** Width of the bar in characters. */ + readonly size?: number +} + +// --------------------------------------------------------------------------- +// Exports +// --------------------------------------------------------------------------- + +/** + * A horizontal progress bar with percentage display. + * + * Renders completed segments in cyan and remaining segments as dim text. + * The style prop controls the characters used for completed and remaining + * portions of the bar. + * + * @param props - The progress bar props. + * @returns A rendered progress bar element. + */ +export function ProgressBar({ + value, + max = 100, + label, + style = 'block', + size = 20, +}: ProgressBarProps): ReactElement { + const ratio = match(max > 0) + .with(true, () => Math.min(1, Math.max(0, value / max))) + .with(false, () => 0) + .exhaustive() + const percentage = Math.round(ratio * 100) + const guardedSize = Math.max(0, size) + const filledCount = Math.round(ratio * guardedSize) + const emptyCount = guardedSize - filledCount + const chars = resolveChars(style) + const filledBar = chars.filled.repeat(filledCount) + const emptyBar = chars.empty.repeat(emptyCount) + + return ( + + {filledBar} + {emptyBar} + {` ${String(percentage)}%`} + {match(label) + .with(undefined, () => null) + .otherwise((text) => ( + {` ${text}`} + ))} + + ) +} + +// --------------------------------------------------------------------------- +// Private +// --------------------------------------------------------------------------- + +/** + * Character pair for rendering the progress bar. + * + * @private + */ +interface BarChars { + readonly filled: string + readonly empty: string +} + +/** + * Resolve the filled and empty characters for a given bar style. + * + * @private + * @param style - The progress bar style. + * @returns The character pair for the given style. + */ +function resolveChars(style: ProgressBarStyle): BarChars { + return match(style) + .with('light', () => ({ filled: '\u2591', empty: ' ' })) + .with('heavy', () => ({ filled: '\u2593', empty: '\u2591' })) + .with('block', () => ({ filled: '\u2588', empty: '\u2591' })) + .exhaustive() +} diff --git a/packages/core/src/ui/display/spinner.stories.tsx b/packages/core/src/ui/display/spinner.stories.tsx new file mode 100644 index 00000000..b22f3a3e --- /dev/null +++ b/packages/core/src/ui/display/spinner.stories.tsx @@ -0,0 +1,35 @@ +import type { ComponentType } from 'react' +import { z } from 'zod' + +import { stories } from '../../stories/story.js' +import type { StoryGroup } from '../../stories/types.js' +import { Spinner } from './spinner.js' + +const schema = z.object({ + label: z.string().optional().describe('Text label displayed next to the spinner'), + isActive: z.boolean().optional().describe('Whether the spinner animates'), + type: z.string().optional().describe('The cli-spinners spinner type'), +}) + +const storyGroup: StoryGroup = stories({ + title: 'Spinner', + component: Spinner as unknown as ComponentType>, + schema, + defaults: {}, + stories: { + Default: { + props: { label: 'Loading...' }, + description: 'Default dots spinner with a label.', + }, + CustomType: { + props: { label: 'Processing...', type: 'line' }, + description: 'Spinner using the line animation type.', + }, + Inactive: { + props: { label: 'Paused', isActive: false }, + description: 'Inactive spinner renders nothing.', + }, + }, +}) + +export default storyGroup diff --git a/packages/core/src/ui/display/spinner.tsx b/packages/core/src/ui/display/spinner.tsx new file mode 100644 index 00000000..07bced50 --- /dev/null +++ b/packages/core/src/ui/display/spinner.tsx @@ -0,0 +1,139 @@ +import { Text } from 'ink' +import type { ReactElement } from 'react' +import { useEffect, useState } from 'react' +import { match } from 'ts-pattern' + +import { colors } from '../theme.js' + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** + * Spinner definition with frames and interval. + */ +interface SpinnerDef { + readonly interval: number + readonly frames: readonly string[] +} + +/** + * Available spinner types. + */ +type SpinnerType = 'dots' | 'line' | 'arc' | 'bouncingBar' + +/** + * Props for the {@link Spinner} component. + */ +export interface SpinnerProps { + /** + * Text label displayed next to the spinner. + */ + readonly label?: string + + /** + * Whether the spinner animates. When `false`, nothing is rendered. + */ + readonly isActive?: boolean + + /** + * The spinner type to use. + */ + readonly type?: SpinnerType +} + +// --------------------------------------------------------------------------- +// Spinner data (inlined from cli-spinners) +// --------------------------------------------------------------------------- + +/** + * Built-in spinner definitions. + * + * @private + */ +const spinners: Readonly> = Object.freeze({ + dots: { + interval: 80, + frames: Object.freeze(['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']), + }, + line: { + interval: 130, + frames: Object.freeze(['-', '\\', '|', '/']), + }, + arc: { + interval: 100, + frames: Object.freeze(['◜', '◠', '◝', '◞', '◡', '◟']), + }, + bouncingBar: { + interval: 80, + frames: Object.freeze([ + '[ ]', + '[= ]', + '[== ]', + '[=== ]', + '[ ===]', + '[ ==]', + '[ =]', + '[ ]', + '[ =]', + '[ ==]', + '[ ===]', + '[====]', + '[=== ]', + '[== ]', + '[= ]', + ]), + }, +}) + +// --------------------------------------------------------------------------- +// Exports +// --------------------------------------------------------------------------- + +/** + * An animated terminal spinner that cycles through frames. + * + * When `isActive` is `false`, the component renders nothing. The spinner + * frame is colored cyan and the label is rendered as plain text beside it. + * + * @param props - The spinner props. + * @returns A rendered spinner element, or `null` when inactive. + */ +export function Spinner({ + label, + isActive = true, + type = 'dots', +}: SpinnerProps): ReactElement | null { + const spinner = spinners[type] + const [frameIndex, setFrameIndex] = useState(0) + + useEffect(() => { + setFrameIndex(0) + + if (!isActive) { + return + } + + const timer = setInterval(() => { + setFrameIndex((prev) => (prev + 1) % spinner.frames.length) + }, spinner.interval) + + return () => { + clearInterval(timer) + } + }, [isActive, spinner]) + + return match(isActive) + .with(false, () => null) + .with(true, () => ( + + {spinner.frames[frameIndex]} + {match(label) + .with(undefined, () => null) + .otherwise((text) => ( + {` ${text}`} + ))} + + )) + .exhaustive() +} diff --git a/packages/core/src/ui/display/status-message.stories.tsx b/packages/core/src/ui/display/status-message.stories.tsx new file mode 100644 index 00000000..3d879658 --- /dev/null +++ b/packages/core/src/ui/display/status-message.stories.tsx @@ -0,0 +1,38 @@ +import type { ComponentType } from 'react' +import { z } from 'zod' + +import { stories } from '../../stories/story.js' +import type { StoryGroup } from '../../stories/types.js' +import { StatusMessage } from './status-message.js' + +const schema = z.object({ + children: z.string().describe('The message content'), + variant: z.enum(['info', 'success', 'error', 'warning']).describe('Status variant'), +}) + +const storyGroup: StoryGroup = stories({ + title: 'StatusMessage', + component: StatusMessage as unknown as ComponentType>, + schema, + defaults: {}, + stories: { + Info: { + props: { children: 'Processing data', variant: 'info' }, + description: 'Informational status with circle icon.', + }, + Success: { + props: { children: 'Operation completed successfully', variant: 'success' }, + description: 'Success status with tick icon.', + }, + Error: { + props: { children: 'Failed to connect', variant: 'error' }, + description: 'Error status with cross icon.', + }, + Warning: { + props: { children: 'Config file not found', variant: 'warning' }, + description: 'Warning status with warning icon.', + }, + }, +}) + +export default storyGroup diff --git a/packages/core/src/ui/display/status-message.tsx b/packages/core/src/ui/display/status-message.tsx new file mode 100644 index 00000000..73128f6a --- /dev/null +++ b/packages/core/src/ui/display/status-message.tsx @@ -0,0 +1,72 @@ +import { Text } from 'ink' +import type { ReactElement } from 'react' +import { match } from 'ts-pattern' + +import type { Variant } from '../theme.js' +import { resolveVariantColor, symbols } from '../theme.js' + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** + * The visual variant of a status message. + */ +export type StatusMessageVariant = Variant + +/** + * Props for the {@link StatusMessage} component. + */ +export interface StatusMessageProps { + /** The text content to display beside the icon. */ + readonly children: string + + /** The variant determines the icon and color. */ + readonly variant: StatusMessageVariant +} + +// --------------------------------------------------------------------------- +// Exports +// --------------------------------------------------------------------------- + +/** + * A status message with a colored icon indicating the variant. + * + * Renders a symbol (tick, cross, warning, or circle) in the variant color + * followed by the message content. Ideal for command output lines that + * communicate success, failure, warnings, or information. + * + * @param props - The status message props. + * @returns A rendered status message element. + */ +export function StatusMessage({ children, variant }: StatusMessageProps): ReactElement { + const icon = resolveIcon(variant) + const color = resolveVariantColor(variant) + + return ( + + {icon} + {` ${children}`} + + ) +} + +// --------------------------------------------------------------------------- +// Private +// --------------------------------------------------------------------------- + +/** + * Resolve the symbol icon for a given status variant. + * + * @private + * @param variant - The status message variant. + * @returns The icon string. + */ +function resolveIcon(variant: StatusMessageVariant): string { + return match(variant) + .with('info', () => symbols.circle) + .with('success', () => symbols.tick) + .with('error', () => symbols.cross) + .with('warning', () => symbols.warning) + .exhaustive() +} diff --git a/packages/core/src/ui/index.ts b/packages/core/src/ui/index.ts index 36f839e8..81b5fe5e 100644 --- a/packages/core/src/ui/index.ts +++ b/packages/core/src/ui/index.ts @@ -1,11 +1,6 @@ -/** - * UI components for building interactive terminal interfaces. - * - * Re-exports primitives from `ink` and higher-level components from - * `@inkjs/ui` as the public UI surface for `@kidd-cli/core`. - * - * @module - */ +// --------------------------------------------------------------------------- +// Base — raw Ink + @inkjs/ui primitives +// --------------------------------------------------------------------------- export { Box, @@ -44,46 +39,101 @@ export type { TransformProps, } from 'ink' -export { ConfirmInput } from './confirm.js' -export type { ConfirmInputProps } from './confirm.js' +// --------------------------------------------------------------------------- +// Prompts +// --------------------------------------------------------------------------- -export { MultiSelect } from './multi-select.js' -export type { MultiSelectProps } from './multi-select.js' - -export { PasswordInput } from './password-input.js' -export type { PasswordInputProps } from './password-input.js' - -export { Select } from './select.js' -export type { SelectProps } from './select.js' - -export { Spinner } from './spinner.js' -export type { SpinnerProps } from './spinner.js' - -export { TextInput } from './text-input.js' -export type { TextInputProps } from './text-input.js' - -export type { Option } from '@inkjs/ui' +export { + Autocomplete, + Confirm, + GroupMultiSelect, + MultiSelect, + PasswordInput, + PathInput, + Select, + SelectKey, + TextInput, +} from './prompts/index.js' +export type { + AutocompleteProps, + ConfirmProps, + GroupMultiSelectProps, + MultiSelectProps, + PasswordInputProps, + PathInputProps, + PromptOption, + SelectKeyProps, + SelectProps, + TextInputProps, +} from './prompts/index.js' + +// --------------------------------------------------------------------------- +// Display +// --------------------------------------------------------------------------- + +export { Alert, ErrorMessage, ProgressBar, Spinner, StatusMessage } from './display/index.js' +export type { + AlertProps, + AlertVariant, + ErrorMessageProps, + ProgressBarProps, + ProgressBarStyle, + SpinnerProps, + StatusMessageProps, + StatusMessageVariant, +} from './display/index.js' + +// --------------------------------------------------------------------------- +// Layout +// --------------------------------------------------------------------------- -export { useScreenContext } from './provider.js' +export { + FullScreen, + ScrollArea, + Tabs, + useFullScreen, + useSize, + useTerminalSize, +} from './layout/index.js' +export type { + FullScreenProps, + FullScreenState, + ScrollAreaProps, + Size, + TabItem, + TabsProps, + TerminalSize, +} from './layout/index.js' + +// --------------------------------------------------------------------------- +// Output +// --------------------------------------------------------------------------- + +export { Output } from './output.js' + +export { useOutputStore } from '../screen/output/index.js' +export type { OutputStore } from '../screen/output/index.js' + +// --------------------------------------------------------------------------- +// Screen +// --------------------------------------------------------------------------- + +export { screen, useScreenContext } from '../screen/index.js' +export type { ScreenDef, ScreenExit } from '../screen/index.js' export type { ScreenContext } from '../context/types.js' -export { FullScreen, useFullScreen, useTerminalSize } from './fullscreen.js' -export type { FullScreenProps, FullScreenState, TerminalSize } from './fullscreen.js' - -export { ScrollArea } from './scroll-area.js' -export type { ScrollAreaProps } from './scroll-area.js' - -export { Tabs } from './tabs.js' -export type { TabItem, TabsProps } from './tabs.js' +// --------------------------------------------------------------------------- +// Theme +// --------------------------------------------------------------------------- -export { useSize } from './use-size.js' -export type { Size } from './use-size.js' +export { colors, resolveVariantColor, symbols } from './theme.js' +export type { ThemeColor, Variant } from './theme.js' -export { Output, useOutputStore } from './output/index.js' -export type { OutputStore } from './output/index.js' +// --------------------------------------------------------------------------- +// Input +// --------------------------------------------------------------------------- -export { screen } from './screen.js' -export type { ScreenDef, ScreenExit } from './screen.js' +export { InputBarrier } from './input-barrier.js' export { useKeyBinding } from './use-key-binding.js' export type { UseKeyBindingArgs } from './use-key-binding.js' diff --git a/packages/core/src/ui/input-barrier.tsx b/packages/core/src/ui/input-barrier.tsx index 655ba02d..05204624 100644 --- a/packages/core/src/ui/input-barrier.tsx +++ b/packages/core/src/ui/input-barrier.tsx @@ -1,23 +1,3 @@ -/** - * Barrier component that isolates child `useInput` hooks from the global - * stdin event emitter. When inactive, children receive a silent - * {@link EventEmitter} so their input handlers never fire. When active, - * the real stdin context passes through unchanged. - * - * This relies on Ink's internal `StdinContext` — the same context that - * `useInput` and `useStdin` consume. Providing a replacement context - * value with a silent emitter effectively mutes all input hooks in the - * subtree without requiring component cooperation. - * - * Ink does not export `StdinContext` in its public `exports` map, so a - * direct static import (`ink/build/components/StdinContext.js`) is - * blocked by Node.js module resolution at runtime. We resolve the - * internal path dynamically via {@link import.meta.resolve} to bypass - * the restriction while keeping the same context identity. - * - * @module - */ - import { EventEmitter } from 'node:events' import process from 'node:process' diff --git a/packages/core/src/ui/keys.ts b/packages/core/src/ui/keys.ts index e0231437..0d0a968d 100644 --- a/packages/core/src/ui/keys.ts +++ b/packages/core/src/ui/keys.ts @@ -1,11 +1,3 @@ -/** - * Shared key name vocabulary, pattern parser, and normalizer for keyboard - * input hooks. Maps Ink's `Key` boolean fields to human-readable key names - * and parses declarative key patterns like `'ctrl+c'` or `'escape escape'`. - * - * @module - */ - import type { Key } from 'ink' import { match } from 'ts-pattern' diff --git a/packages/core/src/ui/fullscreen.test.ts b/packages/core/src/ui/layout/fullscreen.test.ts similarity index 100% rename from packages/core/src/ui/fullscreen.test.ts rename to packages/core/src/ui/layout/fullscreen.test.ts diff --git a/packages/core/src/ui/fullscreen.tsx b/packages/core/src/ui/layout/fullscreen.tsx similarity index 92% rename from packages/core/src/ui/fullscreen.tsx rename to packages/core/src/ui/layout/fullscreen.tsx index 81f455e5..8f385b45 100644 --- a/packages/core/src/ui/fullscreen.tsx +++ b/packages/core/src/ui/layout/fullscreen.tsx @@ -1,14 +1,3 @@ -/** - * Fullscreen mode for terminal UIs. - * - * Provides a `` component that enters the terminal's alternate - * screen buffer, a `useFullScreen` hook for reading terminal dimensions - * and fullscreen state, and a standalone `useTerminalSize` hook for - * tracking terminal dimensions independently. - * - * @module - */ - import process from 'node:process' import { Box, useStdout } from 'ink' @@ -22,13 +11,19 @@ import { createContext, useContext, useEffect, useState } from 'react' /** ANSI: switch to the alternate screen buffer. */ const ENTER_ALT_SCREEN = '\u001B[?1049h' -/** ANSI: return to the normal screen buffer. */ +/** + * ANSI: return to the normal screen buffer. + */ export const LEAVE_ALT_SCREEN = '\u001B[?1049l' -/** ANSI: clear the entire screen. */ +/** + * ANSI: clear the entire screen. + */ const CLEAR_SCREEN = '\u001B[2J' -/** ANSI: move the cursor to the top-left corner. */ +/** + * ANSI: move the cursor to the top-left corner. + */ const CURSOR_HOME = '\u001B[H' /** ANSI: hide the cursor. */ @@ -54,7 +49,9 @@ export interface TerminalSize { */ export interface FullScreenProps { readonly children: ReactNode - /** When `true`, hides the cursor while in fullscreen mode. */ + /** + * When `true`, hides the cursor while in fullscreen mode. + */ readonly hideCursor?: boolean } diff --git a/packages/core/src/ui/layout/index.ts b/packages/core/src/ui/layout/index.ts new file mode 100644 index 00000000..6578e0a9 --- /dev/null +++ b/packages/core/src/ui/layout/index.ts @@ -0,0 +1,11 @@ +export { FullScreen, useFullScreen, useTerminalSize } from './fullscreen.js' +export type { FullScreenProps, FullScreenState, TerminalSize } from './fullscreen.js' + +export { ScrollArea } from './scroll-area.js' +export type { ScrollAreaProps } from './scroll-area.js' + +export { Tabs } from './tabs.js' +export type { TabItem, TabsProps } from './tabs.js' + +export { useSize } from './use-size.js' +export type { Size } from './use-size.js' diff --git a/packages/core/src/ui/scroll-area.tsx b/packages/core/src/ui/layout/scroll-area.tsx similarity index 95% rename from packages/core/src/ui/scroll-area.tsx rename to packages/core/src/ui/layout/scroll-area.tsx index a5555b48..57e24fac 100644 --- a/packages/core/src/ui/scroll-area.tsx +++ b/packages/core/src/ui/layout/scroll-area.tsx @@ -1,15 +1,3 @@ -/** - * ScrollArea UI component. - * - * A vertically scrollable container for terminal UIs. Renders a - * viewport that clips overflowing content and shifts the visible - * window based on a tracked scroll offset. Supports both controlled - * and uncontrolled scroll position, auto-scroll to keep a highlighted - * index visible, and an optional scroll indicator. - * - * @module - */ - import { Box, Text } from 'ink' import type { ReactElement, ReactNode } from 'react' import { useEffect, useState } from 'react' diff --git a/packages/core/src/ui/tabs.tsx b/packages/core/src/ui/layout/tabs.tsx similarity index 94% rename from packages/core/src/ui/tabs.tsx rename to packages/core/src/ui/layout/tabs.tsx index a2037a15..91b17659 100644 --- a/packages/core/src/ui/tabs.tsx +++ b/packages/core/src/ui/layout/tabs.tsx @@ -1,19 +1,10 @@ -/** - * Tab navigation UI component. - * - * Provides a universal {@link Tabs} component for switching between - * panels in a terminal UI. Supports keyboard navigation with left/right - * arrows and number keys. The active tab is highlighted with a colored - * underline. - * - * @module - */ - import { Box, Text, useInput } from 'ink' import type { ReactElement, ReactNode } from 'react' import { useState } from 'react' import { match } from 'ts-pattern' +import { colors } from '../theme.js' + // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- @@ -189,7 +180,7 @@ function TabLabel({ label, isActive, isFocused }: TabLabelProps): ReactElement { */ function resolveTabColor(isActive: boolean, isFocused: boolean): string | undefined { return match({ isActive, isFocused }) - .with({ isActive: true, isFocused: true }, () => 'cyan' as const) + .with({ isActive: true, isFocused: true }, () => colors.primary) .with({ isActive: true, isFocused: false }, () => 'white' as const) .otherwise(() => undefined) } diff --git a/packages/core/src/ui/use-size.test.ts b/packages/core/src/ui/layout/use-size.test.ts similarity index 100% rename from packages/core/src/ui/use-size.test.ts rename to packages/core/src/ui/layout/use-size.test.ts diff --git a/packages/core/src/ui/use-size.tsx b/packages/core/src/ui/layout/use-size.tsx similarity index 93% rename from packages/core/src/ui/use-size.tsx rename to packages/core/src/ui/layout/use-size.tsx index 337ad5be..c803a4dd 100644 --- a/packages/core/src/ui/use-size.tsx +++ b/packages/core/src/ui/layout/use-size.tsx @@ -1,13 +1,3 @@ -/** - * Element and terminal size measurement hook. - * - * Provides a {@link useSize} hook that measures the computed dimensions - * of an Ink `` element via Yoga's layout engine. When no element - * ref is provided, falls back to the terminal ("window") dimensions. - * - * @module - */ - import type { DOMElement } from 'ink' import { measureElement } from 'ink' import type { RefObject } from 'react' diff --git a/packages/core/src/ui/multi-select.tsx b/packages/core/src/ui/multi-select.tsx deleted file mode 100644 index 9218283e..00000000 --- a/packages/core/src/ui/multi-select.tsx +++ /dev/null @@ -1,11 +0,0 @@ -/** - * MultiSelect UI component. - * - * A thin wrapper that re-exports the `MultiSelect` component from `@inkjs/ui`. - * Renders a multi-choice selection list in the terminal, allowing the user to - * select multiple options from a set. - * - * @module - */ -export { MultiSelect } from '@inkjs/ui' -export type { MultiSelectProps } from '@inkjs/ui' diff --git a/packages/core/src/ui/output/output.tsx b/packages/core/src/ui/output.tsx similarity index 79% rename from packages/core/src/ui/output/output.tsx rename to packages/core/src/ui/output.tsx index e7a8e512..2b80baa3 100644 --- a/packages/core/src/ui/output/output.tsx +++ b/packages/core/src/ui/output.tsx @@ -1,13 +1,3 @@ -/** - * React component that renders accumulated output entries from an - * {@link OutputStore}. Used inside `screen()` components to display - * `ctx.log`, `ctx.status.spinner`, and `ctx.report` output declaratively. - * - * @module - */ - -import { Spinner } from '@inkjs/ui' -import figures from 'figures' import { Box, Text } from 'ink' import type { ReactElement } from 'react' import { useSyncExternalStore } from 'react' @@ -16,9 +6,11 @@ import { match } from 'ts-pattern' import { formatCheck } from '@/lib/format/check.js' import { formatFinding } from '@/lib/format/finding.js' import { formatSummary } from '@/lib/format/tally.js' +import type { LogLevel, OutputEntry, SpinnerState } from '@/screen/output/types.js' +import { useOutputStore } from '@/screen/output/use-output-store.js' -import type { LogLevel, OutputEntry, SpinnerState } from './types.js' -import { useOutputStore } from './use-output-store.js' +import { Spinner } from './display/spinner.js' +import { colors, symbols } from './theme.js' // --------------------------------------------------------------------------- // Exports @@ -62,12 +54,14 @@ function SpinnerRow({ state }: { readonly state: SpinnerState }): ReactElement | .with({ status: 'idle' }, () => null) .with({ status: 'spinning' }, ({ message }) => ) .with({ status: 'stopped' }, ({ message }) => - resolveTerminalIcon(message, 'green', figures.tick) + resolveTerminalIcon(message, colors.success, symbols.tick) ) .with({ status: 'cancelled' }, ({ message }) => - resolveTerminalIcon(message, 'yellow', figures.warning) + resolveTerminalIcon(message, colors.warning, symbols.warning) + ) + .with({ status: 'error' }, ({ message }) => + resolveTerminalIcon(message, colors.error, symbols.cross) ) - .with({ status: 'error' }, ({ message }) => resolveTerminalIcon(message, 'red', figures.cross)) .exhaustive() } @@ -120,27 +114,27 @@ function LogRow({ return match(level) .with('info', () => ( - {figures.circle} {text} + {symbols.circle} {text} )) .with('success', () => ( - {figures.tick} {text} + {symbols.tick} {text} )) .with('error', () => ( - {figures.cross} {text} + {symbols.cross} {text} )) .with('warn', () => ( - {figures.warning} {text} + {symbols.warning} {text} )) .with('step', () => ( - {figures.circle} {text} + {symbols.circle} {text} )) .with('message', () => ( diff --git a/packages/core/src/ui/password-input.tsx b/packages/core/src/ui/password-input.tsx deleted file mode 100644 index a8a3f846..00000000 --- a/packages/core/src/ui/password-input.tsx +++ /dev/null @@ -1,11 +0,0 @@ -/** - * PasswordInput UI component. - * - * A thin wrapper that re-exports the `PasswordInput` component from `@inkjs/ui`. - * Renders a masked text input in the terminal for securely entering sensitive - * values such as passwords or tokens. - * - * @module - */ -export { PasswordInput } from '@inkjs/ui' -export type { PasswordInputProps } from '@inkjs/ui' diff --git a/packages/core/src/ui/prompts/autocomplete.stories.tsx b/packages/core/src/ui/prompts/autocomplete.stories.tsx new file mode 100644 index 00000000..acd79bb3 --- /dev/null +++ b/packages/core/src/ui/prompts/autocomplete.stories.tsx @@ -0,0 +1,58 @@ +import { stories } from '@kidd-cli/core/stories' +import type { StoryGroup } from '@kidd-cli/core/stories' +import type { ComponentType } from 'react' +import { z } from 'zod' + +import { Autocomplete } from './autocomplete.js' + +const schema = z.object({ + placeholder: z.string().optional().describe('Placeholder text for the search input'), + maxVisible: z.number().optional().describe('Maximum visible options'), + isDisabled: z.boolean().optional().describe('Disable the component'), +}) + +const defaultOptions = [ + { value: 'react', label: 'React', hint: 'UI library' }, + { value: 'vue', label: 'Vue', hint: 'Progressive framework' }, + { value: 'angular', label: 'Angular', hint: 'Platform' }, + { value: 'svelte', label: 'Svelte', hint: 'Compiler' }, + { value: 'solid', label: 'SolidJS' }, + { value: 'preact', label: 'Preact', hint: 'Lightweight' }, + { value: 'lit', label: 'Lit', hint: 'Web components' }, + { value: 'htmx', label: 'HTMX', disabled: true }, +] + +const storyGroup: StoryGroup = stories({ + title: 'Autocomplete', + component: Autocomplete as unknown as ComponentType>, + schema, + defaults: { + options: defaultOptions, + onChange: (value: unknown) => { + void value + }, + onSubmit: (value: unknown) => { + void value + }, + }, + stories: { + Default: { + props: { placeholder: 'Search frameworks...' }, + description: 'Default autocomplete with placeholder text.', + }, + CustomFilter: { + props: { + placeholder: 'Type to search...', + filter: (search: string, option: { readonly label: string }) => + option.label.toLowerCase().startsWith(search.toLowerCase()), + }, + description: 'Autocomplete with a custom starts-with filter.', + }, + Disabled: { + props: { isDisabled: true }, + description: 'Autocomplete in disabled state.', + }, + }, +}) + +export default storyGroup diff --git a/packages/core/src/ui/prompts/autocomplete.tsx b/packages/core/src/ui/prompts/autocomplete.tsx new file mode 100644 index 00000000..38d2d55a --- /dev/null +++ b/packages/core/src/ui/prompts/autocomplete.tsx @@ -0,0 +1,250 @@ +import { Box, Text, useInput } from 'ink' +import type { ReactElement } from 'react' +import { useMemo, useState } from 'react' +import { match } from 'ts-pattern' + +import { ScrollArea } from '../layout/scroll-area.js' +import { colors, symbols } from '../theme.js' +import { CursorValue } from './cursor-value.js' +import { resolveInitialIndex } from './navigation.js' +import { OptionRow } from './option-row.js' +import { insertCharAt, removeCharAt } from './string-utils.js' +import type { PromptOption } from './types.js' + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** + * Props for the {@link Autocomplete} component. + */ +export interface AutocompleteProps { + /** The full list of selectable options. */ + readonly options: readonly PromptOption[] + + /** Placeholder text shown when the search input is empty. */ + readonly placeholder?: string + + /** Maximum number of visible options in the dropdown. */ + readonly maxVisible?: number + + /** Initially selected value. */ + readonly defaultValue?: TValue + + /** Custom filter function. Defaults to case-insensitive label match. */ + readonly filter?: (search: string, option: PromptOption) => boolean + + /** Called when the focused option changes. */ + readonly onChange?: (value: TValue) => void + + /** Called when the user presses Enter to confirm. */ + readonly onSubmit?: (value: TValue) => void + + /** When `true`, the component does not respond to input. */ + readonly isDisabled?: boolean +} + +// --------------------------------------------------------------------------- +// Exports +// --------------------------------------------------------------------------- + +/** + * Autocomplete prompt with real-time filtering and keyboard navigation. + * + * Renders a text input at the top that filters the option list as the user + * types. Arrow keys navigate the filtered results and Enter selects the + * currently focused option. Uses {@link ScrollArea} to constrain the visible + * list to `maxVisible` rows. + * + * @param props - The autocomplete props. + * @returns A rendered autocomplete element. + */ +export function Autocomplete({ + options, + placeholder, + maxVisible = 5, + defaultValue, + filter = defaultFilter, + onChange, + onSubmit, + isDisabled = false, +}: AutocompleteProps): ReactElement { + const [search, setSearch] = useState('') + const [focusIndex, setFocusIndex] = useState(resolveInitialIndex({ options, defaultValue })) + const [cursorOffset, setCursorOffset] = useState(0) + + const filtered = useMemo( + () => options.filter((option) => filter(search, option)), + [options, search, filter] + ) + + useInput( + (input, key) => { + if (key.upArrow) { + if (filtered.length === 0) { + return + } + const clamped = Math.min(focusIndex, filtered.length - 1) + const next = Math.max(0, clamped - 1) + setFocusIndex(next) + const focused = filtered[next] + if (onChange && focused !== undefined) { + onChange(focused.value) + } + return + } + + if (key.downArrow) { + if (filtered.length === 0) { + return + } + const next = Math.min(filtered.length - 1, focusIndex + 1) + setFocusIndex(next) + const focused = filtered[next] + if (onChange && focused !== undefined) { + onChange(focused.value) + } + return + } + + if (key.return) { + const focused = filtered[focusIndex] + if (onSubmit && focused !== undefined && !focused.disabled) { + onSubmit(focused.value) + } + return + } + + if (key.leftArrow) { + setCursorOffset(Math.max(0, cursorOffset - 1)) + return + } + + if (key.rightArrow) { + setCursorOffset(Math.min(search.length, cursorOffset + 1)) + return + } + + if (key.backspace) { + if (cursorOffset > 0) { + const nextSearch = removeCharAt({ str: search, index: cursorOffset - 1 }) + setSearch(nextSearch) + setCursorOffset(cursorOffset - 1) + setFocusIndex(0) + } + return + } + + if (key.delete) { + if (cursorOffset < search.length) { + const nextSearch = removeCharAt({ str: search, index: cursorOffset }) + setSearch(nextSearch) + setFocusIndex(0) + } + return + } + + if (input && !key.ctrl && !key.meta) { + const nextSearch = insertCharAt({ str: search, index: cursorOffset, chars: input }) + setSearch(nextSearch) + setCursorOffset(cursorOffset + input.length) + setFocusIndex(0) + } + }, + { isActive: !isDisabled } + ) + + return ( + + + {match(filtered.length > 0) + .with(true, () => ( + + {filtered.map((option, index) => { + const isFocused = index === focusIndex + const indicator = match(isFocused) + .with(true, () => symbols.radioOn) + .with(false, () => symbols.radioOff) + .exhaustive() + + return ( + + ) + })} + + )) + .with(false, () => No matches found.) + .exhaustive()} + + ) +} + +// --------------------------------------------------------------------------- +// Private +// --------------------------------------------------------------------------- + +/** + * Props for the {@link SearchInput} component. + * + * @private + */ +interface SearchInputProps { + readonly value: string + readonly placeholder?: string + readonly isDisabled: boolean + readonly cursorOffset: number +} + +/** + * Default case-insensitive substring filter. + * + * @private + * @param search - The current search string. + * @param option - The option to test. + * @returns Whether the option matches the search. + */ +function defaultFilter(search: string, option: PromptOption): boolean { + if (search === '') { + return true + } + return option.label.toLowerCase().includes(search.toLowerCase()) +} + +/** + * Render the search text input with cursor and placeholder support. + * + * @private + * @param props - The search input props. + * @returns A rendered search input element. + */ +function SearchInput({ + value, + placeholder, + isDisabled, + cursorOffset, +}: SearchInputProps): ReactElement { + return ( + + + {'> '} + + {match({ isEmpty: value === '', hasPlaceholder: placeholder !== undefined }) + .with({ isEmpty: true, hasPlaceholder: true }, () => {placeholder}) + .otherwise(() => ( + + ))} + + ) +} diff --git a/packages/core/src/ui/prompts/confirm.stories.tsx b/packages/core/src/ui/prompts/confirm.stories.tsx new file mode 100644 index 00000000..a007f7ed --- /dev/null +++ b/packages/core/src/ui/prompts/confirm.stories.tsx @@ -0,0 +1,47 @@ +import { stories } from '@kidd-cli/core/stories' +import type { StoryGroup } from '@kidd-cli/core/stories' +import type { ComponentType } from 'react' +import { z } from 'zod' + +import { Confirm } from './confirm.js' + +const schema = z.object({ + active: z.string().describe('Label for the affirmative choice'), + inactive: z.string().describe('Label for the negative choice'), + defaultValue: z.boolean().describe('Initial value'), + isDisabled: z.boolean().describe('Disable interaction'), +}) + +const storyGroup: StoryGroup = stories({ + title: 'Confirm', + component: Confirm as unknown as ComponentType>, + schema, + defaults: { + onSubmit: (_v: boolean) => {}, + }, + stories: { + Default: { + props: { active: 'Yes', inactive: 'No', defaultValue: true, isDisabled: false }, + description: 'Standard confirm with default labels', + }, + CustomLabels: { + props: { + active: 'Proceed', + inactive: 'Cancel', + defaultValue: true, + isDisabled: false, + }, + description: 'Confirm with custom active/inactive labels', + }, + DefaultNo: { + props: { active: 'Yes', inactive: 'No', defaultValue: false, isDisabled: false }, + description: 'Confirm defaulting to the negative choice', + }, + Disabled: { + props: { active: 'Yes', inactive: 'No', defaultValue: true, isDisabled: true }, + description: 'Fully disabled confirm', + }, + }, +}) + +export default storyGroup diff --git a/packages/core/src/ui/prompts/confirm.tsx b/packages/core/src/ui/prompts/confirm.tsx new file mode 100644 index 00000000..3c920263 --- /dev/null +++ b/packages/core/src/ui/prompts/confirm.tsx @@ -0,0 +1,129 @@ +import { Box, Text, useInput } from 'ink' +import type { ReactElement } from 'react' +import { useState } from 'react' +import { match } from 'ts-pattern' + +import { colors } from '../theme.js' + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** + * Props for the {@link Confirm} component. + */ +export interface ConfirmProps { + /** Label for the affirmative choice. @default "Yes" */ + readonly active?: string + + /** Label for the negative choice. @default "No" */ + readonly inactive?: string + + /** The initial value. @default true */ + readonly defaultValue?: boolean + + /** Callback fired when the value is submitted via Enter. */ + readonly onSubmit?: (value: boolean) => void + + /** When `true`, the component ignores all keyboard input. */ + readonly isDisabled?: boolean +} + +// --------------------------------------------------------------------------- +// Exports +// --------------------------------------------------------------------------- + +/** + * A boolean confirm prompt with toggle-style keyboard navigation. + * + * Renders two choices side by side. The active choice is styled with + * cyan color and underline. Users toggle with left/right arrows or + * y/n keys and submit with Enter. + * + * **Keyboard shortcuts:** + * - Left/Right arrows — toggle between choices + * - y — select the affirmative choice + * - n — select the negative choice + * - Enter — submit the current value + * + * @param props - The confirm component props. + * @returns A rendered confirm element. + */ +export function Confirm({ + active = 'Yes', + inactive = 'No', + defaultValue = true, + onSubmit, + isDisabled = false, +}: ConfirmProps): ReactElement { + const [value, setValue] = useState(defaultValue) + + useInput( + (input, key) => { + if (key.return) { + if (onSubmit) { + onSubmit(value) + } + return + } + + if (key.leftArrow || key.rightArrow) { + setValue(!value) + return + } + + if (input === 'y' || input === 'Y') { + setValue(true) + return + } + + if (input === 'n' || input === 'N') { + setValue(false) + } + }, + { isActive: !isDisabled } + ) + + return ( + + + / + + + ) +} + +// --------------------------------------------------------------------------- +// Private +// --------------------------------------------------------------------------- + +/** + * Props for the {@link ConfirmChoice} component. + * + * @private + */ +interface ConfirmChoiceProps { + readonly label: string + readonly isActive: boolean + readonly isDisabled: boolean +} + +/** + * Render a single confirm choice with active/inactive styling. + * + * @private + * @param props - The choice props. + * @returns A rendered choice element. + */ +function ConfirmChoice({ label, isActive, isDisabled }: ConfirmChoiceProps): ReactElement { + const color = match({ isActive, isDisabled }) + .with({ isDisabled: true }, () => undefined) + .with({ isActive: true }, () => colors.primary) + .otherwise(() => undefined) + + return ( + + {label} + + ) +} diff --git a/packages/core/src/ui/prompts/cursor-value.tsx b/packages/core/src/ui/prompts/cursor-value.tsx new file mode 100644 index 00000000..8cabf133 --- /dev/null +++ b/packages/core/src/ui/prompts/cursor-value.tsx @@ -0,0 +1,52 @@ +import { Text } from 'ink' +import type { ReactElement } from 'react' +import { match } from 'ts-pattern' + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** + * Props for the {@link CursorValue} component. + */ +export interface CursorValueProps { + /** The display string (plain text or masked). */ + readonly value: string + + /** The cursor position within the value string. */ + readonly cursor: number + + /** When `true`, the value is rendered dimmed. */ + readonly isDisabled: boolean +} + +// --------------------------------------------------------------------------- +// Exports +// --------------------------------------------------------------------------- + +/** + * Render a string with a visible cursor at the specified position. + * + * The character at the cursor position is rendered with inverse styling. + * If the cursor is at the end, an inverse space is appended. + * + * @param props - The cursor value props. + * @returns A rendered value element with cursor. + */ +export function CursorValue({ value, cursor, isDisabled }: CursorValueProps): ReactElement { + const beforeCursor = value.slice(0, cursor) + const atCursor = value[cursor] + const afterCursor = value.slice(cursor + 1) + + return ( + + {beforeCursor} + + {match(atCursor) + .with(undefined, () => ' ') + .otherwise((ch) => ch)} + + {afterCursor} + + ) +} diff --git a/packages/core/src/ui/prompts/group-multi-select.stories.tsx b/packages/core/src/ui/prompts/group-multi-select.stories.tsx new file mode 100644 index 00000000..70b71388 --- /dev/null +++ b/packages/core/src/ui/prompts/group-multi-select.stories.tsx @@ -0,0 +1,55 @@ +import { stories } from '@kidd-cli/core/stories' +import type { StoryGroup } from '@kidd-cli/core/stories' +import type { ComponentType } from 'react' +import { z } from 'zod' + +import { GroupMultiSelect } from './group-multi-select.js' + +const schema = z.object({ + required: z.boolean().optional().describe('Require at least one selection'), + selectableGroups: z.boolean().optional().describe('Allow toggling entire groups'), + isDisabled: z.boolean().optional().describe('Disable the component'), +}) + +const defaultOptions = { + Fruits: [ + { value: 'apple', label: 'Apple', hint: 'Sweet' }, + { value: 'banana', label: 'Banana' }, + { value: 'cherry', label: 'Cherry', disabled: true }, + ], + Vegetables: [ + { value: 'carrot', label: 'Carrot' }, + { value: 'broccoli', label: 'Broccoli', hint: 'Healthy' }, + ], +} + +const storyGroup: StoryGroup = stories({ + title: 'GroupMultiSelect', + component: GroupMultiSelect as unknown as ComponentType>, + schema, + defaults: { + options: defaultOptions, + onChange: (values: readonly unknown[]) => { + void values + }, + onSubmit: (values: readonly unknown[]) => { + void values + }, + }, + stories: { + Default: { + props: {}, + description: 'Basic grouped multi-select with default settings.', + }, + SelectableGroups: { + props: { selectableGroups: true }, + description: 'Group headers can be toggled to select/deselect all options.', + }, + WithRequired: { + props: { required: true }, + description: 'At least one option must be selected before submitting.', + }, + }, +}) + +export default storyGroup diff --git a/packages/core/src/ui/prompts/group-multi-select.tsx b/packages/core/src/ui/prompts/group-multi-select.tsx new file mode 100644 index 00000000..3dec8ce9 --- /dev/null +++ b/packages/core/src/ui/prompts/group-multi-select.tsx @@ -0,0 +1,406 @@ +import { Box, Text, useInput } from 'ink' +import picocolors from 'picocolors' +import type { ReactElement } from 'react' +import { useEffect, useMemo, useState } from 'react' +import { match } from 'ts-pattern' + +import { ErrorMessage } from '../display/error-message.js' +import { colors, symbols } from '../theme.js' +import type { PromptOption } from './types.js' + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** + * Props for the {@link GroupMultiSelect} component. + */ +export interface GroupMultiSelectProps { + /** Options organized by group name. */ + readonly options: Readonly[]>> + + /** Initially selected values. */ + readonly defaultValue?: readonly TValue[] + + /** When `true`, at least one option must be selected to submit. */ + readonly required?: boolean + + /** When `true`, group headers can be toggled to select/deselect all options in the group. */ + readonly selectableGroups?: boolean + + /** Called whenever the selection changes. */ + readonly onChange?: (value: readonly TValue[]) => void + + /** Called when the user presses Enter to confirm. */ + readonly onSubmit?: (value: readonly TValue[]) => void + + /** When `true`, the component does not respond to input. */ + readonly isDisabled?: boolean +} + +// --------------------------------------------------------------------------- +// Exports +// --------------------------------------------------------------------------- + +/** + * Multi-select prompt with options organized into named groups. + * + * Renders group headers as bold section labels with indented options beneath. + * Space toggles the focused item, Enter submits the current selection. + * When `selectableGroups` is enabled, toggling a group header toggles all + * of its child options. + * + * @param props - The group multi-select props. + * @returns A rendered group multi-select element. + */ +export function GroupMultiSelect({ + options, + defaultValue = [], + required = false, + selectableGroups = false, + onChange, + onSubmit, + isDisabled = false, +}: GroupMultiSelectProps): ReactElement { + const flatItems = useMemo( + () => buildFlatItems({ options, selectableGroups }), + [options, selectableGroups] + ) + const [focusIndex, setFocusIndex] = useState(0) + const [selected, setSelected] = useState(defaultValue) + const [error, setError] = useState(undefined) + const selectedSet = useMemo(() => new Set(selected), [selected]) + + useEffect(() => { + if (flatItems.length === 0) { + return + } + if (focusIndex >= flatItems.length) { + setFocusIndex(flatItems.length - 1) + } + }, [flatItems, focusIndex]) + + useInput( + (input, key) => { + if (key.return) { + if (required && selected.length === 0) { + setError('At least one option must be selected.') + return + } + if (onSubmit) { + onSubmit(selected) + } + return + } + + if (flatItems.length === 0) { + return + } + + if (key.upArrow) { + setFocusIndex(moveFocus(focusIndex, -1, flatItems)) + return + } + + if (key.downArrow) { + setFocusIndex(moveFocus(focusIndex, 1, flatItems)) + return + } + + if (input === ' ') { + const item = flatItems[focusIndex] + if (item === undefined) { + return + } + const nextSelected = toggleItem({ item, selected, selectedSet, options }) + setSelected(nextSelected) + setError(undefined) + if (onChange) { + onChange(nextSelected) + } + } + }, + { isActive: !isDisabled } + ) + + return ( + + {flatItems.map((item, index) => { + const isFocused = index === focusIndex + return ( + + ) + })} + + + ) +} + +// --------------------------------------------------------------------------- +// Private +// --------------------------------------------------------------------------- + +/** + * A flattened group header item. + * + * @private + */ +interface FlatGroupItem { + readonly kind: 'group' + readonly groupName: string +} + +/** + * A flattened option item within a group. + * + * @private + */ +interface FlatOptionItem { + readonly kind: 'option' + readonly groupName: string + readonly option: PromptOption +} + +/** + * A flattened item that is either a group header or an option. + * + * @private + */ +type FlatItem = FlatGroupItem | FlatOptionItem + +/** + * Props for the {@link FlatItemRow} component. + * + * @private + */ +interface FlatItemRowProps { + readonly item: FlatItem + readonly isFocused: boolean + readonly isSelected: boolean + readonly isDisabled: boolean +} + +/** + * Options for building a flat item list. + * + * @private + */ +interface BuildFlatItemsOptions { + readonly options: Readonly[]>> + readonly selectableGroups: boolean +} + +/** + * Options for toggling a flat item's selection state. + * + * @private + */ +interface ToggleItemOptions { + readonly item: FlatItem + readonly selected: readonly TValue[] + readonly selectedSet: ReadonlySet + readonly options: Readonly[]>> +} + +/** + * Build a flat list of items from the grouped options map. + * + * @private + * @param opts - The build options. + * @returns A flat array of group headers and options. + */ +function buildFlatItems({ + options, + selectableGroups, +}: BuildFlatItemsOptions): readonly FlatItem[] { + return Object.entries(options).flatMap(([groupName, groupOptions]) => { + const header: FlatItem = { kind: 'group', groupName } + const items: readonly FlatItem[] = groupOptions.map((option) => ({ + kind: 'option' as const, + groupName, + option, + })) + return match(selectableGroups) + .with(true, () => [header, ...items]) + .with(false, () => items) + .exhaustive() + }) +} + +/** + * Compute the next focus index, skipping disabled options. + * + * @private + * @param current - The current focus index. + * @param direction - The direction to move (-1 or 1). + * @param items - The flat item list. + * @returns The next valid focus index. + */ +function moveFocus(current: number, direction: number, items: readonly FlatItem[]): number { + const next = current + direction + if (next < 0 || next >= items.length) { + return current + } + const item = items[next] + if (item !== undefined && item.kind === 'option' && item.option.disabled) { + const result = moveFocus(next, direction, items) + return match(result === next) + .with(true, () => current) + .with(false, () => result) + .exhaustive() + } + return next +} + +/** + * Toggle the selection state for a flat item. For group headers, toggles all + * non-disabled options in the group. For options, toggles the single value. + * + * @private + * @param opts - The toggle options. + * @returns The updated selection array. + */ +function toggleItem({ + item, + selected, + selectedSet, + options, +}: ToggleItemOptions): readonly TValue[] { + return match(item.kind) + .with('group', () => { + const groupOptions = options[item.groupName] ?? [] + const enabledValues = groupOptions.filter((opt) => !opt.disabled).map((opt) => opt.value) + const enabledSet = new Set(enabledValues) + const allSelected = enabledValues.every((v) => selectedSet.has(v)) + return match(allSelected) + .with(true, () => selected.filter((v) => !enabledSet.has(v))) + .with(false, () => [...selected.filter((v) => !enabledSet.has(v)), ...enabledValues]) + .exhaustive() + }) + .with('option', () => { + if (item.kind !== 'option' || item.option.disabled) { + return selected + } + const { value } = item.option + return match(selectedSet.has(value)) + .with(true, () => selected.filter((v) => v !== value)) + .with(false, () => [...selected, value]) + .exhaustive() + }) + .exhaustive() +} + +/** + * Determine whether a flat item is currently selected. + * + * @private + * @param item - The flat item. + * @param selectedSet - A set of currently selected values for O(1) lookup. + * @param options - The grouped options. + * @returns Whether the item is selected. + */ +function isItemSelected( + item: FlatItem, + selectedSet: ReadonlySet, + options: Readonly[]>> +): boolean { + return match(item.kind) + .with('group', () => { + const groupOptions = options[item.groupName] ?? [] + const enabledValues = groupOptions.filter((opt) => !opt.disabled).map((opt) => opt.value) + return enabledValues.length > 0 && enabledValues.every((v) => selectedSet.has(v)) + }) + .with('option', () => item.kind === 'option' && selectedSet.has(item.option.value)) + .exhaustive() +} + +/** + * Generate a stable key for a flat item. + * + * @private + * @param item - The flat item. + * @param index - The item index. + * @returns A string key. + */ +function itemKey(item: FlatItem): string { + return match(item) + .with({ kind: 'group' }, (i) => `group-${i.groupName}`) + .with({ kind: 'option' }, (i) => `option-${i.groupName}-${String(i.option.value)}`) + .exhaustive() +} + +/** + * Render a single row in the flat item list, either a group header + * or an indented option with checkbox. + * + * @private + * @param props - The row props. + * @returns A rendered row element. + */ +function FlatItemRow({ item, isFocused, isSelected, isDisabled }: FlatItemRowProps): ReactElement { + return match(item) + .with({ kind: 'group' }, (groupItem) => ( + + + {match(isFocused) + .with(true, () => `${symbols.pointer} `) + .with(false, () => ' ') + .exhaustive()} + {match(isSelected) + .with(true, () => `${symbols.checkboxOn} `) + .with(false, () => `${symbols.checkboxOff} `) + .exhaustive()} + {groupItem.groupName} + + + )) + .with({ kind: 'option' }, (optionItem) => { + const { option } = optionItem + const disabled = isDisabled || (option.disabled ?? false) + return ( + + 'gray' as const) + .with({ isFocused: true }, () => colors.primary) + .otherwise(() => undefined)} + > + {match(isFocused) + .with(true, () => `${symbols.pointer} `) + .with(false, () => ' ') + .exhaustive()} + {match(isSelected) + .with(true, () => `${symbols.checkboxOn} `) + .with(false, () => `${symbols.checkboxOff} `) + .exhaustive()} + {option.label} + {match(disabled && !picocolors.isColorSupported) + .with(true, () => ' (disabled)') + .with(false, () => '') + .exhaustive()} + + {match(option.hint) + .with(undefined, () => null) + .otherwise((hint) => ( + 'gray' as const) + .with(false, () => undefined) + .exhaustive()} + dimColor + > + {' '} + {hint} + + ))} + + ) + }) + .exhaustive() +} diff --git a/packages/core/src/ui/prompts/index.ts b/packages/core/src/ui/prompts/index.ts new file mode 100644 index 00000000..6eb169a9 --- /dev/null +++ b/packages/core/src/ui/prompts/index.ts @@ -0,0 +1,28 @@ +export type { PromptOption } from './types.js' + +export { Autocomplete } from './autocomplete.js' +export type { AutocompleteProps } from './autocomplete.js' + +export { Confirm } from './confirm.js' +export type { ConfirmProps } from './confirm.js' + +export { GroupMultiSelect } from './group-multi-select.js' +export type { GroupMultiSelectProps } from './group-multi-select.js' + +export { MultiSelect } from './multi-select.js' +export type { MultiSelectProps } from './multi-select.js' + +export { PasswordInput } from './password-input.js' +export type { PasswordInputProps } from './password-input.js' + +export { PathInput } from './path-input.js' +export type { PathInputProps } from './path-input.js' + +export { Select } from './select.js' +export type { SelectProps } from './select.js' + +export { SelectKey } from './select-key.js' +export type { SelectKeyProps } from './select-key.js' + +export { TextInput } from './text-input.js' +export type { TextInputProps } from './text-input.js' diff --git a/packages/core/src/ui/prompts/input-state.ts b/packages/core/src/ui/prompts/input-state.ts new file mode 100644 index 00000000..4d09b8d9 --- /dev/null +++ b/packages/core/src/ui/prompts/input-state.ts @@ -0,0 +1,97 @@ +import { match } from 'ts-pattern' + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** + * Internal state for a text-based input component. + */ +export interface InputState { + readonly value: string + readonly cursor: number + readonly error: string | undefined +} + +/** + * Input key descriptor used for state resolution. + */ +export interface KeyDescriptor { + readonly leftArrow: boolean + readonly rightArrow: boolean + readonly backspace: boolean + readonly delete: boolean + readonly return: boolean + readonly meta: boolean + readonly ctrl: boolean +} + +/** + * Options for resolving the next input state. + */ +export interface ResolveStateOptions { + readonly state: InputState + readonly input: string + readonly key: KeyDescriptor +} + +// --------------------------------------------------------------------------- +// Exports +// --------------------------------------------------------------------------- + +/** + * Compute the next input state from a keyboard event. + * + * Handles cursor movement (left/right, home/end), character deletion + * (backspace/delete), and character insertion. + * + * @param options - The state resolution options. + * @returns The next input state. + */ +export function resolveNextState({ state, input, key }: ResolveStateOptions): InputState { + if (key.leftArrow) { + return { ...state, cursor: Math.max(0, state.cursor - 1) } + } + + if (key.rightArrow) { + return { ...state, cursor: Math.min(state.value.length, state.cursor + 1) } + } + + if (key.ctrl && input === 'a') { + return { ...state, cursor: 0 } + } + + if (key.ctrl && input === 'e') { + return { ...state, cursor: state.value.length } + } + + if (key.backspace || key.delete) { + return match(key.backspace) + .with(true, () => { + if (state.cursor === 0) { + return state + } + const nextValue = state.value.slice(0, state.cursor - 1) + state.value.slice(state.cursor) + return { ...state, value: nextValue, cursor: state.cursor - 1, error: undefined } + }) + .with(false, () => { + if (state.cursor >= state.value.length) { + return state + } + const nextValue = state.value.slice(0, state.cursor) + state.value.slice(state.cursor + 1) + return { ...state, value: nextValue, error: undefined } + }) + .exhaustive() + } + + if (key.ctrl || key.meta) { + return state + } + + if (input.length === 0 || key.return) { + return state + } + + const nextValue = state.value.slice(0, state.cursor) + input + state.value.slice(state.cursor) + return { ...state, value: nextValue, cursor: state.cursor + input.length, error: undefined } +} diff --git a/packages/core/src/ui/prompts/multi-select.stories.tsx b/packages/core/src/ui/prompts/multi-select.stories.tsx new file mode 100644 index 00000000..19dab5c7 --- /dev/null +++ b/packages/core/src/ui/prompts/multi-select.stories.tsx @@ -0,0 +1,66 @@ +import { stories } from '@kidd-cli/core/stories' +import type { StoryGroup } from '@kidd-cli/core/stories' +import type { ComponentType } from 'react' +import { z } from 'zod' + +import { MultiSelect } from './multi-select.js' + +const schema = z.object({ + maxVisible: z.number().describe('Max visible options'), + required: z.boolean().describe('Require at least one selection'), + isDisabled: z.boolean().describe('Disable interaction'), +}) + +const storyGroup: StoryGroup = stories({ + title: 'MultiSelect', + component: MultiSelect as unknown as ComponentType>, + schema, + defaults: { + options: [ + { value: 'ts', label: 'TypeScript' }, + { value: 'eslint', label: 'ESLint' }, + { value: 'prettier', label: 'Prettier' }, + { value: 'tailwind', label: 'Tailwind', disabled: true }, + ], + onSubmit: (_v: readonly string[]) => {}, + }, + stories: { + Default: { + props: { maxVisible: 5, required: false, isDisabled: false }, + description: 'Standard multi-select with default settings', + }, + WithRequired: { + props: { maxVisible: 5, required: true, isDisabled: false }, + description: 'Multi-select that requires at least one selection', + }, + DisabledOptions: { + props: { + maxVisible: 5, + required: false, + isDisabled: false, + options: [ + { value: 'a', label: 'Available' }, + { value: 'b', label: 'Blocked', disabled: true }, + { value: 'c', label: 'Also available' }, + { value: 'd', label: 'Also blocked', disabled: true }, + ], + }, + description: 'Mix of enabled and disabled options', + }, + Preselected: { + props: { + maxVisible: 5, + required: false, + isDisabled: false, + defaultValue: ['ts', 'prettier'], + }, + description: 'Pre-selected values on mount', + }, + Disabled: { + props: { maxVisible: 5, required: false, isDisabled: true }, + description: 'Fully disabled multi-select', + }, + }, +}) + +export default storyGroup diff --git a/packages/core/src/ui/prompts/multi-select.tsx b/packages/core/src/ui/prompts/multi-select.tsx new file mode 100644 index 00000000..b0da49c7 --- /dev/null +++ b/packages/core/src/ui/prompts/multi-select.tsx @@ -0,0 +1,206 @@ +import { Box, useInput } from 'ink' +import type { ReactElement } from 'react' +import { useState } from 'react' +import { match } from 'ts-pattern' + +import { ErrorMessage } from '../display/error-message.js' +import { ScrollArea } from '../layout/scroll-area.js' +import { symbols } from '../theme.js' +import { resolveDirection, resolveFirstEnabledIndex, resolveNextFocusIndex } from './navigation.js' +import { OptionRow } from './option-row.js' +import type { PromptOption } from './types.js' + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** + * Props for the {@link MultiSelect} component. + * + * @typeParam TValue - The type of each option's value. + */ +export interface MultiSelectProps { + /** The list of selectable options. */ + readonly options: readonly PromptOption[] + + /** The initially selected values. */ + readonly defaultValue?: readonly TValue[] + + /** Maximum number of visible options before scrolling. */ + readonly maxVisible?: number + + /** When `true`, at least one option must be selected to submit. */ + readonly required?: boolean + + /** Callback fired when the selection changes. */ + readonly onChange?: (value: readonly TValue[]) => void + + /** Callback fired when the selection is submitted via Enter. */ + readonly onSubmit?: (value: readonly TValue[]) => void + + /** When `true`, the component ignores all keyboard input. */ + readonly isDisabled?: boolean +} + +// --------------------------------------------------------------------------- +// Exports +// --------------------------------------------------------------------------- + +/** + * A multi-value select prompt with checkbox indicators and keyboard navigation. + * + * Renders a vertical list of options with checkbox-style indicators. Users + * toggle selections with Space and submit with Enter. Disabled options are + * rendered dimmed with strikethrough and cannot be toggled. + * + * **Keyboard shortcuts:** + * - Up/Down arrows — navigate between enabled options + * - Space — toggle the focused option + * - Enter — submit the current selection + * + * @typeParam TValue - The type of each option's value. + * @param props - The multi-select component props. + * @returns A rendered multi-select element. + */ +export function MultiSelect({ + options, + defaultValue, + maxVisible = 5, + required = false, + onChange, + onSubmit, + isDisabled = false, +}: MultiSelectProps): ReactElement { + const initialSelected = resolveInitialSelected(options, defaultValue) + const [focusedIndex, setFocusedIndex] = useState(resolveFirstEnabledIndex(options)) + const [selectedIndices, setSelectedIndices] = useState>(initialSelected) + const [validationError, setValidationError] = useState(undefined) + + useInput( + (input, key) => { + if (key.return) { + if (required && selectedIndices.size === 0) { + setValidationError('At least one option must be selected') + return + } + setValidationError(undefined) + const selectedValues = resolveSelectedValues(options, selectedIndices) + if (onSubmit) { + onSubmit(selectedValues) + } + return + } + + if (input === ' ') { + const option = options[focusedIndex] + if (option && option.disabled !== true) { + const nextSelected = toggleIndex(selectedIndices, focusedIndex) + setSelectedIndices(nextSelected) + setValidationError(undefined) + if (onChange) { + onChange(resolveSelectedValues(options, nextSelected)) + } + } + return + } + + const nextIndex = resolveNextFocusIndex({ + options, + currentIndex: focusedIndex, + direction: resolveDirection(key), + }) + + if (nextIndex !== focusedIndex) { + setFocusedIndex(nextIndex) + } + }, + { isActive: !isDisabled } + ) + + return ( + + + {options.map((option, index) => { + const isSelected = selectedIndices.has(index) + const indicator = match(isSelected) + .with(true, () => symbols.checkboxOn) + .with(false, () => symbols.checkboxOff) + .exhaustive() + + return ( + + ) + })} + + + + ) +} + +// --------------------------------------------------------------------------- +// Private +// --------------------------------------------------------------------------- + +/** + * Toggle an index in a readonly set, returning a new set. + * + * @private + * @param set - The current selection set. + * @param index - The index to toggle. + * @returns A new set with the index toggled. + */ +function toggleIndex(set: ReadonlySet, index: number): ReadonlySet { + const next = new Set(set) + if (next.has(index)) { + next.delete(index) + } else { + next.add(index) + } + return next +} + +/** + * Resolve the initially selected indices from default values. + * + * @private + * @param options - The option list. + * @param defaultValue - The default selected values. + * @returns A set of selected indices. + */ +function resolveInitialSelected( + options: readonly PromptOption[], + defaultValue: readonly TValue[] | undefined +): ReadonlySet { + if (defaultValue === undefined) { + return new Set() + } + + const indices = options + .map((option, index) => ({ option, index })) + .filter(({ option }) => defaultValue.includes(option.value)) + .map(({ index }) => index) + + return new Set(indices) +} + +/** + * Extract the values from selected indices. + * + * @private + * @param options - The option list. + * @param selectedIndices - The set of selected indices. + * @returns An array of selected values. + */ +function resolveSelectedValues( + options: readonly PromptOption[], + selectedIndices: ReadonlySet +): readonly TValue[] { + return options.filter((_, index) => selectedIndices.has(index)).map((option) => option.value) +} diff --git a/packages/core/src/ui/prompts/navigation.ts b/packages/core/src/ui/prompts/navigation.ts new file mode 100644 index 00000000..bedded26 --- /dev/null +++ b/packages/core/src/ui/prompts/navigation.ts @@ -0,0 +1,138 @@ +import { match } from 'ts-pattern' + +import type { PromptOption } from './types.js' + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** + * Direction of navigation input. + */ +export type Direction = 'up' | 'down' | 'none' + +/** + * Options for computing the next focus index. + */ +export interface NextFocusOptions { + readonly options: readonly PromptOption[] + readonly currentIndex: number + readonly direction: Direction +} + +// --------------------------------------------------------------------------- +// Exports +// --------------------------------------------------------------------------- + +/** + * Resolve a keyboard event to a navigation direction. + * + * @param key - The Ink key object with arrow properties. + * @returns The resolved direction. + */ +export function resolveDirection(key: { + readonly upArrow: boolean + readonly downArrow: boolean +}): Direction { + return match(key) + .with({ upArrow: true }, () => 'up' as const) + .with({ downArrow: true }, () => 'down' as const) + .otherwise(() => 'none' as const) +} + +/** + * Compute the next focusable option index, skipping disabled options. + * + * @param opts - The navigation options. + * @returns The next valid focus index. + */ +export function resolveNextFocusIndex({ + options, + currentIndex, + direction, +}: NextFocusOptions): number { + if (direction === 'none') { + return currentIndex + } + + const step = match(direction) + .with('up', () => -1) + .with('down', () => 1) + .exhaustive() + + return findNextEnabledIndex(options, currentIndex, step) +} + +/** + * Find the first non-disabled option index. + * + * @param options - The option list. + * @returns The first enabled index, or 0 if none found. + */ +export function resolveFirstEnabledIndex(options: readonly PromptOption[]): number { + const index = options.findIndex((o) => o.disabled !== true) + return Math.max(0, index) +} + +/** + * Options for resolving the initial focus index. + */ +export interface ResolveInitialIndexOptions { + readonly options: readonly PromptOption[] + readonly defaultValue: TValue | undefined +} + +/** + * Resolve the initial focus index from a default value. Falls back to + * the first non-disabled option if no match is found. + * + * @param opts - The resolution options. + * @returns The initial focus index. + */ +export function resolveInitialIndex({ + options, + defaultValue, +}: ResolveInitialIndexOptions): number { + if (defaultValue !== undefined) { + const matchIndex = options.findIndex((o) => o.value === defaultValue) + if (matchIndex !== -1) { + return matchIndex + } + } + + return resolveFirstEnabledIndex(options) +} + +// --------------------------------------------------------------------------- +// Private +// --------------------------------------------------------------------------- + +/** + * Walk through options in the given step direction to find the next + * non-disabled index. Returns the current index if no enabled option + * is found. + * + * @private + * @param options - The option list. + * @param startIndex - The index to start searching from. + * @param step - The step direction (-1 or 1). + * @returns The next enabled index, or the start index if none found. + */ +function findNextEnabledIndex( + options: readonly PromptOption[], + startIndex: number, + step: number +): number { + const count = options.length + const indices = Array.from({ length: count - 1 }, (_, i) => { + const candidate = startIndex + (i + 1) * step + return ((candidate % count) + count) % count + }) + + const found = indices.find((i) => { + const opt = options[i] + return opt !== undefined && opt.disabled !== true + }) + + return found ?? startIndex +} diff --git a/packages/core/src/ui/prompts/option-row.tsx b/packages/core/src/ui/prompts/option-row.tsx new file mode 100644 index 00000000..865c1a71 --- /dev/null +++ b/packages/core/src/ui/prompts/option-row.tsx @@ -0,0 +1,100 @@ +import { Box, Text } from 'ink' +import picocolors from 'picocolors' +import type { ReactElement } from 'react' +import { match } from 'ts-pattern' + +import { colors, symbols } from '../theme.js' +import type { PromptOption } from './types.js' + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** + * Props for the {@link OptionRow} component. + */ +export interface OptionRowProps { + /** The option to render. */ + readonly option: PromptOption + + /** The indicator symbol (e.g. radio or checkbox). */ + readonly indicator: string + + /** Whether this row is currently focused by the cursor. */ + readonly isFocused: boolean + + /** Whether this row is the selected/checked option. */ + readonly isSelected: boolean + + /** Whether the entire prompt is disabled. */ + readonly isDisabled: boolean +} + +// --------------------------------------------------------------------------- +// Exports +// --------------------------------------------------------------------------- + +/** + * Render a single option row with pointer, indicator, label, and hint. + * + * Shared by Select, MultiSelect, and similar prompt components to ensure + * consistent styling and keyboard focus behavior. + * + * @param props - The option row props. + * @returns A rendered option row element. + */ +export function OptionRow({ + option, + indicator, + isFocused, + isSelected, + isDisabled, +}: OptionRowProps): ReactElement { + const isOptionDisabled = option.disabled === true || isDisabled + + return ( + + + {match(isFocused) + .with(true, () => `${symbols.pointer} `) + .with(false, () => ' ') + .exhaustive()} + + 'gray' as const) + .with({ isSelected: true }, () => colors.primary) + .with({ isFocused: true }, () => colors.primary) + .otherwise(() => undefined)} + > + {indicator} + + + 'gray' as const) + .with({ isFocused: true }, () => colors.primary) + .otherwise(() => undefined)} + > + {option.label} + {match(isOptionDisabled && !picocolors.isColorSupported) + .with(true, () => ' (disabled)') + .with(false, () => '') + .exhaustive()} + + {match(option.hint) + .with(undefined, () => null) + .otherwise((hint) => ( + 'gray' as const) + .with(false, () => undefined) + .exhaustive()} + dimColor + > + {` ${hint}`} + + ))} + + ) +} diff --git a/packages/core/src/ui/prompts/password-input.stories.tsx b/packages/core/src/ui/prompts/password-input.stories.tsx new file mode 100644 index 00000000..5de4abdf --- /dev/null +++ b/packages/core/src/ui/prompts/password-input.stories.tsx @@ -0,0 +1,51 @@ +import { stories } from '@kidd-cli/core/stories' +import type { StoryGroup } from '@kidd-cli/core/stories' +import type { ComponentType } from 'react' +import { z } from 'zod' + +import { PasswordInput } from './password-input.js' + +const schema = z.object({ + placeholder: z.string().describe('Placeholder text'), + mask: z.string().describe('Mask character'), + isDisabled: z.boolean().describe('Disable interaction'), +}) + +const storyGroup: StoryGroup = stories({ + title: 'PasswordInput', + component: PasswordInput as unknown as ComponentType>, + schema, + defaults: { + onSubmit: (_v: string) => {}, + }, + stories: { + Default: { + props: { placeholder: 'Enter password...', mask: '*', isDisabled: false }, + description: 'Standard password input with asterisk mask', + }, + CustomMask: { + props: { placeholder: 'Enter secret...', mask: '#', isDisabled: false }, + description: 'Password input with custom hash mask character', + }, + WithValidation: { + props: { + placeholder: 'Enter at least 8 characters...', + mask: '*', + isDisabled: false, + validate: (value: string) => { + if (value.length < 8) { + return 'Password must be at least 8 characters' + } + return undefined + }, + }, + description: 'Password input with minimum length validation', + }, + Disabled: { + props: { placeholder: 'Disabled input', mask: '*', isDisabled: true }, + description: 'Fully disabled password input', + }, + }, +}) + +export default storyGroup diff --git a/packages/core/src/ui/prompts/password-input.tsx b/packages/core/src/ui/prompts/password-input.tsx new file mode 100644 index 00000000..cc800c65 --- /dev/null +++ b/packages/core/src/ui/prompts/password-input.tsx @@ -0,0 +1,115 @@ +import { Box, Text, useInput } from 'ink' +import type { ReactElement } from 'react' +import { useState } from 'react' +import { match } from 'ts-pattern' + +import { ErrorMessage } from '../display/error-message.js' +import { CursorValue } from './cursor-value.js' +import type { InputState } from './input-state.js' +import { resolveNextState } from './input-state.js' + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** + * Props for the {@link PasswordInput} component. + */ +export interface PasswordInputProps { + /** Placeholder text shown dimmed when the input is empty. */ + readonly placeholder?: string + + /** The mask character used to hide input. @default "*" */ + readonly mask?: string + + /** Validation function. Return an error message string to show, or undefined if valid. */ + readonly validate?: (value: string) => string | undefined + + /** Callback fired when the input value changes. */ + readonly onChange?: (value: string) => void + + /** Callback fired when the value is submitted via Enter. */ + readonly onSubmit?: (value: string) => void + + /** When `true`, the component ignores all keyboard input. */ + readonly isDisabled?: boolean +} + +// --------------------------------------------------------------------------- +// Exports +// --------------------------------------------------------------------------- + +/** + * A masked single-line text input prompt with cursor movement and validation. + * + * Renders the current value with each character replaced by the mask + * character. Shows a visible cursor (inverse character) at the current + * position. Placeholder text is shown dimmed when the input is empty. + * Validation errors appear below the input in red. + * + * **Keyboard shortcuts:** + * - Left/Right arrows — move cursor + * - Home (Ctrl+A) / End (Ctrl+E) — jump to start/end + * - Backspace — delete character before cursor + * - Delete (Ctrl+D) — delete character at cursor + * - Enter — validate and submit + * + * @param props - The password input component props. + * @returns A rendered password input element. + */ +export function PasswordInput({ + placeholder, + mask = '*', + validate, + onChange, + onSubmit, + isDisabled = false, +}: PasswordInputProps): ReactElement { + const [state, setState] = useState({ + value: '', + cursor: 0, + error: undefined, + }) + + useInput( + (input, key) => { + if (key.return) { + const validationError = match(validate) + .with(undefined, () => undefined) + .otherwise((fn) => fn(state.value)) + if (validationError) { + setState({ ...state, error: validationError }) + return + } + setState({ ...state, error: undefined }) + if (onSubmit) { + onSubmit(state.value) + } + return + } + + const nextState = resolveNextState({ state, input, key }) + if (nextState.value !== state.value || nextState.cursor !== state.cursor) { + setState(nextState) + if (nextState.value !== state.value && onChange) { + onChange(nextState.value) + } + } + }, + { isActive: !isDisabled } + ) + + const maskedValue = mask.repeat(state.value.length) + + return ( + + {match(state.value.length === 0 && placeholder !== undefined) + .with(true, () => {placeholder}) + .with(false, () => ( + + )) + .exhaustive()} + + + ) +} diff --git a/packages/core/src/ui/prompts/path-input.stories.tsx b/packages/core/src/ui/prompts/path-input.stories.tsx new file mode 100644 index 00000000..e64d6166 --- /dev/null +++ b/packages/core/src/ui/prompts/path-input.stories.tsx @@ -0,0 +1,51 @@ +import { stories } from '@kidd-cli/core/stories' +import type { StoryGroup } from '@kidd-cli/core/stories' +import type { ComponentType } from 'react' +import { z } from 'zod' + +import { PathInput } from './path-input.js' + +const schema = z.object({ + root: z.string().optional().describe('Root directory for completions'), + directoryOnly: z.boolean().optional().describe('Only suggest directories'), + defaultValue: z.string().optional().describe('Initial input value'), + isDisabled: z.boolean().optional().describe('Disable the component'), +}) + +const storyGroup: StoryGroup = stories({ + title: 'PathInput', + component: PathInput as unknown as ComponentType>, + schema, + defaults: { + onChange: (value: unknown) => { + void value + }, + onSubmit: (value: unknown) => { + void value + }, + }, + stories: { + Default: { + props: { root: process.cwd() }, + description: 'Default path input with tab-completion from the current directory.', + }, + DirectoryOnly: { + props: { root: process.cwd(), directoryOnly: true }, + description: 'Path input that only suggests and accepts directories.', + }, + WithValidation: { + props: { + root: process.cwd(), + validate: (value: string) => { + if (value === '') { + return 'Path is required.' + } + return undefined + }, + }, + description: 'Path input with validation that requires a non-empty value.', + }, + }, +}) + +export default storyGroup diff --git a/packages/core/src/ui/prompts/path-input.tsx b/packages/core/src/ui/prompts/path-input.tsx new file mode 100644 index 00000000..b11099ac --- /dev/null +++ b/packages/core/src/ui/prompts/path-input.tsx @@ -0,0 +1,293 @@ +import { readdirSync, statSync } from 'node:fs' +import { join, resolve } from 'node:path' + +import { Box, Text, useInput } from 'ink' +import type { ReactElement } from 'react' +import { useState } from 'react' +import { match } from 'ts-pattern' + +import { ErrorMessage } from '../display/error-message.js' +import { colors } from '../theme.js' +import { insertCharAt, removeCharAt } from './string-utils.js' + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const MAX_SUGGESTIONS = 5 + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** + * Props for the {@link PathInput} component. + */ +export interface PathInputProps { + /** Root directory for completion lookups. Defaults to `process.cwd()`. */ + readonly root?: string + + /** When `true`, only directories are suggested and accepted. */ + readonly directoryOnly?: boolean + + /** Initial value for the input. */ + readonly defaultValue?: string + + /** Validation function. Return a string message on error, or `undefined` on success. */ + readonly validate?: (value: string) => string | undefined + + /** Called whenever the input value changes. */ + readonly onChange?: (value: string) => void + + /** Called when the user presses Enter to confirm. */ + readonly onSubmit?: (value: string) => void + + /** When `true`, the component does not respond to input. */ + readonly isDisabled?: boolean +} + +// --------------------------------------------------------------------------- +// Exports +// --------------------------------------------------------------------------- + +/** + * File path input with tab-completion from the filesystem. + * + * Renders a text input that reads directory entries from `root` on Tab + * press and cycles through matching completions. Up to 5 suggestions + * are shown below the input. Validation runs on Enter and errors are + * displayed in red. + * + * @param props - The path input props. + * @returns A rendered path input element. + */ +export function PathInput({ + root, + directoryOnly = false, + defaultValue = '', + validate, + onChange, + onSubmit, + isDisabled = false, +}: PathInputProps): ReactElement { + const resolvedRoot = root ?? process.cwd() + const [value, setValue] = useState(defaultValue) + const [cursorOffset, setCursorOffset] = useState(defaultValue.length) + const [suggestions, setSuggestions] = useState([]) + const [suggestionIndex, setSuggestionIndex] = useState(0) + const [error, setError] = useState(undefined) + + useInput( + (input, key) => { + if (key.tab) { + const matches = readCompletions({ + root: resolvedRoot, + partial: value, + directoryOnly, + }) + setSuggestions(matches) + if (matches.length > 0) { + const nextIndex = match(suggestions.length > 0) + .with(true, () => (suggestionIndex + 1) % matches.length) + .with(false, () => 0) + .exhaustive() + setSuggestionIndex(nextIndex) + const completed = matches[nextIndex] ?? value + setValue(completed) + setCursorOffset(completed.length) + setError(undefined) + if (onChange) { + onChange(completed) + } + } + return + } + + if (key.return) { + if (validate) { + const validationError = validate(value) + if (validationError !== undefined) { + setError(validationError) + return + } + } + setError(undefined) + if (onSubmit) { + onSubmit(value) + } + return + } + + if (key.leftArrow) { + setCursorOffset(Math.max(0, cursorOffset - 1)) + return + } + + if (key.rightArrow) { + setCursorOffset(Math.min(value.length, cursorOffset + 1)) + return + } + + if (key.backspace || key.delete) { + if (cursorOffset > 0) { + const nextValue = removeCharAt({ str: value, index: cursorOffset - 1 }) + setValue(nextValue) + setCursorOffset(cursorOffset - 1) + setSuggestions([]) + setSuggestionIndex(0) + setError(undefined) + if (onChange) { + onChange(nextValue) + } + } + return + } + + if (input && !key.ctrl && !key.meta) { + const nextValue = insertCharAt({ str: value, index: cursorOffset, chars: input }) + setValue(nextValue) + setCursorOffset(cursorOffset + input.length) + setSuggestions([]) + setSuggestionIndex(0) + setError(undefined) + if (onChange) { + onChange(nextValue) + } + } + }, + { isActive: !isDisabled } + ) + + return ( + + + + {'> '} + + {value} + + {match(suggestions.length > 0) + .with(true, () => ( + + {suggestions.slice(0, MAX_SUGGESTIONS).map((suggestion, index) => ( + colors.primary) + .with(false, () => undefined) + .exhaustive()} + > + {suggestion} + + ))} + + )) + .with(false, () => null) + .exhaustive()} + + + ) +} + +// --------------------------------------------------------------------------- +// Private +// --------------------------------------------------------------------------- + +/** + * Options for reading filesystem completions. + * + * @private + */ +interface ReadCompletionsOptions { + readonly root: string + readonly partial: string + readonly directoryOnly: boolean +} + +/** + * Read directory entries matching a partial path from the filesystem. + * + * @private + * @param opts - The completion options. + * @returns An array of matching path strings relative to root. + */ +function readCompletions({ + root, + partial, + directoryOnly, +}: ReadCompletionsOptions): readonly string[] { + const [dirPart, prefix] = splitPartial(partial) + const targetDir = resolve(root, dirPart) + + const [readError, entries] = safeReaddir(targetDir) + if (readError !== null) { + return [] + } + + const filtered = entries.filter((entry) => { + if (!entry.toLowerCase().startsWith(prefix.toLowerCase())) { + return false + } + if (directoryOnly) { + return isDirectory(join(targetDir, entry)) + } + return true + }) + + return filtered.map((entry) => + match(dirPart) + .with('', () => entry) + .otherwise((dir) => `${dir}/${entry}`) + ) +} + +/** + * Split a partial path into the directory portion and the filename prefix. + * + * @private + * @param partial - The partial path string. + * @returns A tuple of [directory, prefix]. + */ +function splitPartial(partial: string): readonly [string, string] { + const lastSlash = partial.lastIndexOf('/') + if (lastSlash === -1) { + return ['', partial] + } + return [partial.slice(0, lastSlash), partial.slice(lastSlash + 1)] +} + +/** + * Safely read a directory, returning a Result tuple. + * + * @private + * @param dir - The directory path. + * @returns A Result tuple with entries or an error. + */ +function safeReaddir(dir: string): readonly [Error, null] | readonly [null, readonly string[]] { + try { + const entries = readdirSync(dir) + return [null, entries] + } catch (error: unknown) { + const resolvedError = match(error instanceof Error) + .with(true, () => error as Error) + .with(false, () => new Error(String(error))) + .exhaustive() + return [resolvedError, null] + } +} + +/** + * Check whether a path is a directory. + * + * @private + * @param path - The filesystem path. + * @returns Whether the path is a directory. + */ +function isDirectory(path: string): boolean { + try { + return statSync(path).isDirectory() + } catch { + return false + } +} diff --git a/packages/core/src/ui/prompts/select-key.stories.tsx b/packages/core/src/ui/prompts/select-key.stories.tsx new file mode 100644 index 00000000..4fb8f687 --- /dev/null +++ b/packages/core/src/ui/prompts/select-key.stories.tsx @@ -0,0 +1,41 @@ +import { stories } from '@kidd-cli/core/stories' +import type { StoryGroup } from '@kidd-cli/core/stories' +import type { ComponentType } from 'react' +import { z } from 'zod' + +import { SelectKey } from './select-key.js' + +const schema = z.object({ + isDisabled: z.boolean().optional().describe('Disable the component'), +}) + +const defaultOptions = [ + { value: 'y', label: 'Yes', hint: 'Confirm action' }, + { value: 'n', label: 'No', hint: 'Cancel action' }, + { value: 'a', label: 'Always' }, + { value: 's', label: 'Skip', disabled: true }, +] + +const storyGroup: StoryGroup = stories({ + title: 'SelectKey', + component: SelectKey as unknown as ComponentType>, + schema, + defaults: { + options: defaultOptions, + onSubmit: (value: unknown) => { + void value + }, + }, + stories: { + Default: { + props: {}, + description: 'Key-press driven selection with highlighted key characters.', + }, + Disabled: { + props: { isDisabled: true }, + description: 'Select-key in disabled state.', + }, + }, +}) + +export default storyGroup diff --git a/packages/core/src/ui/prompts/select-key.tsx b/packages/core/src/ui/prompts/select-key.tsx new file mode 100644 index 00000000..519b57e9 --- /dev/null +++ b/packages/core/src/ui/prompts/select-key.tsx @@ -0,0 +1,132 @@ +import { Box, Text, useInput } from 'ink' +import type { ReactElement } from 'react' +import { match } from 'ts-pattern' + +import { colors } from '../theme.js' +import type { PromptOption } from './types.js' + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** + * Props for the {@link SelectKey} component. + */ +export interface SelectKeyProps { + /** Options where each `value` is a single key character. */ + readonly options: readonly PromptOption[] + + /** Called when the user presses a matching key. */ + readonly onSubmit?: (value: TValue) => void + + /** When `true`, the component does not respond to input. */ + readonly isDisabled?: boolean +} + +// --------------------------------------------------------------------------- +// Exports +// --------------------------------------------------------------------------- + +/** + * Key-press driven select prompt where each option maps to a single key. + * + * Renders options in a vertical list with the key character highlighted + * in cyan and bold. Pressing the corresponding key immediately fires + * `onSubmit`. Disabled options are shown dimmed and their key presses + * are ignored. + * + * @param props - The select-key props. + * @returns A rendered select-key element. + */ +export function SelectKey({ + options, + onSubmit, + isDisabled = false, +}: SelectKeyProps): ReactElement { + useInput( + (input) => { + const matched = options.find((opt) => opt.value.toLowerCase() === input.toLowerCase()) + if (matched === undefined || matched.disabled) { + return + } + if (onSubmit) { + onSubmit(matched.value) + } + }, + { isActive: !isDisabled } + ) + + return ( + + {options.map((option) => ( + + ))} + + ) +} + +// --------------------------------------------------------------------------- +// Private +// --------------------------------------------------------------------------- + +/** + * Props for the {@link KeyOptionRow} component. + * + * @private + */ +interface KeyOptionRowProps { + readonly option: PromptOption + readonly isDisabled: boolean +} + +/** + * Render a single option row with the key character highlighted. + * + * @private + * @param props - The row props. + * @returns A rendered row element. + */ +function KeyOptionRow({ option, isDisabled }: KeyOptionRowProps): ReactElement { + const disabled = isDisabled || (option.disabled ?? false) + const keyChar = option.value.charAt(0) + const restLabel = option.label.slice(1) + const startsWithKey = option.label.charAt(0).toLowerCase() === keyChar.toLowerCase() + + return ( + + {match(disabled) + .with(true, () => ( + + {' '}[{keyChar}] {option.label} + + )) + .with(false, () => ( + + {' '} + [ + + {keyChar} + + ] + {match(startsWithKey) + .with(true, () => ( + + + {option.label.charAt(0)} + + {restLabel} + + )) + .with(false, () => {option.label}) + .exhaustive()} + + )) + .exhaustive()} + {match(option.hint) + .with(undefined, () => null) + .otherwise((hint) => ( + {hint} + ))} + + ) +} diff --git a/packages/core/src/ui/prompts/select.stories.tsx b/packages/core/src/ui/prompts/select.stories.tsx new file mode 100644 index 00000000..2fa067ec --- /dev/null +++ b/packages/core/src/ui/prompts/select.stories.tsx @@ -0,0 +1,79 @@ +import { stories } from '@kidd-cli/core/stories' +import type { StoryGroup } from '@kidd-cli/core/stories' +import type { ComponentType } from 'react' +import { z } from 'zod' + +import { Select } from './select.js' + +const schema = z.object({ + maxVisible: z.number().describe('Max visible options'), + isDisabled: z.boolean().describe('Disable interaction'), +}) + +const storyGroup: StoryGroup = stories({ + title: 'Select', + component: Select as unknown as ComponentType>, + schema, + defaults: { + options: [ + { value: 'next', label: 'Next.js', hint: 'React framework' }, + { value: 'remix', label: 'Remix' }, + { value: 'astro', label: 'Astro', hint: 'Content-focused' }, + { value: 'nuxt', label: 'Nuxt', disabled: true }, + ], + onSubmit: (_v: string) => {}, + }, + stories: { + Default: { + props: { maxVisible: 5, isDisabled: false }, + description: 'Standard select with default settings', + }, + WithHints: { + props: { + maxVisible: 5, + isDisabled: false, + options: [ + { value: 'ts', label: 'TypeScript', hint: 'Strongly typed' }, + { value: 'js', label: 'JavaScript', hint: 'Dynamic typing' }, + { value: 'rs', label: 'Rust', hint: 'Systems language' }, + { value: 'go', label: 'Go', hint: 'Concurrency built-in' }, + ], + }, + description: 'All options with hint text', + }, + DisabledOptions: { + props: { + maxVisible: 5, + isDisabled: false, + options: [ + { value: 'a', label: 'Available' }, + { value: 'b', label: 'Blocked', disabled: true }, + { value: 'c', label: 'Also available' }, + { value: 'd', label: 'Also blocked', disabled: true }, + ], + }, + description: 'Mix of enabled and disabled options', + }, + Scrolling: { + props: { + maxVisible: 3, + isDisabled: false, + options: [ + { value: '1', label: 'Option 1' }, + { value: '2', label: 'Option 2' }, + { value: '3', label: 'Option 3' }, + { value: '4', label: 'Option 4' }, + { value: '5', label: 'Option 5' }, + { value: '6', label: 'Option 6' }, + ], + }, + description: 'Scrollable list with maxVisible=3', + }, + Disabled: { + props: { maxVisible: 5, isDisabled: true }, + description: 'Fully disabled select', + }, + }, +}) + +export default storyGroup diff --git a/packages/core/src/ui/prompts/select.tsx b/packages/core/src/ui/prompts/select.tsx new file mode 100644 index 00000000..25b63779 --- /dev/null +++ b/packages/core/src/ui/prompts/select.tsx @@ -0,0 +1,124 @@ +import { useInput } from 'ink' +import type { ReactElement } from 'react' +import { useState } from 'react' +import { match } from 'ts-pattern' + +import { ScrollArea } from '../layout/scroll-area.js' +import { symbols } from '../theme.js' +import { resolveDirection, resolveInitialIndex, resolveNextFocusIndex } from './navigation.js' +import { OptionRow } from './option-row.js' +import type { PromptOption } from './types.js' + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** + * Props for the {@link Select} component. + * + * @typeParam TValue - The type of each option's value. + */ +export interface SelectProps { + /** The list of selectable options. */ + readonly options: readonly PromptOption[] + + /** The initially selected value. */ + readonly defaultValue?: TValue + + /** Maximum number of visible options before scrolling. */ + readonly maxVisible?: number + + /** Callback fired when the focused option changes. */ + readonly onChange?: (value: TValue) => void + + /** Callback fired when an option is submitted via Enter. */ + readonly onSubmit?: (value: TValue) => void + + /** When `true`, the component ignores all keyboard input. */ + readonly isDisabled?: boolean +} + +// --------------------------------------------------------------------------- +// Exports +// --------------------------------------------------------------------------- + +/** + * A single-value select prompt with keyboard navigation and scroll support. + * + * Renders a vertical list of options with radio-style indicators. The focused + * option is highlighted with a pointer and cyan coloring. Disabled options are + * rendered dimmed with strikethrough and are skipped during navigation. + * + * **Keyboard shortcuts:** + * - Up/Down arrows — navigate between enabled options + * - Enter — submit the focused option + * + * @typeParam TValue - The type of each option's value. + * @param props - The select component props. + * @returns A rendered select element. + */ +export function Select({ + options, + defaultValue, + maxVisible = 5, + onChange, + onSubmit, + isDisabled = false, +}: SelectProps): ReactElement { + const initialIndex = resolveInitialIndex({ options, defaultValue }) + const [focusedIndex, setFocusedIndex] = useState(initialIndex) + const [selectedIndex, setSelectedIndex] = useState(initialIndex) + + useInput( + (_input, key) => { + if (key.return) { + const option = options[focusedIndex] + if (option && !option.disabled) { + setSelectedIndex(focusedIndex) + if (onSubmit) { + onSubmit(option.value) + } + } + return + } + + const nextIndex = resolveNextFocusIndex({ + options, + currentIndex: focusedIndex, + direction: resolveDirection(key), + }) + + if (nextIndex !== focusedIndex) { + setFocusedIndex(nextIndex) + const nextOption = options[nextIndex] + if (nextOption && onChange) { + onChange(nextOption.value) + } + } + }, + { isActive: !isDisabled } + ) + + return ( + + {options.map((option, index) => { + const isSelected = index === selectedIndex + const indicator = match(isSelected) + .with(true, () => symbols.radioOn) + .with(false, () => symbols.radioOff) + .exhaustive() + + return ( + + ) + })} + + ) +} diff --git a/packages/core/src/ui/prompts/string-utils.ts b/packages/core/src/ui/prompts/string-utils.ts new file mode 100644 index 00000000..4042d320 --- /dev/null +++ b/packages/core/src/ui/prompts/string-utils.ts @@ -0,0 +1,44 @@ +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** + * Options for removing a character from a string. + */ +export interface RemoveCharAtOptions { + readonly str: string + readonly index: number +} + +/** + * Options for inserting characters into a string. + */ +export interface InsertCharAtOptions { + readonly str: string + readonly index: number + readonly chars: string +} + +// --------------------------------------------------------------------------- +// Exports +// --------------------------------------------------------------------------- + +/** + * Remove a character at the given position in a string. + * + * @param opts - The removal options. + * @returns The string with the character removed. + */ +export function removeCharAt({ str, index }: RemoveCharAtOptions): string { + return str.slice(0, index) + str.slice(index + 1) +} + +/** + * Insert a character sequence at the given position in a string. + * + * @param opts - The insertion options. + * @returns The string with the characters inserted. + */ +export function insertCharAt({ str, index, chars }: InsertCharAtOptions): string { + return str.slice(0, index) + chars + str.slice(index) +} diff --git a/packages/core/src/ui/prompts/text-input.stories.tsx b/packages/core/src/ui/prompts/text-input.stories.tsx new file mode 100644 index 00000000..26baca6c --- /dev/null +++ b/packages/core/src/ui/prompts/text-input.stories.tsx @@ -0,0 +1,51 @@ +import { stories } from '@kidd-cli/core/stories' +import type { StoryGroup } from '@kidd-cli/core/stories' +import type { ComponentType } from 'react' +import { z } from 'zod' + +import { TextInput } from './text-input.js' + +const schema = z.object({ + placeholder: z.string().describe('Placeholder text'), + defaultValue: z.string().describe('Initial value'), + isDisabled: z.boolean().describe('Disable interaction'), +}) + +const storyGroup: StoryGroup = stories({ + title: 'TextInput', + component: TextInput as unknown as ComponentType>, + schema, + defaults: { + onSubmit: (_v: string) => {}, + }, + stories: { + Default: { + props: { placeholder: '', defaultValue: '', isDisabled: false }, + description: 'Empty text input with no placeholder', + }, + WithPlaceholder: { + props: { placeholder: 'Enter your name...', defaultValue: '', isDisabled: false }, + description: 'Text input with placeholder text', + }, + WithValidation: { + props: { + placeholder: 'Enter at least 3 characters...', + defaultValue: '', + isDisabled: false, + validate: (value: string) => { + if (value.length < 3) { + return 'Must be at least 3 characters' + } + return undefined + }, + }, + description: 'Text input with minimum length validation', + }, + Disabled: { + props: { placeholder: 'Disabled input', defaultValue: '', isDisabled: true }, + description: 'Fully disabled text input', + }, + }, +}) + +export default storyGroup diff --git a/packages/core/src/ui/prompts/text-input.tsx b/packages/core/src/ui/prompts/text-input.tsx new file mode 100644 index 00000000..2c3134bb --- /dev/null +++ b/packages/core/src/ui/prompts/text-input.tsx @@ -0,0 +1,112 @@ +import { Box, Text, useInput } from 'ink' +import type { ReactElement } from 'react' +import { useState } from 'react' +import { match } from 'ts-pattern' + +import { ErrorMessage } from '../display/error-message.js' +import { CursorValue } from './cursor-value.js' +import type { InputState } from './input-state.js' +import { resolveNextState } from './input-state.js' + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** + * Props for the {@link TextInput} component. + */ +export interface TextInputProps { + /** Placeholder text shown dimmed when the input is empty. */ + readonly placeholder?: string + + /** The initial input value. */ + readonly defaultValue?: string + + /** Validation function. Return an error message string to show, or undefined if valid. */ + readonly validate?: (value: string) => string | undefined + + /** Callback fired when the input value changes. */ + readonly onChange?: (value: string) => void + + /** Callback fired when the value is submitted via Enter. */ + readonly onSubmit?: (value: string) => void + + /** When `true`, the component ignores all keyboard input. */ + readonly isDisabled?: boolean +} + +// --------------------------------------------------------------------------- +// Exports +// --------------------------------------------------------------------------- + +/** + * A single-line text input prompt with cursor movement and validation. + * + * Renders the current value with a visible cursor (inverse character). + * Shows placeholder text when the input is empty. Validation errors + * appear below the input in red. + * + * **Keyboard shortcuts:** + * - Left/Right arrows — move cursor + * - Home (Ctrl+A) / End (Ctrl+E) — jump to start/end + * - Backspace — delete character before cursor + * - Delete (Ctrl+D) — delete character at cursor + * - Enter — validate and submit + * + * @param props - The text input component props. + * @returns A rendered text input element. + */ +export function TextInput({ + placeholder, + defaultValue = '', + validate, + onChange, + onSubmit, + isDisabled = false, +}: TextInputProps): ReactElement { + const [state, setState] = useState({ + value: defaultValue, + cursor: defaultValue.length, + error: undefined, + }) + + useInput( + (input, key) => { + if (key.return) { + const validationError = match(validate) + .with(undefined, () => undefined) + .otherwise((fn) => fn(state.value)) + if (validationError) { + setState({ ...state, error: validationError }) + return + } + setState({ ...state, error: undefined }) + if (onSubmit) { + onSubmit(state.value) + } + return + } + + const nextState = resolveNextState({ state, input, key }) + if (nextState.value !== state.value || nextState.cursor !== state.cursor) { + setState(nextState) + if (nextState.value !== state.value && onChange) { + onChange(nextState.value) + } + } + }, + { isActive: !isDisabled } + ) + + return ( + + {match(state.value.length === 0 && placeholder !== undefined) + .with(true, () => {placeholder}) + .with(false, () => ( + + )) + .exhaustive()} + + + ) +} diff --git a/packages/core/src/ui/prompts/types.ts b/packages/core/src/ui/prompts/types.ts new file mode 100644 index 00000000..e26ad618 --- /dev/null +++ b/packages/core/src/ui/prompts/types.ts @@ -0,0 +1,25 @@ +// --------------------------------------------------------------------------- +// Exports +// --------------------------------------------------------------------------- + +/** + * A single option in a prompt component. + * + * Replaces `@inkjs/ui`'s `Option` (`{ label: string, value: string }`) + * with support for generic values, disabled state, and hint text. + * + * @typeParam TValue - The type of the option's value. + */ +export interface PromptOption { + /** The value returned when this option is selected. */ + readonly value: TValue + + /** The display label shown to the user. */ + readonly label: string + + /** Optional hint text shown dimmed beside the label. */ + readonly hint?: string + + /** When `true`, the option is shown but not selectable. */ + readonly disabled?: boolean +} diff --git a/packages/core/src/ui/select.tsx b/packages/core/src/ui/select.tsx deleted file mode 100644 index 3c5043ac..00000000 --- a/packages/core/src/ui/select.tsx +++ /dev/null @@ -1,11 +0,0 @@ -/** - * Select UI component. - * - * A thin wrapper that re-exports the `Select` component from `@inkjs/ui`. - * Renders a single-choice selection list in the terminal, allowing the user - * to navigate and pick one option from a set. - * - * @module - */ -export { Select } from '@inkjs/ui' -export type { SelectProps } from '@inkjs/ui' diff --git a/packages/core/src/ui/spinner.tsx b/packages/core/src/ui/spinner.tsx deleted file mode 100644 index 7479d98d..00000000 --- a/packages/core/src/ui/spinner.tsx +++ /dev/null @@ -1,11 +0,0 @@ -/** - * Spinner UI component. - * - * A thin wrapper that re-exports the `Spinner` component from `@inkjs/ui`. - * Displays an animated spinner with an optional label, useful for indicating - * loading or in-progress operations in the terminal. - * - * @module - */ -export { Spinner } from '@inkjs/ui' -export type { SpinnerProps } from '@inkjs/ui' diff --git a/packages/core/src/ui/text-input.tsx b/packages/core/src/ui/text-input.tsx deleted file mode 100644 index 44031962..00000000 --- a/packages/core/src/ui/text-input.tsx +++ /dev/null @@ -1,11 +0,0 @@ -/** - * TextInput UI component. - * - * A thin wrapper that re-exports the `TextInput` component from `@inkjs/ui`. - * Renders a text input field in the terminal with support for placeholders, - * default values, and autocomplete suggestions. - * - * @module - */ -export { TextInput } from '@inkjs/ui' -export type { TextInputProps } from '@inkjs/ui' diff --git a/packages/core/src/ui/theme.ts b/packages/core/src/ui/theme.ts new file mode 100644 index 00000000..250041a7 --- /dev/null +++ b/packages/core/src/ui/theme.ts @@ -0,0 +1,83 @@ +import figures from 'figures' +import { match } from 'ts-pattern' + +// --------------------------------------------------------------------------- +// Colors +// --------------------------------------------------------------------------- + +/** + * Color palette used by kidd UI components. + */ +export const colors: Readonly<{ + readonly primary: 'cyan' + readonly success: 'green' + readonly error: 'red' + readonly warning: 'yellow' + readonly info: 'blue' +}> = Object.freeze({ + primary: 'cyan', + success: 'green', + error: 'red', + warning: 'yellow', + info: 'blue', +} as const) + +/** + * Color type derived from the palette. + */ +export type ThemeColor = (typeof colors)[keyof typeof colors] + +/** + * Variant type shared by display components (Alert, StatusMessage). + */ +export type Variant = 'info' | 'success' | 'error' | 'warning' + +/** + * Resolve the theme color for a given variant. + * + * @param variant - The display variant. + * @returns The color string from the theme palette. + */ +export function resolveVariantColor(variant: Variant): ThemeColor { + return match(variant) + .with('info', () => colors.info) + .with('success', () => colors.success) + .with('error', () => colors.error) + .with('warning', () => colors.warning) + .exhaustive() +} + +// --------------------------------------------------------------------------- +// Symbols +// --------------------------------------------------------------------------- + +/** + * Symbol set used by kidd UI components for indicators and status icons. + */ +export const symbols: Readonly<{ + readonly radioOn: string + readonly radioOff: string + readonly checkboxOn: string + readonly checkboxOff: string + readonly pointer: string + readonly tick: string + readonly cross: string + readonly warning: string + readonly info: string + readonly circle: string + readonly line: string + readonly ellipsis: string +}> = Object.freeze({ + radioOn: figures.radioOn, + radioOff: figures.radioOff, + checkboxOn: figures.checkboxOn, + checkboxOff: figures.checkboxOff, + pointer: figures.pointer, + tick: figures.tick, + cross: figures.cross, + warning: figures.warning, + info: figures.info, + circle: figures.circle, + line: figures.line, + ellipsis: figures.ellipsis, +} as const) diff --git a/packages/core/src/ui/use-key-binding.ts b/packages/core/src/ui/use-key-binding.ts index dc3343a1..eaffd582 100644 --- a/packages/core/src/ui/use-key-binding.ts +++ b/packages/core/src/ui/use-key-binding.ts @@ -1,12 +1,3 @@ -/** - * Declarative keymap hook that binds key patterns to action callbacks. - * Supports single keys, modifier combinations, and multi-key sequences - * (e.g. double-press Escape). Wraps Ink's `useInput` and uses the shared - * key vocabulary from `keys.ts`. - * - * @module - */ - import type { Key } from 'ink' import { useInput } from 'ink' import { useCallback, useEffect, useMemo, useRef } from 'react' diff --git a/packages/core/src/ui/use-key-input.ts b/packages/core/src/ui/use-key-input.ts index 52737f79..015c390c 100644 --- a/packages/core/src/ui/use-key-input.ts +++ b/packages/core/src/ui/use-key-input.ts @@ -1,11 +1,3 @@ -/** - * Enhanced raw keyboard input hook that normalizes Ink's `useInput` callback - * into a richer {@link KeyInputEvent} descriptor. Useful for components that - * need character-by-character input handling with consistent key names. - * - * @module - */ - import type { Key } from 'ink' import { useInput } from 'ink' import { useCallback } from 'react' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c8e518ad..7c9c8198 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,6 +47,9 @@ importers: '@clack/prompts': specifier: ^1.1.0 version: 1.1.0 + '@kidd-cli/cli': + specifier: workspace:* + version: link:packages/cli '@types/node': specifier: 'catalog:' version: 25.5.0