From 046e93ab8f4d6ea1f61967321818e2c61565b8bd Mon Sep 17 00:00:00 2001 From: Zac Rosenbauer Date: Fri, 27 Mar 2026 18:53:04 -0400 Subject: [PATCH 01/12] refactor(core): reorganize UI components into prompts, display, and layout modules Move UI components from flat structure into organized subdirectories: - prompts/ for user input components (Confirm, Select, TextInput, etc.) - display/ for presentational components - layout/ for structural components (FullScreen, ScrollArea, Tabs) - Consolidate output module into single file - Add screen module with provider and context exports Co-Authored-By: Claude --- docs/designs/component-library.md | 675 ++++++++++++++++++ examples/diagnostic-output/package.json | 2 +- packages/core/src/index.ts | 4 +- packages/core/src/screen/index.ts | 13 + .../core/src/{ui => screen}/output/index.ts | 2 - .../src/{ui => screen}/output/screen-log.ts | 0 .../{ui => screen}/output/screen-report.ts | 0 .../{ui => screen}/output/screen-spinner.ts | 0 .../src/{ui => screen}/output/store-key.ts | 8 +- .../core/src/{ui => screen}/output/store.ts | 2 +- .../core/src/{ui => screen}/output/types.ts | 0 .../{ui => screen}/output/use-output-store.ts | 0 packages/core/src/{ui => screen}/provider.tsx | 0 .../core/src/{ui => screen}/screen.test.ts | 0 packages/core/src/{ui => screen}/screen.tsx | 2 +- packages/core/src/stories/decorators.tsx | 4 +- .../viewer/components/field-control.tsx | 5 +- .../src/stories/viewer/components/preview.tsx | 4 +- .../viewer/components/props-editor.tsx | 4 +- .../src/stories/viewer/components/sidebar.tsx | 4 +- .../core/src/stories/viewer/stories-app.tsx | 2 +- .../core/src/stories/viewer/stories-check.tsx | 4 +- packages/core/src/ui/confirm.tsx | 11 - .../core/src/ui/display/alert.stories.tsx | 56 ++ packages/core/src/ui/display/alert.tsx | 286 ++++++++ .../core/src/ui/display/error-message.tsx | 38 + packages/core/src/ui/display/index.ts | 20 + .../src/ui/display/progress-bar.stories.tsx | 45 ++ packages/core/src/ui/display/progress-bar.tsx | 116 +++ .../core/src/ui/display/spinner.stories.tsx | 35 + packages/core/src/ui/display/spinner.tsx | 146 ++++ .../src/ui/display/status-message.stories.tsx | 38 + .../core/src/ui/display/status-message.tsx | 82 +++ packages/core/src/ui/index.ts | 114 ++- .../src/ui/{ => layout}/fullscreen.test.ts | 0 .../core/src/ui/{ => layout}/fullscreen.tsx | 16 +- packages/core/src/ui/layout/index.ts | 17 + .../core/src/ui/{ => layout}/scroll-area.tsx | 0 packages/core/src/ui/{ => layout}/tabs.tsx | 4 +- .../core/src/ui/{ => layout}/use-size.test.ts | 0 .../core/src/ui/{ => layout}/use-size.tsx | 0 packages/core/src/ui/multi-select.tsx | 11 - packages/core/src/ui/{output => }/output.tsx | 20 +- packages/core/src/ui/password-input.tsx | 11 - .../src/ui/prompts/autocomplete.stories.tsx | 58 ++ packages/core/src/ui/prompts/autocomplete.tsx | 244 +++++++ .../core/src/ui/prompts/confirm.stories.tsx | 47 ++ packages/core/src/ui/prompts/confirm.tsx | 139 ++++ packages/core/src/ui/prompts/cursor-value.tsx | 61 ++ .../ui/prompts/group-multi-select.stories.tsx | 55 ++ .../src/ui/prompts/group-multi-select.tsx | 391 ++++++++++ packages/core/src/ui/prompts/index.ts | 34 + packages/core/src/ui/prompts/input-state.ts | 106 +++ .../src/ui/prompts/multi-select.stories.tsx | 66 ++ packages/core/src/ui/prompts/multi-select.tsx | 217 ++++++ packages/core/src/ui/prompts/navigation.ts | 140 ++++ packages/core/src/ui/prompts/option-row.tsx | 98 +++ .../src/ui/prompts/password-input.stories.tsx | 51 ++ .../core/src/ui/prompts/password-input.tsx | 126 ++++ .../src/ui/prompts/path-input.stories.tsx | 51 ++ packages/core/src/ui/prompts/path-input.tsx | 303 ++++++++ .../src/ui/prompts/select-key.stories.tsx | 41 ++ packages/core/src/ui/prompts/select-key.tsx | 142 ++++ .../core/src/ui/prompts/select.stories.tsx | 79 ++ packages/core/src/ui/prompts/select.tsx | 135 ++++ packages/core/src/ui/prompts/string-utils.ts | 32 + .../src/ui/prompts/text-input.stories.tsx | 51 ++ packages/core/src/ui/prompts/text-input.tsx | 122 ++++ packages/core/src/ui/prompts/types.ts | 31 + packages/core/src/ui/select.tsx | 11 - packages/core/src/ui/spinner.tsx | 11 - packages/core/src/ui/text-input.tsx | 11 - packages/core/src/ui/theme.ts | 92 +++ 73 files changed, 4612 insertions(+), 134 deletions(-) create mode 100644 docs/designs/component-library.md create mode 100644 packages/core/src/screen/index.ts rename packages/core/src/{ui => screen}/output/index.ts (94%) rename packages/core/src/{ui => screen}/output/screen-log.ts (100%) rename packages/core/src/{ui => screen}/output/screen-report.ts (100%) rename packages/core/src/{ui => screen}/output/screen-spinner.ts (100%) rename packages/core/src/{ui => screen}/output/store-key.ts (95%) rename packages/core/src/{ui => screen}/output/store.ts (96%) rename packages/core/src/{ui => screen}/output/types.ts (100%) rename packages/core/src/{ui => screen}/output/use-output-store.ts (100%) rename packages/core/src/{ui => screen}/provider.tsx (100%) rename packages/core/src/{ui => screen}/screen.test.ts (100%) rename packages/core/src/{ui => screen}/screen.tsx (98%) delete mode 100644 packages/core/src/ui/confirm.tsx create mode 100644 packages/core/src/ui/display/alert.stories.tsx create mode 100644 packages/core/src/ui/display/alert.tsx create mode 100644 packages/core/src/ui/display/error-message.tsx create mode 100644 packages/core/src/ui/display/index.ts create mode 100644 packages/core/src/ui/display/progress-bar.stories.tsx create mode 100644 packages/core/src/ui/display/progress-bar.tsx create mode 100644 packages/core/src/ui/display/spinner.stories.tsx create mode 100644 packages/core/src/ui/display/spinner.tsx create mode 100644 packages/core/src/ui/display/status-message.stories.tsx create mode 100644 packages/core/src/ui/display/status-message.tsx rename packages/core/src/ui/{ => layout}/fullscreen.test.ts (100%) rename packages/core/src/ui/{ => layout}/fullscreen.tsx (96%) create mode 100644 packages/core/src/ui/layout/index.ts rename packages/core/src/ui/{ => layout}/scroll-area.tsx (100%) rename packages/core/src/ui/{ => layout}/tabs.tsx (98%) rename packages/core/src/ui/{ => layout}/use-size.test.ts (100%) rename packages/core/src/ui/{ => layout}/use-size.tsx (100%) delete mode 100644 packages/core/src/ui/multi-select.tsx rename packages/core/src/ui/{output => }/output.tsx (84%) delete mode 100644 packages/core/src/ui/password-input.tsx create mode 100644 packages/core/src/ui/prompts/autocomplete.stories.tsx create mode 100644 packages/core/src/ui/prompts/autocomplete.tsx create mode 100644 packages/core/src/ui/prompts/confirm.stories.tsx create mode 100644 packages/core/src/ui/prompts/confirm.tsx create mode 100644 packages/core/src/ui/prompts/cursor-value.tsx create mode 100644 packages/core/src/ui/prompts/group-multi-select.stories.tsx create mode 100644 packages/core/src/ui/prompts/group-multi-select.tsx create mode 100644 packages/core/src/ui/prompts/index.ts create mode 100644 packages/core/src/ui/prompts/input-state.ts create mode 100644 packages/core/src/ui/prompts/multi-select.stories.tsx create mode 100644 packages/core/src/ui/prompts/multi-select.tsx create mode 100644 packages/core/src/ui/prompts/navigation.ts create mode 100644 packages/core/src/ui/prompts/option-row.tsx create mode 100644 packages/core/src/ui/prompts/password-input.stories.tsx create mode 100644 packages/core/src/ui/prompts/password-input.tsx create mode 100644 packages/core/src/ui/prompts/path-input.stories.tsx create mode 100644 packages/core/src/ui/prompts/path-input.tsx create mode 100644 packages/core/src/ui/prompts/select-key.stories.tsx create mode 100644 packages/core/src/ui/prompts/select-key.tsx create mode 100644 packages/core/src/ui/prompts/select.stories.tsx create mode 100644 packages/core/src/ui/prompts/select.tsx create mode 100644 packages/core/src/ui/prompts/string-utils.ts create mode 100644 packages/core/src/ui/prompts/text-input.stories.tsx create mode 100644 packages/core/src/ui/prompts/text-input.tsx create mode 100644 packages/core/src/ui/prompts/types.ts delete mode 100644 packages/core/src/ui/select.tsx delete mode 100644 packages/core/src/ui/spinner.tsx delete mode 100644 packages/core/src/ui/text-input.tsx create mode 100644 packages/core/src/ui/theme.ts diff --git a/docs/designs/component-library.md b/docs/designs/component-library.md new file mode 100644 index 00000000..227560c3 --- /dev/null +++ b/docs/designs/component-library.md @@ -0,0 +1,675 @@ +# Component Library Design + +> Design document for kidd's React/Ink UI component library with full clack-level API coverage. + +## Context + +kidd has two rendering paths: + +1. **Handler mode** (`ctx.prompts`, `ctx.log`, `ctx.status`) — imperative, delegates to `@clack/prompts`. The `feat/log-config` branch adds full clack API coverage here. +2. **Screen mode** (React/Ink) — declarative components for TUI screens. Currently re-exports `@inkjs/ui` components with no customization. + +The screen-mode components don't match the clack visual style and lack features clack provides (disabled options, hints, validation, generic values, etc.). This design closes that gap by building a full component library with clack-level API coverage. + +## Assessment: @inkjs/ui v2 + +### What it provides + +| Component | Props | +| --------------- | ------------------------------------------------------------------------------------------------------ | +| `Select` | `options`, `defaultValue`, `onChange`, `isDisabled`, `visibleOptionCount`, `highlightText` | +| `MultiSelect` | `options`, `defaultValue`, `onChange`, `onSubmit`, `isDisabled`, `visibleOptionCount`, `highlightText` | +| `ConfirmInput` | `defaultChoice`, `onConfirm`, `onCancel`, `isDisabled`, `submitOnEnter` | +| `TextInput` | `placeholder`, `defaultValue`, `suggestions`, `onChange`, `onSubmit`, `isDisabled` | +| `PasswordInput` | `placeholder`, `onChange`, `onSubmit`, `isDisabled` | +| `EmailInput` | `placeholder`, `defaultValue`, `domains`, `onChange`, `onSubmit`, `isDisabled` | +| `Spinner` | `label`, `type` | +| `ProgressBar` | `value` (0-100) | +| `Alert` | `children`, `variant`, `title` | +| `StatusMessage` | `children`, `variant` | +| `Badge` | `children`, `color` | +| `OrderedList` | list rendering | +| `UnorderedList` | list rendering | + +### Theming system + +`@inkjs/ui` v2 has a `ThemeProvider` + `extendTheme` + `useComponentTheme` system. Each component reads styles from the theme context. Styles are functions that receive state (`isFocused`, `isSelected`, etc.) and return Ink props (`color`, `bold`, `gap`, `paddingLeft`, etc.). Some components also have a `config` function for swapping symbols/characters. + +### Limitations + +- **`Option` type** is `{ label: string, value: string }` — no `disabled`, no `hint`, value must be `string` +- **No validation** on TextInput or PasswordInput +- **No generic value types** — everything is `string` +- **ConfirmInput** has no custom active/inactive labels, no message header +- **PasswordInput** has no custom mask character +- **Spinner** has no start/stop/message lifecycle (just renders an animated frame + label) +- **ProgressBar** has no `max`, no `style` variants, no label — just `value` (0-100) +- **Components are opinionated black boxes** — no render props, no slot patterns + +## Decision: Build Custom for Full API Coverage + +Every component in the kidd library is built custom on raw Ink primitives. This ensures full clack-level API coverage — disabled options, hints, validation, generic values, lifecycle controls — with a consistent visual style. No component is "theme only" since even the simpler @inkjs/ui components (Spinner, ProgressBar) lack features clack provides. + +`@inkjs/ui` remains available as `@kidd-cli/core/ui/base` for anyone who wants the raw vanilla Ink widgets. + +### Gap analysis: @inkjs/ui vs clack + +| Component | @inkjs/ui gaps | +| -------------------- | ---------------------------------------------------------------------------------------------------------------- | +| **Select** | No per-option `disabled`, `hint`. No generic `TValue`. No `onSubmit`. Value is `string` only. | +| **MultiSelect** | No per-option `disabled`, `hint`. No `required`. No generic `TValue`. Value is `string` only. | +| **ConfirmInput** | No custom active/inactive labels. | +| **TextInput** | No `validate`. | +| **PasswordInput** | No `validate`. No custom mask character. | +| **Spinner** | No start/stop/message lifecycle. Just `label` + `type`. Clack's spinner has `.start()`, `.stop()`, `.message()`. | +| **ProgressBar** | No `max`, no `style` variants (light/heavy/block), no label. Clack has `advance(step, message)`. | +| **Alert** | Adequate for display, but kidd version should match clack `box()` API (width, alignment, padding, rounded). | +| **StatusMessage** | Adequate for display, but kidd version should use clack's symbol set. | +| **GroupMultiSelect** | Missing entirely. | +| **Autocomplete** | Missing entirely. | +| **SelectKey** | Missing entirely. | +| **PathPrompt** | Missing entirely. | + +## Structure + +The codebase splits into two concerns: the **screen runtime** (framework plumbing that mounts Ink, wires context, manages the output store) and the **component library** (what users compose inside their screens). + +``` +packages/core/src/ +├── screen/ +│ ├── screen.tsx # screen() factory — mounts Ink, creates store, wires context +│ ├── provider.tsx # KiddContext provider (React context for screen runtime) +│ ├── output/ +│ │ ├── store.ts # OutputStore factory (internal) +│ │ ├── store-key.ts # Symbol injection for output store (internal) +│ │ ├── screen-log.ts # Screen-backed Log implementation (internal) +│ │ ├── screen-spinner.ts # Screen-backed Spinner implementation (internal) +│ │ ├── screen-report.ts # Screen-backed Report implementation (internal) +│ │ ├── types.ts # OutputEntry, SpinnerState, etc. +│ │ └── index.ts # Internal barrel +│ └── index.ts # Exports: screen, useScreenContext +├── command/ # Existing — command() factory, handler types +├── context/ # Existing — CommandContext, ScreenContext, prompts, status +├── middleware/ # Existing — auth, http, icons, etc. +├── ui/ +│ ├── base/ +│ │ └── index.ts # Raw @inkjs/ui + Ink re-exports (vanilla, no kidd styling) +│ ├── prompts/ +│ │ ├── select.tsx # Single-select with disabled, hints, generic value +│ │ ├── multi-select.tsx # Multi-select with checkboxes, required, generic value +│ │ ├── group-multi-select.tsx # Grouped multi-select with section headers +│ │ ├── confirm.tsx # Confirm with custom active/inactive labels +│ │ ├── text-input.tsx # Text input with validate, placeholder +│ │ ├── password-input.tsx # Password input with validate, custom mask +│ │ ├── autocomplete.tsx # Filtered select with text input +│ │ ├── select-key.tsx # Single-keypress selection +│ │ ├── path-input.tsx # Filesystem path autocomplete +│ │ ├── types.ts # PromptOption, shared prompt types +│ │ └── index.ts # Barrel export for all prompt components +│ ├── display/ +│ │ ├── spinner.tsx # Spinner with start/stop/message lifecycle +│ │ ├── progress-bar.tsx # Progress bar with max, style, label +│ │ ├── alert.tsx # Alert box with variant, title, alignment +│ │ ├── status-message.tsx # Status message with variant icons +│ │ └── index.ts # Barrel export for all display components +│ ├── layout/ +│ │ ├── scroll-area.tsx # Existing (moved) +│ │ ├── tabs.tsx # Existing (moved) +│ │ ├── fullscreen.tsx # Existing (moved) +│ │ ├── use-size.tsx # Existing (moved) +│ │ └── index.ts # Barrel export for all layout components +│ ├── output.tsx # component (reads from screen's output store) +│ ├── theme.ts # Kidd color palette and symbol constants +│ └── index.ts # Root barrel — re-exports prompts/, display/, layout/, Output +``` + +### Separation of concerns + +- **`screen/`** — a peer to `command/` at the core level. Owns `screen()` factory, context provider, and the output store internals (screen-backed log, spinner, report). Users never import from `screen/output/` directly. `screen()` and `useScreenContext()` are exported from the core barrel alongside `command()`, `cli()`, etc. +- **`ui/`** — component library. Everything users compose inside their screens. `` lives here as a component that reads from the store `screen()` creates — it's the user-facing interface to the output system. + +`screen()` imports `FullScreen` from `ui/layout/` when `fullscreen: true` is set. It does not import any prompt or display components — those are purely user-land. + +### Import examples + +```ts +// Core — screen() is a peer to command() +import { command, screen, useScreenContext } from '@kidd-cli/core' + +// Component library — single flat import path +import { + Select, + MultiSelect, + Confirm, + TextInput, + PasswordInput, + Spinner, + ProgressBar, + Alert, + StatusMessage, + Tabs, + ScrollArea, + FullScreen, + Output, +} from '@kidd-cli/core/ui' +``` + +### Export paths + +| Path | What it exports | +| ------------------- | ---------------------------------------------------------------------------------- | +| `@kidd-cli/core` | `cli()`, `command()`, `screen()`, `useScreenContext()`, middleware, context types | +| `@kidd-cli/core/ui` | All kidd components — prompts, display, layout, Output, base Ink primitives, types | + +The directory structure (`prompts/`, `display/`, `layout/`, `base/`) is an internal organizational detail. Users always import from `@kidd-cli/core/ui` — one path, one barrel. + +## Shared Types + +```ts +interface PromptOption { + readonly value: TValue + readonly label: string + readonly hint?: string + readonly disabled?: boolean +} +``` + +This replaces `@inkjs/ui`'s `Option` (`{ label: string, value: string }`) for all kidd components. + +## Component APIs + +### Select + +```tsx +interface SelectProps { + readonly options: readonly PromptOption[] + readonly defaultValue?: TValue + readonly maxVisible?: number + readonly onChange?: (value: TValue) => void + readonly onSubmit?: (value: TValue) => void + readonly isDisabled?: boolean +} +``` + +- Arrow keys navigate, disabled options are skipped +- Focused option shows pointer indicator, selected shows filled dot +- Hints render dimmed beside the label +- Disabled options render dimmed with strikethrough +- Scrolls when list exceeds `maxVisible` + +### MultiSelect + +```tsx +interface MultiSelectProps { + readonly options: readonly PromptOption[] + readonly defaultValue?: readonly TValue[] + readonly maxVisible?: number + readonly required?: boolean + readonly onChange?: (value: readonly TValue[]) => void + readonly onSubmit?: (value: readonly TValue[]) => void + readonly isDisabled?: boolean +} +``` + +- Space toggles selection (filled/empty checkbox) +- Enter submits; if `required` and nothing selected, shows validation message +- Disabled options shown but not toggleable + +### Confirm + +```tsx +interface ConfirmProps { + readonly active?: string // default: "Yes" + readonly inactive?: string // default: "No" + readonly defaultValue?: boolean + readonly onSubmit?: (value: boolean) => void + readonly isDisabled?: boolean +} +``` + +- Left/right or y/n to toggle +- Enter to submit +- Active choice highlighted, inactive dimmed + +### TextInput + +```tsx +interface TextInputProps { + readonly placeholder?: string + readonly defaultValue?: string + readonly validate?: (value: string) => string | undefined + readonly onChange?: (value: string) => void + readonly onSubmit?: (value: string) => void + readonly isDisabled?: boolean +} +``` + +- Full cursor movement (left/right, home/end, backspace/delete) +- Validation runs on submit; error shown below input +- Placeholder shown dimmed when empty + +### PasswordInput + +```tsx +interface PasswordInputProps { + readonly placeholder?: string + readonly mask?: string // default: "*" + readonly validate?: (value: string) => string | undefined + readonly onChange?: (value: string) => void + readonly onSubmit?: (value: string) => void + readonly isDisabled?: boolean +} +``` + +- Same as TextInput but input masked with `mask` character + +### GroupMultiSelect + +```tsx +interface GroupMultiSelectProps { + readonly options: Readonly[]>> + readonly defaultValue?: readonly TValue[] + readonly required?: boolean + readonly selectableGroups?: boolean + readonly onChange?: (value: readonly TValue[]) => void + readonly onSubmit?: (value: readonly TValue[]) => void + readonly isDisabled?: boolean +} +``` + +- Group headers rendered as section labels +- If `selectableGroups`, toggling a group header toggles all its options +- Options within each group are indented + +### Autocomplete + +```tsx +interface AutocompleteProps { + readonly options: readonly PromptOption[] + readonly placeholder?: string + readonly maxVisible?: number + readonly defaultValue?: TValue + readonly filter?: (search: string, option: PromptOption) => boolean + readonly onChange?: (value: TValue) => void + readonly onSubmit?: (value: TValue) => void + readonly isDisabled?: boolean +} +``` + +- Text input filters the option list in real-time +- Default filter: case-insensitive label substring match +- Arrow keys navigate filtered results, enter selects + +### SelectKey + +```tsx +interface SelectKeyProps { + readonly options: readonly PromptOption[] + readonly onSubmit?: (value: TValue) => void + readonly isDisabled?: boolean +} +``` + +- Each option's `value` is a single key character +- Pressing the key immediately selects that option +- Options rendered with key highlighted + +### PathInput + +```tsx +interface PathInputProps { + readonly root?: string + readonly directoryOnly?: boolean + readonly defaultValue?: string + readonly validate?: (value: string) => string | undefined + readonly onChange?: (value: string) => void + readonly onSubmit?: (value: string) => void + readonly isDisabled?: boolean +} +``` + +- Text input with tab-completion from the filesystem +- Suggestions shown below input +- `directoryOnly` filters to directories + +### Spinner + +```tsx +interface SpinnerProps { + readonly label?: string + readonly isActive?: boolean + readonly type?: SpinnerName // from cli-spinners +} +``` + +- Renders animated spinner frame + label +- `isActive` controls whether the spinner animates (default `true`) +- Matches clack's visual style (colored frame, label beside it) + +### ProgressBar + +```tsx +interface ProgressBarProps { + readonly value: number + readonly max?: number // default: 100 + readonly label?: string + readonly style?: 'light' | 'heavy' | 'block' + readonly size?: number // bar width in characters +} +``` + +- Renders a progress bar with completed/remaining segments +- `style` controls the bar characters (light shade, heavy shade, or block) +- `label` shown beside the bar +- Automatically fills available width if `size` not specified + +### Alert + +```tsx +interface AlertProps { + readonly children: ReactNode + readonly variant: 'info' | 'success' | 'error' | 'warning' + readonly title?: string + readonly width?: number | 'auto' + readonly rounded?: boolean + readonly contentAlign?: 'left' | 'center' | 'right' + readonly titleAlign?: 'left' | 'center' | 'right' +} +``` + +- Bordered box with variant-colored border and icon +- Matches clack `box()` API for alignment and width control + +### StatusMessage + +```tsx +interface StatusMessageProps { + readonly children: ReactNode + readonly variant: 'info' | 'success' | 'error' | 'warning' +} +``` + +- Icon + message, variant determines color and symbol +- Uses clack's symbol set (matching `ctx.log.info/success/error/warn`) + +## Visual Style + +All components use a consistent clack-inspired visual language: + +**Select (focused):** + +``` + ● Option A hint text + ○ Option B + ○ Option C hint text + ○ Option D (disabled) +``` + +**MultiSelect (focused):** + +``` + ◼ TypeScript + ◻ ESLint + ◼ Prettier + ◻ Tailwind (disabled) +``` + +**Confirm:** + +``` + Yes / No +``` + +**TextInput:** + +``` + my-project█ +``` + +**TextInput (validation error):** + +``` + █ + Project name is required. +``` + +**Spinner:** + +``` + ◒ Loading... +``` + +**ProgressBar:** + +``` + ████████░░░░░░░░ 50% Installing dependencies +``` + +**Alert:** + +``` + ╭─ Warning ──────────────────╮ + │ ⚠ Config file not found. │ + ╰────────────────────────────╯ +``` + +### Color palette + +| Element | Color | +| ---------------------------------------- | --------------------- | +| Focused option pointer/label | `cyan` | +| Selected indicator (filled dot/checkbox) | `cyan` | +| Unselected indicator | `dim` | +| Disabled option | `dim` + strikethrough | +| Hint text | `dim` | +| Validation error | `red` | +| Active confirm choice | `cyan` + underline | +| Inactive confirm choice | `dim` | +| Cursor | inverse | +| Placeholder | `dim` | +| Spinner frame | `cyan` | +| Progress completed | `cyan` | +| Progress remaining | `dim` | +| Alert info border | `blue` | +| Alert success border | `green` | +| Alert error border | `red` | +| Alert warning border | `yellow` | + +## Stories + +Every component ships with a `.stories.tsx` file using kidd's built-in stories system (`@kidd-cli/core/stories`). Stories are viewable via `kidd stories` in the terminal with live hot-reload, interactive props editing, and keyboard navigation. + +### Story pattern + +Each component gets a story group with variants covering key states: + +```tsx +// ui/prompts/select.stories.tsx +import { stories } from '@kidd-cli/core/stories' +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'), +}) + +export default stories({ + title: 'Select', + component: Select, + 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 hints and disabled option', + }, + Disabled: { + props: { maxVisible: 5, isDisabled: true }, + description: 'Fully disabled select', + }, + }, +}) +``` + +### Required stories per component + +| Component | Variants | +| -------------------- | ------------------------------------------------------------- | +| **Select** | Default, WithHints, DisabledOptions, Scrolling, Disabled | +| **MultiSelect** | Default, WithRequired, DisabledOptions, Preselected, Disabled | +| **Confirm** | Default, CustomLabels, DefaultNo, Disabled | +| **TextInput** | Default, WithPlaceholder, WithValidation, Disabled | +| **PasswordInput** | Default, CustomMask, WithValidation, Disabled | +| **GroupMultiSelect** | Default, SelectableGroups, WithRequired | +| **Autocomplete** | Default, CustomFilter, Disabled | +| **SelectKey** | Default, Disabled | +| **PathInput** | Default, DirectoryOnly, WithValidation | +| **Spinner** | Default, CustomType, Inactive | +| **ProgressBar** | Empty, Half, Full, WithLabel, StyleVariants | +| **Alert** | Info, Success, Error, Warning, WithTitle | +| **StatusMessage** | Info, Success, Error, Warning | + +### Viewing + +```bash +kidd stories # Launch TUI viewer with hot-reload +kidd stories --check # Validate all stories +kidd stories --out Select # Render Select story to stdout +kidd stories --include 'ui/**' # Filter to ui stories only +``` + +## Phases + +### Phase 1: Foundation + +1. Extract `screen/` — move `screen.tsx`, `provider.tsx`, `output/` into `src/screen/` +2. Create `ui/base/index.ts` — move raw Ink + @inkjs/ui re-exports here +3. Create `ui/theme.ts` — kidd color palette and symbol constants +4. Create `ui/prompts/types.ts` — `PromptOption`, shared prompt types +5. Move existing layout components into `ui/layout/` with barrel +6. Move `` component to `ui/output.tsx` (reads from screen's store) +7. Set up barrel exports for each group and root `ui/index.ts` +8. Update package exports in `package.json` for new paths + +### Phase 2: Core Prompts (`ui/prompts/`) + +9. `prompts/select.tsx` + `prompts/select.stories.tsx` +10. `prompts/multi-select.tsx` + `prompts/multi-select.stories.tsx` +11. `prompts/confirm.tsx` + `prompts/confirm.stories.tsx` +12. `prompts/text-input.tsx` + `prompts/text-input.stories.tsx` +13. `prompts/password-input.tsx` + `prompts/password-input.stories.tsx` +14. `prompts/index.ts` — barrel export + +### Phase 3: Extended Prompts (`ui/prompts/`) + +15. `prompts/group-multi-select.tsx` + `prompts/group-multi-select.stories.tsx` +16. `prompts/autocomplete.tsx` + `prompts/autocomplete.stories.tsx` +17. `prompts/select-key.tsx` + `prompts/select-key.stories.tsx` +18. `prompts/path-input.tsx` + `prompts/path-input.stories.tsx` + +### Phase 4: Display Components (`ui/display/`) + +19. `display/spinner.tsx` + `display/spinner.stories.tsx` +20. `display/progress-bar.tsx` + `display/progress-bar.stories.tsx` +21. `display/alert.tsx` + `display/alert.stories.tsx` +22. `display/status-message.tsx` + `display/status-message.stories.tsx` +23. `display/index.ts` — barrel export + +### Phase 5: Integration + +24. Update root `ui/index.ts` to re-export all groups +25. Update component standards doc +26. Run `kidd stories --check` to validate all stories pass + +## Agent Team Execution Plan + +Work is parallelized across agent teams. Phase 1 (foundation) runs first since everything depends on it. After that, phases 2-4 run in parallel — prompts, extended prompts, and display components have no dependencies on each other. + +### Phase 1: Foundation (sequential — blocks everything) + +**Agent: `foundation`** + +- Extract `screen/` directory, move `output/` internals +- Create `ui/base/`, `ui/theme.ts`, `ui/prompts/types.ts` +- Move layout components into `ui/layout/` +- Move `` to `ui/output.tsx` +- Wire barrel exports, update `package.json` +- Run `pnpm check` to verify nothing broke + +### Phase 2-4: Components (parallel teams after Phase 1) + +**Agent: `prompts-core`** (Phase 2) + +- Build Select + stories +- Build MultiSelect + stories +- Build Confirm + stories +- Build TextInput + stories +- Build PasswordInput + stories +- Wire `prompts/index.ts` barrel + +**Agent: `prompts-extended`** (Phase 3) + +- Build GroupMultiSelect + stories +- Build Autocomplete + stories +- Build SelectKey + stories +- Build PathInput + stories + +**Agent: `display`** (Phase 4) + +- Build Spinner + stories +- Build ProgressBar + stories +- Build Alert + stories +- Build StatusMessage + stories +- Wire `display/index.ts` barrel + +### Phase 5: Integration (sequential — after all teams complete) + +**Agent: `integration`** + +- Update root `ui/index.ts` barrel +- Update component standards doc +- Run `pnpm check` + `kidd stories --check` + +### Dependency graph + +``` +foundation + ├── prompts-core ──────┐ + ├── prompts-extended ──┼── integration + └── display ───────────┘ +``` + +## Build Approach + +All custom components are built on raw Ink primitives: + +- **Layout**: `Box` (flexbox), `Text` (styled text) +- **Input**: `useInput` from Ink with `isActive` guard +- **State**: `useState` / `useReducer` (following existing kidd patterns) +- **Conditionals**: `ts-pattern` `match()` — no ternaries, no switch +- **Immutability**: all props `readonly`, all state frozen, no `let` +- **Symbols**: `figures` package for cross-platform indicators +- **Colors**: Ink's `color` / `dimColor` / `bold` props + +No dependency on `@inkjs/ui` internals (OptionMap, etc.) — all custom components are self-contained. + +## Dependencies + +No new dependencies required. Everything used is already in the tree: + +- `ink` (Box, Text, useInput) +- `figures` (symbols) +- `ts-pattern` (conditionals) +- `@inkjs/ui` (re-exported via `ui/base` only) +- `react` (hooks) +- `cli-spinners` (spinner animation frames, already a dep of @inkjs/ui) diff --git a/examples/diagnostic-output/package.json b/examples/diagnostic-output/package.json index 32ba9d20..135fbc2c 100644 --- a/examples/diagnostic-output/package.json +++ b/examples/diagnostic-output/package.json @@ -18,4 +18,4 @@ "tsx": "catalog:", "typescript": "catalog:" } -} \ No newline at end of file +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b0965cf0..2db98476 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, @@ -15,7 +17,7 @@ export type { Resolvable, } from './types/index.js' export type { Colors } from 'picocolors/types' -export type { CommandContext, Log, Prompts, Spinner } from './context/types.js' +export type { CommandContext, Log, Prompts, ScreenContext, Spinner } from './context/types.js' export type { DotDirectory, DotDirectoryClient, diff --git a/packages/core/src/screen/index.ts b/packages/core/src/screen/index.ts new file mode 100644 index 00000000..d740aa5c --- /dev/null +++ b/packages/core/src/screen/index.ts @@ -0,0 +1,13 @@ +/** + * Screen runtime — mounts Ink, wires context, manages the output store. + * + * A peer to `command/` at the core level. Users never import from + * `screen/output/` directly. + * + * @module + */ + +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 94% rename from packages/core/src/ui/output/index.ts rename to packages/core/src/screen/output/index.ts index 6bfbf8e1..9942b5cc 100644 --- a/packages/core/src/ui/output/index.ts +++ b/packages/core/src/screen/output/index.ts @@ -5,8 +5,6 @@ * @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 100% rename from packages/core/src/ui/output/screen-log.ts rename to packages/core/src/screen/output/screen-log.ts diff --git a/packages/core/src/ui/output/screen-report.ts b/packages/core/src/screen/output/screen-report.ts similarity index 100% rename from packages/core/src/ui/output/screen-report.ts rename to packages/core/src/screen/output/screen-report.ts diff --git a/packages/core/src/ui/output/screen-spinner.ts b/packages/core/src/screen/output/screen-spinner.ts similarity index 100% rename from packages/core/src/ui/output/screen-spinner.ts rename to packages/core/src/screen/output/screen-spinner.ts diff --git a/packages/core/src/ui/output/store-key.ts b/packages/core/src/screen/output/store-key.ts similarity index 95% rename from packages/core/src/ui/output/store-key.ts rename to packages/core/src/screen/output/store-key.ts index 1257404e..240ab7ef 100644 --- a/packages/core/src/ui/output/store-key.ts +++ b/packages/core/src/screen/output/store-key.ts @@ -27,9 +27,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 96% rename from packages/core/src/ui/output/store.ts rename to packages/core/src/screen/output/store.ts index 53241ec9..377b9137 100644 --- a/packages/core/src/ui/output/store.ts +++ b/packages/core/src/screen/output/store.ts @@ -43,7 +43,7 @@ export function createOutputStore(): OutputStore { entries: Object.freeze([...entries]), spinner: spinnerState, }) - ;[...subscribers].map((cb) => cb()) + ;[...subscribers].reduce((_, cb) => cb(), undefined) } return Object.freeze({ diff --git a/packages/core/src/ui/output/types.ts b/packages/core/src/screen/output/types.ts similarity index 100% rename from packages/core/src/ui/output/types.ts rename to packages/core/src/screen/output/types.ts diff --git a/packages/core/src/ui/output/use-output-store.ts b/packages/core/src/screen/output/use-output-store.ts similarity index 100% rename from packages/core/src/ui/output/use-output-store.ts rename to packages/core/src/screen/output/use-output-store.ts 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 98% rename from packages/core/src/ui/screen.tsx rename to packages/core/src/screen/screen.tsx index 968413b9..9102cc07 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/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 fc5d2f85..1d7e970f 100644 --- a/packages/core/src/stories/viewer/components/preview.tsx +++ b/packages/core/src/stories/viewer/components/preview.tsx @@ -4,8 +4,8 @@ import type { ComponentType, ReactElement } from 'react' import { useMemo, useRef } 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 { 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/stories-app.tsx b/packages/core/src/stories/viewer/stories-app.tsx index f09c7dd3..332d6fc7 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 7cf8c902..78e27b99 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/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..e2e3dd1f --- /dev/null +++ b/packages/core/src/ui/display/alert.tsx @@ -0,0 +1,286 @@ +/** + * Alert UI component. + * + * Renders a bordered box with variant-colored borders, an optional title, + * and an icon matching the alert variant. Supports rounded and square + * border styles with configurable width and alignment. + * + * @module + */ + +import { Text } from 'ink' +import type { ReactElement, ReactNode } 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 content to display inside the alert box. */ + readonly children: ReactNode + + /** 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} ${String(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 titleWidth = match(title) + .with(undefined, () => 0) + .otherwise((t) => t.length + 4) + return Math.max(contentStr.length, 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..bbdb46c9 --- /dev/null +++ b/packages/core/src/ui/display/error-message.tsx @@ -0,0 +1,38 @@ +/** + * Inline validation error message component. + * + * Shared by prompt components that display validation errors below + * their input area. + * + * @module + */ + +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..e3fdd752 --- /dev/null +++ b/packages/core/src/ui/display/index.ts @@ -0,0 +1,20 @@ +/** + * Display components for presenting information in terminal UIs. + * + * @module + */ + +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..87889b4a --- /dev/null +++ b/packages/core/src/ui/display/progress-bar.tsx @@ -0,0 +1,116 @@ +/** + * ProgressBar UI component. + * + * Renders a horizontal progress bar with configurable style, size, and + * label. Displays a percentage alongside the bar and an optional + * descriptive label. + * + * @module + */ + +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 = Math.min(1, Math.max(0, value / max)) + const percentage = Math.round(ratio * 100) + const filledCount = Math.round(ratio * size) + const emptyCount = size - 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..b5e785c3 --- /dev/null +++ b/packages/core/src/ui/display/spinner.tsx @@ -0,0 +1,146 @@ +/** + * Spinner UI component. + * + * Renders an animated spinner with an optional label. Spinner frame data + * is inlined to avoid depending on transitive packages. + * + * @module + */ + +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(() => { + 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..e8812264 --- /dev/null +++ b/packages/core/src/ui/display/status-message.tsx @@ -0,0 +1,82 @@ +/** + * StatusMessage UI component. + * + * Renders an icon and message colored according to a variant. Useful for + * displaying success, error, warning, or informational messages in a + * consistent style. + * + * @module + */ + +import { Text } from 'ink' +import type { ReactElement, ReactNode } 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 message content to display beside the icon. */ + readonly children: ReactNode + + /** 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} + {` ${String(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 5eb6bb69..c4589aa1 100644 --- a/packages/core/src/ui/index.ts +++ b/packages/core/src/ui/index.ts @@ -1,12 +1,17 @@ /** * 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`. + * Re-exports kidd components (prompts, display, layout), the Output + * component, base Ink primitives, and shared types. Users import + * everything from `@kidd-cli/core/ui` — one path, one barrel. * * @module */ +// --------------------------------------------------------------------------- +// Base — raw Ink + @inkjs/ui primitives +// --------------------------------------------------------------------------- + export { Box, kittyFlags, @@ -44,43 +49,92 @@ export type { TransformProps, } from 'ink' -export { ConfirmInput } from './confirm.js' -export type { ConfirmInputProps } from './confirm.js' - -export { MultiSelect } from './multi-select.js' -export type { MultiSelectProps } from './multi-select.js' +// --------------------------------------------------------------------------- +// Prompts +// --------------------------------------------------------------------------- -export { PasswordInput } from './password-input.js' -export type { PasswordInputProps } from './password-input.js' +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' -export { Select } from './select.js' -export type { SelectProps } from './select.js' +// --------------------------------------------------------------------------- +// Display +// --------------------------------------------------------------------------- -export { Spinner } from './spinner.js' -export type { SpinnerProps } from './spinner.js' +export { Alert, ErrorMessage, ProgressBar, Spinner, StatusMessage } from './display/index.js' +export type { + AlertProps, + AlertVariant, + ErrorMessageProps, + ProgressBarProps, + ProgressBarStyle, + SpinnerProps, + StatusMessageProps, + StatusMessageVariant, +} from './display/index.js' -export { TextInput } from './text-input.js' -export type { TextInputProps } from './text-input.js' +// --------------------------------------------------------------------------- +// Layout +// --------------------------------------------------------------------------- -export type { Option } from '@inkjs/ui' +export { + FullScreen, + ScrollArea, + Tabs, + useFullScreen, + useSize, + useTerminalSize, +} from './layout/index.js' +export type { + FullScreenProps, + FullScreenState, + ScrollAreaProps, + Size, + TabItem, + TabsProps, + TerminalSize, +} from './layout/index.js' -export { useScreenContext } from './provider.js' -export type { ScreenContext } from '../context/types.js' +// --------------------------------------------------------------------------- +// Output +// --------------------------------------------------------------------------- -export { FullScreen, useFullScreen, useTerminalSize } from './fullscreen.js' -export type { FullScreenProps, FullScreenState, TerminalSize } from './fullscreen.js' +export { Output } from './output.js' -export { ScrollArea } from './scroll-area.js' -export type { ScrollAreaProps } from './scroll-area.js' +export { useOutputStore } from '../screen/output/index.js' +export type { OutputStore } from '../screen/output/index.js' -export { Tabs } from './tabs.js' -export type { TabItem, TabsProps } from './tabs.js' +// --------------------------------------------------------------------------- +// Screen (re-exported for backward compatibility) +// --------------------------------------------------------------------------- -export { useSize } from './use-size.js' -export type { Size } from './use-size.js' +export { screen, useScreenContext } from '../screen/index.js' +export type { ScreenDef, ScreenExit } from '../screen/index.js' +export type { ScreenContext } from '../context/types.js' -export { Output, useOutputStore } from './output/index.js' -export type { OutputStore } from './output/index.js' +// --------------------------------------------------------------------------- +// Theme +// --------------------------------------------------------------------------- -export { screen } from './screen.js' -export type { ScreenDef, ScreenExit } from './screen.js' +export { colors, resolveVariantColor, symbols } from './theme.js' +export type { ThemeColor, Variant } from './theme.js' 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 96% rename from packages/core/src/ui/fullscreen.tsx rename to packages/core/src/ui/layout/fullscreen.tsx index 81f455e5..8b90d861 100644 --- a/packages/core/src/ui/fullscreen.tsx +++ b/packages/core/src/ui/layout/fullscreen.tsx @@ -22,13 +22,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 +60,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..9f143c80 --- /dev/null +++ b/packages/core/src/ui/layout/index.ts @@ -0,0 +1,17 @@ +/** + * Layout components for building terminal UI structures. + * + * @module + */ + +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 100% rename from packages/core/src/ui/scroll-area.tsx rename to packages/core/src/ui/layout/scroll-area.tsx diff --git a/packages/core/src/ui/tabs.tsx b/packages/core/src/ui/layout/tabs.tsx similarity index 98% rename from packages/core/src/ui/tabs.tsx rename to packages/core/src/ui/layout/tabs.tsx index a2037a15..8528a76c 100644 --- a/packages/core/src/ui/tabs.tsx +++ b/packages/core/src/ui/layout/tabs.tsx @@ -14,6 +14,8 @@ import type { ReactElement, ReactNode } from 'react' import { useState } from 'react' import { match } from 'ts-pattern' +import { colors } from '../theme.js' + // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- @@ -189,7 +191,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 100% rename from packages/core/src/ui/use-size.tsx rename to packages/core/src/ui/layout/use-size.tsx 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 84% rename from packages/core/src/ui/output/output.tsx rename to packages/core/src/ui/output.tsx index be9503fd..01319ec5 100644 --- a/packages/core/src/ui/output/output.tsx +++ b/packages/core/src/ui/output.tsx @@ -6,8 +6,6 @@ * @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 +14,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 @@ -65,7 +65,7 @@ function SpinnerRow({ state }: { readonly state: SpinnerState }): ReactElement | match(message.length > 0) .with(true, () => ( - {figures.tick} {message} + {symbols.tick} {message} )) .with(false, () => null) @@ -107,27 +107,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..3387253b --- /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 = Object.freeze([ + { 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..4b8ac7fb --- /dev/null +++ b/packages/core/src/ui/prompts/autocomplete.tsx @@ -0,0 +1,244 @@ +/** + * Autocomplete prompt component. + * + * Provides a text input that filters a list of options in real-time. + * Arrow keys navigate the filtered results and Enter selects the + * focused option. + * + * @module + */ + +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) { + const next = Math.max(0, focusIndex - 1) + setFocusIndex(next) + const focused = filtered[next] + if (onChange && focused !== undefined) { + onChange(focused.value) + } + return + } + + if (key.downArrow) { + 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 || key.delete) { + if (cursorOffset > 0) { + const nextSearch = removeCharAt(search, cursorOffset - 1) + setSearch(nextSearch) + setCursorOffset(cursorOffset - 1) + setFocusIndex(0) + } + return + } + + if (input && !key.ctrl && !key.meta) { + const nextSearch = insertCharAt(search, cursorOffset, 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..80fbc36e --- /dev/null +++ b/packages/core/src/ui/prompts/confirm.tsx @@ -0,0 +1,139 @@ +/** + * Confirm prompt component. + * + * A boolean yes/no prompt for terminal UIs. Renders two toggle choices + * that can be switched with left/right arrows or y/n keys. The active + * choice is highlighted with cyan and underline styling. + * + * @module + */ + +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..768fa1ce --- /dev/null +++ b/packages/core/src/ui/prompts/cursor-value.tsx @@ -0,0 +1,61 @@ +/** + * Shared cursor-rendered value component for text-based prompts. + * + * Renders a string with a visible cursor (inverse character) at the + * given position. Used by TextInput and PasswordInput. + * + * @module + */ + +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..e506108d --- /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 = Object.freeze({ + 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..7c4e8357 --- /dev/null +++ b/packages/core/src/ui/prompts/group-multi-select.tsx @@ -0,0 +1,391 @@ +/** + * Group multi-select prompt component. + * + * Renders a multi-select list with options organized into named groups. + * Each group has a header label, and options within groups are indented. + * Supports toggling entire groups when `selectableGroups` is enabled. + * + * @module + */ + +import { Box, Text, useInput } from 'ink' +import type { ReactElement } from 'react' +import { 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]) + + useInput( + (input, key) => { + 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) + } + return + } + + if (key.return) { + if (required && selected.length === 0) { + setError('At least one option must be selected.') + return + } + if (onSubmit) { + onSubmit(selected) + } + } + }, + { 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) { + return moveFocus(next, direction, items) + } + 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}-${i.option.label}`) + .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 ( + + undefined) + .with({ isFocused: true }, () => colors.primary) + .otherwise(() => undefined)} + dimColor={disabled} + strikethrough={disabled} + > + {match(isFocused) + .with(true, () => `${symbols.pointer} `) + .with(false, () => ' ') + .exhaustive()} + {match(isSelected) + .with(true, () => `${symbols.checkboxOn} `) + .with(false, () => `${symbols.checkboxOff} `) + .exhaustive()} + {option.label} + + {match(option.hint) + .with(undefined, () => null) + .otherwise((hint) => ( + {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..048b6a53 --- /dev/null +++ b/packages/core/src/ui/prompts/index.ts @@ -0,0 +1,34 @@ +/** + * Prompt components for interactive terminal input. + * + * @module + */ + +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..93bf9022 --- /dev/null +++ b/packages/core/src/ui/prompts/input-state.ts @@ -0,0 +1,106 @@ +/** + * Shared input state management for text-based prompt components. + * + * Provides the state type, key descriptor, and state resolution logic + * shared by TextInput and PasswordInput. + * + * @module + */ + +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..2eb4269d --- /dev/null +++ b/packages/core/src/ui/prompts/multi-select.tsx @@ -0,0 +1,217 @@ +/** + * MultiSelect prompt component. + * + * A multi-value checkbox selector for terminal UIs. Renders a scrollable + * list of options with keyboard navigation (up/down arrows), space to + * toggle, and Enter to submit. Supports disabled options, required + * validation, and pre-selected defaults. + * + * @module + */ + +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..4facc30a --- /dev/null +++ b/packages/core/src/ui/prompts/navigation.ts @@ -0,0 +1,140 @@ +/** + * Shared keyboard navigation utilities for prompt components. + * + * Provides direction resolution and focus index computation used by + * Select, MultiSelect, and other navigable prompt components. + * + * @module + */ + +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) +} + +// --------------------------------------------------------------------------- +// Private +// --------------------------------------------------------------------------- + +/** + * 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) +} + +/** + * Resolve the initial focus index from a default value. Falls back to + * the first non-disabled option if no match is found. + * + * @param options - The option list. + * @param defaultValue - The default value to match. + * @returns The initial focus index. + */ +export function resolveInitialIndex( + options: readonly PromptOption[], + defaultValue: TValue | undefined +): number { + if (defaultValue !== undefined) { + const matchIndex = options.findIndex((o) => o.value === defaultValue) + if (matchIndex !== -1) { + return matchIndex + } + } + + return resolveFirstEnabledIndex(options) +} + +/** + * 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..6ded1b97 --- /dev/null +++ b/packages/core/src/ui/prompts/option-row.tsx @@ -0,0 +1,98 @@ +/** + * Shared option row component for prompt lists. + * + * Used by Select, MultiSelect, and other prompt components that render + * a vertical list of focusable options with indicator, label, and hint. + * + * @module + */ + +import { Box, Text } from 'ink' +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()} + + colors.primary) + .with({ isFocused: true }, () => colors.primary) + .otherwise(() => undefined)} + dimColor={isOptionDisabled} + > + {indicator} + + + colors.primary) + .with(false, () => undefined) + .exhaustive()} + dimColor={isOptionDisabled} + strikethrough={option.disabled === true} + > + {option.label} + + {match(option.hint) + .with(undefined, () => null) + .otherwise((hint) => ( + {` ${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..8d38f4ff --- /dev/null +++ b/packages/core/src/ui/prompts/password-input.tsx @@ -0,0 +1,126 @@ +/** + * PasswordInput prompt component. + * + * A masked single-line text input for terminal UIs. Behaves identically + * to {@link TextInput} but replaces each character with a configurable + * mask character (default `*`). Supports full cursor movement, + * validation, and placeholder text. + * + * @module + */ + +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..057912f1 --- /dev/null +++ b/packages/core/src/ui/prompts/path-input.tsx @@ -0,0 +1,303 @@ +/** + * Path input prompt component. + * + * Provides a text input with tab-completion from the filesystem. + * On Tab press, directory entries matching the current input are + * enumerated and cycled through as suggestions. + * + * @module + */ + +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(value, 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(value, cursorOffset, 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..dc2a41b4 --- /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 = Object.freeze([ + { 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..8a02bee3 --- /dev/null +++ b/packages/core/src/ui/prompts/select-key.tsx @@ -0,0 +1,142 @@ +/** + * Select-by-key prompt component. + * + * Renders a list of options where each option is bound to a single + * key character. Pressing the key immediately selects that option + * without requiring Enter confirmation. + * + * @module + */ + +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 === input) + 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..8f0ecad9 --- /dev/null +++ b/packages/core/src/ui/prompts/select.tsx @@ -0,0 +1,135 @@ +/** + * Select prompt component. + * + * A single-value selector for terminal UIs. Renders a scrollable list + * of options with keyboard navigation (up/down arrows), radio-style + * indicators, and optional hint text. Supports disabled options, + * controlled focus, and scroll overflow. + * + * @module + */ + +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..85e53dff --- /dev/null +++ b/packages/core/src/ui/prompts/string-utils.ts @@ -0,0 +1,32 @@ +/** + * Shared string manipulation utilities for prompt components. + * + * @module + */ + +// --------------------------------------------------------------------------- +// Exports +// --------------------------------------------------------------------------- + +/** + * Remove a character at the given position in a string. + * + * @param str - The source string. + * @param index - The character index to remove. + * @returns The string with the character removed. + */ +export function removeCharAt(str: string, index: number): string { + return str.slice(0, index) + str.slice(index + 1) +} + +/** + * Insert a character sequence at the given position in a string. + * + * @param str - The source string. + * @param index - The position to insert at. + * @param chars - The characters to insert. + * @returns The string with the characters inserted. + */ +export function insertCharAt(str: string, index: number, chars: string): 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..2d97bfd6 --- /dev/null +++ b/packages/core/src/ui/prompts/text-input.tsx @@ -0,0 +1,122 @@ +/** + * TextInput prompt component. + * + * A single-line text input for terminal UIs. Supports full cursor + * movement (left/right, home/end), character insertion, backspace/delete, + * placeholder text, and validation on submit. + * + * @module + */ + +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..7da321f5 --- /dev/null +++ b/packages/core/src/ui/prompts/types.ts @@ -0,0 +1,31 @@ +/** + * Shared types for kidd prompt components. + * + * @module + */ + +// --------------------------------------------------------------------------- +// 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..98d89a8b --- /dev/null +++ b/packages/core/src/ui/theme.ts @@ -0,0 +1,92 @@ +/** + * Kidd color palette and symbol constants for the component library. + * + * All components share a consistent clack-inspired visual language + * defined here. + * + * @module + */ + +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) From 44b4c84dc1eccc64e059a997bcbcdc895c99c276 Mon Sep 17 00:00:00 2001 From: Zac Rosenbauer Date: Fri, 27 Mar 2026 18:53:33 -0400 Subject: [PATCH 02/12] chore: add changeset for UI component reorganization Co-Authored-By: Claude --- .changeset/reorganize-ui-components.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/reorganize-ui-components.md 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. From d896a53f54aac47773a8104c50a038e3f6fd84fe Mon Sep 17 00:00:00 2001 From: Zac Rosenbauer Date: Fri, 27 Mar 2026 19:01:51 -0400 Subject: [PATCH 03/12] fix(core): use map instead of reduce for subscriber notification The reduce with a void accumulator was semantically incorrect for side-effect iteration. Revert to map which is the idiomatic choice in this codebase. Co-Authored-By: Claude --- packages/core/src/screen/output/store.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/screen/output/store.ts b/packages/core/src/screen/output/store.ts index 377b9137..53241ec9 100644 --- a/packages/core/src/screen/output/store.ts +++ b/packages/core/src/screen/output/store.ts @@ -43,7 +43,7 @@ export function createOutputStore(): OutputStore { entries: Object.freeze([...entries]), spinner: spinnerState, }) - ;[...subscribers].reduce((_, cb) => cb(), undefined) + ;[...subscribers].map((cb) => cb()) } return Object.freeze({ From c581c9de18e181d784612932f10b5ef43bba5a42 Mon Sep 17 00:00:00 2001 From: Zac Rosenbauer Date: Fri, 27 Mar 2026 19:15:47 -0400 Subject: [PATCH 04/12] refactor(core): remove deprecated spinner compat shim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove all backwards compatibility for the deprecated `spinner` option on `cli()`, `createContext()`, and `createTestContext()`. Consumers must migrate to `status` — no deprecation period, hard break. - Remove `spinner` from CliOptions, RuntimeOptions, CreateContextOptions, TestContextOptions and all pass-through call sites - Remove compat `resolveStatus` logic that wrapped spinner in Status - Remove "backward compatibility" comment on screen re-exports - Delete deprecated-spinner test case - Clean up unused Spinner type imports Co-Authored-By: Claude --- packages/core/src/cli.ts | 1 - packages/core/src/context/create-context.ts | 10 +-------- packages/core/src/runtime/runtime.ts | 1 - packages/core/src/runtime/types.ts | 10 +-------- packages/core/src/test/context.test.ts | 8 +------ packages/core/src/test/context.ts | 24 ++++++++++----------- packages/core/src/test/types.ts | 7 +----- packages/core/src/types/cli.ts | 7 +----- packages/core/src/ui/index.ts | 2 +- 9 files changed, 18 insertions(+), 52 deletions(-) diff --git a/packages/core/src/cli.ts b/packages/core/src/cli.ts index 1b95d561..c5168285 100644 --- a/packages/core/src/cli.ts +++ b/packages/core/src/cli.ts @@ -108,7 +108,6 @@ export async function cli( middleware: options.middleware, name: options.name, prompts: options.prompts, - spinner: options.spinner, status: options.status, version, }) diff --git a/packages/core/src/context/create-context.ts b/packages/core/src/context/create-context.ts index 231c3193..2e8c45db 100644 --- a/packages/core/src/context/create-context.ts +++ b/packages/core/src/context/create-context.ts @@ -17,7 +17,6 @@ import type { Log, Meta, Prompts, - Spinner, Status, Store, StoreMap, @@ -45,11 +44,6 @@ export interface CreateContextOptions( return createContextStatus({ defaults: commonDefaults, progressConfig: dc.progress, - spinner: options.spinner, spinnerConfig: dc.spinner, }) } diff --git a/packages/core/src/runtime/runtime.ts b/packages/core/src/runtime/runtime.ts index c4e876ba..9cb18671 100644 --- a/packages/core/src/runtime/runtime.ts +++ b/packages/core/src/runtime/runtime.ts @@ -52,7 +52,6 @@ export async function createRuntime( version: options.version, }, prompts: options.prompts, - spinner: options.spinner, status: options.status, }) diff --git a/packages/core/src/runtime/types.ts b/packages/core/src/runtime/types.ts index b849b26e..32ed5158 100644 --- a/packages/core/src/runtime/types.ts +++ b/packages/core/src/runtime/types.ts @@ -1,14 +1,7 @@ import type { AsyncResult, Result } from '@kidd-cli/utils/fp' import type { z } from 'zod' -import type { - CommandContext, - DisplayConfig, - Log, - Prompts, - Spinner, - Status, -} from '@/context/types.js' +import type { CommandContext, DisplayConfig, Log, Prompts, Status } from '@/context/types.js' import type { ArgsDef, CliConfigOptions, @@ -30,7 +23,6 @@ export interface RuntimeOptions { readonly log?: Log readonly prompts?: Prompts readonly status?: Status - readonly spinner?: Spinner } /** diff --git a/packages/core/src/test/context.test.ts b/packages/core/src/test/context.test.ts index 246cded7..6b449060 100644 --- a/packages/core/src/test/context.test.ts +++ b/packages/core/src/test/context.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { createTestContext, mockLog, mockPrompts, mockSpinner, mockStatus } from './context.js' +import { createTestContext, mockLog, mockPrompts, mockStatus } from './context.js' describe('test context factory', () => { it('should return a context with default args', () => { @@ -54,12 +54,6 @@ describe('test context factory', () => { expect(ctx.status).toBe(status) }) - it('should accept custom spinner via deprecated option', () => { - const spinner = mockSpinner() - const { ctx } = createTestContext({ spinner }) - expect(ctx.status.spinner).toBe(spinner) - }) - it('should provide status with stub methods by default', () => { const { ctx } = createTestContext() expect(() => ctx.status.spinner.start('loading...')).not.toThrow() diff --git a/packages/core/src/test/context.ts b/packages/core/src/test/context.ts index 6a79e2a9..ca27bc82 100644 --- a/packages/core/src/test/context.ts +++ b/packages/core/src/test/context.ts @@ -169,16 +169,20 @@ export function mockStatus(spinnerOverride?: Spinner): Status { isCancelled: false, })), tasks: vi.fn(async () => {}), - taskLog: vi.fn((): TaskLogHandle => ({ - message: vi.fn(), - success: vi.fn(), - error: vi.fn(), - group: vi.fn((): TaskLogGroupHandle => ({ + taskLog: vi.fn( + (): TaskLogHandle => ({ message: vi.fn(), success: vi.fn(), error: vi.fn(), - })), - })), + group: vi.fn( + (): TaskLogGroupHandle => ({ + message: vi.fn(), + success: vi.fn(), + error: vi.fn(), + }) + ), + }) + ), } as Status } @@ -216,8 +220,7 @@ function resolvePrompts(opts: TestContextOptions): Prompts { } /** - * Resolve the status instance from overrides, supporting the deprecated - * `spinner` override for backwards compatibility. + * Resolve the status instance from overrides or create a mock. * * @private * @param opts - Test context options. @@ -227,9 +230,6 @@ function resolveStatus(opts: TestContextOptions): Status { if (opts.status !== undefined) { return opts.status } - if (opts.spinner !== undefined) { - return mockStatus(opts.spinner) - } return mockStatus() } diff --git a/packages/core/src/test/types.ts b/packages/core/src/test/types.ts index cbf31bb1..349b75e7 100644 --- a/packages/core/src/test/types.ts +++ b/packages/core/src/test/types.ts @@ -1,6 +1,6 @@ import type { vi } from 'vitest' -import type { CommandContext, Log, Prompts, Spinner, Status } from '@/context/types.js' +import type { CommandContext, Log, Prompts, Status } from '@/context/types.js' import type { AnyRecord, CliConfigOptions, @@ -33,11 +33,6 @@ export interface TestContextOptions< readonly log?: Log readonly prompts?: Prompts readonly status?: Status - /** - * @deprecated Use `status` instead. When provided, creates a Status - * wrapper around this spinner for backwards compatibility. - */ - readonly spinner?: Spinner } /** diff --git a/packages/core/src/types/cli.ts b/packages/core/src/types/cli.ts index f042ba92..bc8e80d5 100644 --- a/packages/core/src/types/cli.ts +++ b/packages/core/src/types/cli.ts @@ -1,6 +1,6 @@ import type { z } from 'zod' -import type { DisplayConfig, Log, Prompts, Spinner, Status } from '@/context/types.js' +import type { DisplayConfig, Log, Prompts, Status } from '@/context/types.js' import type { CommandMap, CommandsConfig } from './command.js' import type { Middleware } from './middleware.js' @@ -167,11 +167,6 @@ export interface CliOptions { * `@clack/prompts`-backed status indicators are created automatically. */ readonly status?: Status - /** - * @deprecated Use `status` instead. When provided, creates a Status - * wrapper around this spinner for backwards compatibility. - */ - readonly spinner?: Spinner /** * When `true` (the default), yargs rejects unknown flags with an error. * Set to `false` to allow unknown flags to pass through unchecked. diff --git a/packages/core/src/ui/index.ts b/packages/core/src/ui/index.ts index c4589aa1..56fb9d0b 100644 --- a/packages/core/src/ui/index.ts +++ b/packages/core/src/ui/index.ts @@ -125,7 +125,7 @@ export { useOutputStore } from '../screen/output/index.js' export type { OutputStore } from '../screen/output/index.js' // --------------------------------------------------------------------------- -// Screen (re-exported for backward compatibility) +// Screen // --------------------------------------------------------------------------- export { screen, useScreenContext } from '../screen/index.js' From 99efed5152419820a434366608aad957b9470144 Mon Sep 17 00:00:00 2001 From: Zac Rosenbauer Date: Fri, 27 Mar 2026 19:44:27 -0400 Subject: [PATCH 05/12] fix(core): address PR review feedback - Add language identifiers to fenced code blocks in component-library.md - Restrict Alert and StatusMessage children to string (prevents [object Object]) - Guard ProgressBar against division by zero when max is 0 - Fix Delete key in Autocomplete to remove char at cursor (not before) - Prevent focus landing on disabled option at boundary in GroupMultiSelect - Use option.value as React key in MultiSelect to avoid collisions - Refactor resolveInitialIndex to use object destructuring per conventions Co-Authored-By: Claude --- docs/designs/component-library.md | 18 +++++++++--------- packages/core/src/ui/display/alert.tsx | 8 ++++---- packages/core/src/ui/display/progress-bar.tsx | 5 ++++- .../core/src/ui/display/status-message.tsx | 8 ++++---- packages/core/src/ui/prompts/autocomplete.tsx | 13 +++++++++++-- .../src/ui/prompts/group-multi-select.tsx | 6 +++++- packages/core/src/ui/prompts/multi-select.tsx | 2 +- packages/core/src/ui/prompts/navigation.ts | 19 +++++++++++++------ packages/core/src/ui/prompts/select.tsx | 2 +- 9 files changed, 52 insertions(+), 29 deletions(-) diff --git a/docs/designs/component-library.md b/docs/designs/component-library.md index 227560c3..9a5a47de 100644 --- a/docs/designs/component-library.md +++ b/docs/designs/component-library.md @@ -74,7 +74,7 @@ Every component in the kidd library is built custom on raw Ink primitives. This The codebase splits into two concerns: the **screen runtime** (framework plumbing that mounts Ink, wires context, manages the output store) and the **component library** (what users compose inside their screens). -``` +```text packages/core/src/ ├── screen/ │ ├── screen.tsx # screen() factory — mounts Ink, creates store, wires context @@ -398,7 +398,7 @@ All components use a consistent clack-inspired visual language: **Select (focused):** -``` +```text ● Option A hint text ○ Option B ○ Option C hint text @@ -407,7 +407,7 @@ All components use a consistent clack-inspired visual language: **MultiSelect (focused):** -``` +```text ◼ TypeScript ◻ ESLint ◼ Prettier @@ -416,38 +416,38 @@ All components use a consistent clack-inspired visual language: **Confirm:** -``` +```text Yes / No ``` **TextInput:** -``` +```text my-project█ ``` **TextInput (validation error):** -``` +```text █ Project name is required. ``` **Spinner:** -``` +```text ◒ Loading... ``` **ProgressBar:** -``` +```text ████████░░░░░░░░ 50% Installing dependencies ``` **Alert:** -``` +```text ╭─ Warning ──────────────────╮ │ ⚠ Config file not found. │ ╰────────────────────────────╯ diff --git a/packages/core/src/ui/display/alert.tsx b/packages/core/src/ui/display/alert.tsx index e2e3dd1f..09eb1629 100644 --- a/packages/core/src/ui/display/alert.tsx +++ b/packages/core/src/ui/display/alert.tsx @@ -9,7 +9,7 @@ */ import { Text } from 'ink' -import type { ReactElement, ReactNode } from 'react' +import type { ReactElement } from 'react' import { match } from 'ts-pattern' import type { Variant } from '../theme.js' @@ -28,8 +28,8 @@ export type AlertVariant = Variant * Props for the {@link Alert} component. */ export interface AlertProps { - /** The content to display inside the alert box. */ - readonly children: ReactNode + /** The text content to display inside the alert box. */ + readonly children: string /** The variant determines the border color and icon. */ readonly variant: AlertVariant @@ -76,7 +76,7 @@ export function Alert({ const variantColor = resolveVariantColor(variant) const icon = resolveVariantIcon(variant) const border = resolveBorderChars(rounded) - const contentStr = `${icon} ${String(children)}` + 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}` diff --git a/packages/core/src/ui/display/progress-bar.tsx b/packages/core/src/ui/display/progress-bar.tsx index 87889b4a..597b1744 100644 --- a/packages/core/src/ui/display/progress-bar.tsx +++ b/packages/core/src/ui/display/progress-bar.tsx @@ -64,7 +64,10 @@ export function ProgressBar({ style = 'block', size = 20, }: ProgressBarProps): ReactElement { - const ratio = Math.min(1, Math.max(0, value / max)) + 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 filledCount = Math.round(ratio * size) const emptyCount = size - filledCount diff --git a/packages/core/src/ui/display/status-message.tsx b/packages/core/src/ui/display/status-message.tsx index e8812264..6290cf3d 100644 --- a/packages/core/src/ui/display/status-message.tsx +++ b/packages/core/src/ui/display/status-message.tsx @@ -9,7 +9,7 @@ */ import { Text } from 'ink' -import type { ReactElement, ReactNode } from 'react' +import type { ReactElement } from 'react' import { match } from 'ts-pattern' import type { Variant } from '../theme.js' @@ -28,8 +28,8 @@ export type StatusMessageVariant = Variant * Props for the {@link StatusMessage} component. */ export interface StatusMessageProps { - /** The message content to display beside the icon. */ - readonly children: ReactNode + /** The text content to display beside the icon. */ + readonly children: string /** The variant determines the icon and color. */ readonly variant: StatusMessageVariant @@ -56,7 +56,7 @@ export function StatusMessage({ children, variant }: StatusMessageProps): ReactE return ( {icon} - {` ${String(children)}`} + {` ${children}`} ) } diff --git a/packages/core/src/ui/prompts/autocomplete.tsx b/packages/core/src/ui/prompts/autocomplete.tsx index 4b8ac7fb..aa48f872 100644 --- a/packages/core/src/ui/prompts/autocomplete.tsx +++ b/packages/core/src/ui/prompts/autocomplete.tsx @@ -80,7 +80,7 @@ export function Autocomplete({ isDisabled = false, }: AutocompleteProps): ReactElement { const [search, setSearch] = useState('') - const [focusIndex, setFocusIndex] = useState(resolveInitialIndex(options, defaultValue)) + const [focusIndex, setFocusIndex] = useState(resolveInitialIndex({ options, defaultValue })) const [cursorOffset, setCursorOffset] = useState(0) const filtered = useMemo( @@ -128,7 +128,7 @@ export function Autocomplete({ return } - if (key.backspace || key.delete) { + if (key.backspace) { if (cursorOffset > 0) { const nextSearch = removeCharAt(search, cursorOffset - 1) setSearch(nextSearch) @@ -138,6 +138,15 @@ export function Autocomplete({ return } + if (key.delete) { + if (cursorOffset < search.length) { + const nextSearch = removeCharAt(search, cursorOffset) + setSearch(nextSearch) + setFocusIndex(0) + } + return + } + if (input && !key.ctrl && !key.meta) { const nextSearch = insertCharAt(search, cursorOffset, input) setSearch(nextSearch) diff --git a/packages/core/src/ui/prompts/group-multi-select.tsx b/packages/core/src/ui/prompts/group-multi-select.tsx index 7c4e8357..60911dea 100644 --- a/packages/core/src/ui/prompts/group-multi-select.tsx +++ b/packages/core/src/ui/prompts/group-multi-select.tsx @@ -249,7 +249,11 @@ function moveFocus(current: number, direction: number, items: readonly FlatItem[ } const item = items[next] if (item !== undefined && item.kind === 'option' && item.option.disabled) { - return moveFocus(next, direction, items) + const result = moveFocus(next, direction, items) + return match(result === next) + .with(true, () => current) + .with(false, () => result) + .exhaustive() } return next } diff --git a/packages/core/src/ui/prompts/multi-select.tsx b/packages/core/src/ui/prompts/multi-select.tsx index 2eb4269d..e96c487c 100644 --- a/packages/core/src/ui/prompts/multi-select.tsx +++ b/packages/core/src/ui/prompts/multi-select.tsx @@ -140,7 +140,7 @@ export function MultiSelect({ return ( (options: readonly PromptOption< 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 options - The option list. - * @param defaultValue - The default value to match. + * @param opts - The resolution options. * @returns The initial focus index. */ -export function resolveInitialIndex( - options: readonly PromptOption[], - defaultValue: TValue | undefined -): number { +export function resolveInitialIndex({ + options, + defaultValue, +}: ResolveInitialIndexOptions): number { if (defaultValue !== undefined) { const matchIndex = options.findIndex((o) => o.value === defaultValue) if (matchIndex !== -1) { diff --git a/packages/core/src/ui/prompts/select.tsx b/packages/core/src/ui/prompts/select.tsx index 8f0ecad9..2e7e0d98 100644 --- a/packages/core/src/ui/prompts/select.tsx +++ b/packages/core/src/ui/prompts/select.tsx @@ -76,7 +76,7 @@ export function Select({ onSubmit, isDisabled = false, }: SelectProps): ReactElement { - const initialIndex = resolveInitialIndex(options, defaultValue) + const initialIndex = resolveInitialIndex({ options, defaultValue }) const [focusedIndex, setFocusedIndex] = useState(initialIndex) const [selectedIndex, setSelectedIndex] = useState(initialIndex) From 3dfbe86f2d04cd4c0ac98eada9b5a06f2d454e51 Mon Sep 17 00:00:00 2001 From: Zac Rosenbauer Date: Sun, 29 Mar 2026 20:32:48 -0400 Subject: [PATCH 06/12] feat(core): add root stories support and improve disabled option styling - Add @kidd-cli/cli as root devDependency for direct `kidd stories` access - Patch Module._resolveFilename in story importer to handle TypeScript ESM-style .js -> .ts/.tsx extension mapping (same strategy as tsx/ts-node) - Replace strikethrough on disabled options with gray color (matches clack) - Add (disabled) text fallback when colors are not supported - Use gray + dim for disabled option hints Co-Authored-By: Claude --- examples/tui/package.json | 1 + package.json | 2 + packages/core/src/stories/importer.ts | 104 ++++++++++++++++++ .../src/ui/prompts/group-multi-select.tsx | 20 +++- packages/core/src/ui/prompts/option-row.tsx | 29 +++-- pnpm-lock.yaml | 3 + turbo.json | 2 +- 7 files changed, 147 insertions(+), 14 deletions(-) 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/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/ui/prompts/group-multi-select.tsx b/packages/core/src/ui/prompts/group-multi-select.tsx index 60911dea..997d1055 100644 --- a/packages/core/src/ui/prompts/group-multi-select.tsx +++ b/packages/core/src/ui/prompts/group-multi-select.tsx @@ -9,6 +9,7 @@ */ import { Box, Text, useInput } from 'ink' +import picocolors from 'picocolors' import type { ReactElement } from 'react' import { useMemo, useState } from 'react' import { match } from 'ts-pattern' @@ -367,11 +368,9 @@ function FlatItemRow({ item, isFocused, isSelected, isDisabled }: FlatItemRowPro undefined) + .with({ disabled: true }, () => 'gray' as const) .with({ isFocused: true }, () => colors.primary) .otherwise(() => undefined)} - dimColor={disabled} - strikethrough={disabled} > {match(isFocused) .with(true, () => `${symbols.pointer} `) @@ -382,11 +381,24 @@ function FlatItemRow({ item, isFocused, isSelected, isDisabled }: FlatItemRowPro .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) => ( - {hint} + 'gray' as const) + .with(false, () => undefined) + .exhaustive()} + dimColor + > + {' '} + {hint} + ))} ) diff --git a/packages/core/src/ui/prompts/option-row.tsx b/packages/core/src/ui/prompts/option-row.tsx index 6ded1b97..410cb7b0 100644 --- a/packages/core/src/ui/prompts/option-row.tsx +++ b/packages/core/src/ui/prompts/option-row.tsx @@ -8,6 +8,7 @@ */ import { Box, Text } from 'ink' +import picocolors from 'picocolors' import type { ReactElement } from 'react' import { match } from 'ts-pattern' @@ -69,29 +70,39 @@ export function OptionRow({ .exhaustive()} 'gray' as const) .with({ isSelected: true }, () => colors.primary) .with({ isFocused: true }, () => colors.primary) .otherwise(() => undefined)} - dimColor={isOptionDisabled} > {indicator} colors.primary) - .with(false, () => undefined) - .exhaustive()} - dimColor={isOptionDisabled} - strikethrough={option.disabled === true} + color={match({ isOptionDisabled, isFocused }) + .with({ isOptionDisabled: true }, () => '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) => ( - {` ${hint}`} + 'gray' as const) + .with(false, () => undefined) + .exhaustive()} + dimColor + > + {` ${hint}`} + ))} ) 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 diff --git a/turbo.json b/turbo.json index 67aed59d..ac43f9f5 100644 --- a/turbo.json +++ b/turbo.json @@ -15,7 +15,7 @@ "test:coverage": { "dependsOn": ["build"] }, - "dev": { +"dev": { "cache": false, "persistent": true }, From 2ceb33d2ec420857884a2c8bae4c0344f7e903b8 Mon Sep 17 00:00:00 2001 From: Zac Rosenbauer Date: Sun, 29 Mar 2026 20:33:36 -0400 Subject: [PATCH 07/12] chore(repo): sync formatting from main Co-Authored-By: Claude --- codspeed.yml | 4 ++-- turbo.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) 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/turbo.json b/turbo.json index ac43f9f5..67aed59d 100644 --- a/turbo.json +++ b/turbo.json @@ -15,7 +15,7 @@ "test:coverage": { "dependsOn": ["build"] }, -"dev": { + "dev": { "cache": false, "persistent": true }, From 695fa5488ef68cca3a3b1372a8b66e7492a9f7d7 Mon Sep 17 00:00:00 2001 From: Zac Rosenbauer Date: Sun, 29 Mar 2026 20:42:39 -0400 Subject: [PATCH 08/12] refactor(core): remove unnecessary Object.freeze from story data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Static test/story data doesn't need to be frozen — it's already a const binding that nothing mutates. Co-Authored-By: Claude --- packages/core/src/ui/prompts/autocomplete.stories.tsx | 4 ++-- packages/core/src/ui/prompts/group-multi-select.stories.tsx | 4 ++-- packages/core/src/ui/prompts/select-key.stories.tsx | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/core/src/ui/prompts/autocomplete.stories.tsx b/packages/core/src/ui/prompts/autocomplete.stories.tsx index 3387253b..acd79bb3 100644 --- a/packages/core/src/ui/prompts/autocomplete.stories.tsx +++ b/packages/core/src/ui/prompts/autocomplete.stories.tsx @@ -11,7 +11,7 @@ const schema = z.object({ isDisabled: z.boolean().optional().describe('Disable the component'), }) -const defaultOptions = Object.freeze([ +const defaultOptions = [ { value: 'react', label: 'React', hint: 'UI library' }, { value: 'vue', label: 'Vue', hint: 'Progressive framework' }, { value: 'angular', label: 'Angular', hint: 'Platform' }, @@ -20,7 +20,7 @@ const defaultOptions = Object.freeze([ { 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', diff --git a/packages/core/src/ui/prompts/group-multi-select.stories.tsx b/packages/core/src/ui/prompts/group-multi-select.stories.tsx index e506108d..70b71388 100644 --- a/packages/core/src/ui/prompts/group-multi-select.stories.tsx +++ b/packages/core/src/ui/prompts/group-multi-select.stories.tsx @@ -11,7 +11,7 @@ const schema = z.object({ isDisabled: z.boolean().optional().describe('Disable the component'), }) -const defaultOptions = Object.freeze({ +const defaultOptions = { Fruits: [ { value: 'apple', label: 'Apple', hint: 'Sweet' }, { value: 'banana', label: 'Banana' }, @@ -21,7 +21,7 @@ const defaultOptions = Object.freeze({ { value: 'carrot', label: 'Carrot' }, { value: 'broccoli', label: 'Broccoli', hint: 'Healthy' }, ], -}) +} const storyGroup: StoryGroup = stories({ title: 'GroupMultiSelect', diff --git a/packages/core/src/ui/prompts/select-key.stories.tsx b/packages/core/src/ui/prompts/select-key.stories.tsx index dc2a41b4..4fb8f687 100644 --- a/packages/core/src/ui/prompts/select-key.stories.tsx +++ b/packages/core/src/ui/prompts/select-key.stories.tsx @@ -9,12 +9,12 @@ const schema = z.object({ isDisabled: z.boolean().optional().describe('Disable the component'), }) -const defaultOptions = Object.freeze([ +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', From 4c9f5af34d565dd676add6f2518fc6932eed4314 Mon Sep 17 00:00:00 2001 From: Zac Rosenbauer Date: Sun, 29 Mar 2026 20:47:49 -0400 Subject: [PATCH 09/12] fix(core): address PR review feedback - Remove docs/designs/component-library.md design doc - Guard focus movement in autocomplete when filtered list is empty - Fix key collision in group-multi-select by using option.value instead of label Co-Authored-By: Claude --- docs/designs/component-library.md | 675 ------------------ packages/core/src/ui/prompts/autocomplete.tsx | 6 + .../src/ui/prompts/group-multi-select.tsx | 2 +- 3 files changed, 7 insertions(+), 676 deletions(-) delete mode 100644 docs/designs/component-library.md diff --git a/docs/designs/component-library.md b/docs/designs/component-library.md deleted file mode 100644 index 9a5a47de..00000000 --- a/docs/designs/component-library.md +++ /dev/null @@ -1,675 +0,0 @@ -# Component Library Design - -> Design document for kidd's React/Ink UI component library with full clack-level API coverage. - -## Context - -kidd has two rendering paths: - -1. **Handler mode** (`ctx.prompts`, `ctx.log`, `ctx.status`) — imperative, delegates to `@clack/prompts`. The `feat/log-config` branch adds full clack API coverage here. -2. **Screen mode** (React/Ink) — declarative components for TUI screens. Currently re-exports `@inkjs/ui` components with no customization. - -The screen-mode components don't match the clack visual style and lack features clack provides (disabled options, hints, validation, generic values, etc.). This design closes that gap by building a full component library with clack-level API coverage. - -## Assessment: @inkjs/ui v2 - -### What it provides - -| Component | Props | -| --------------- | ------------------------------------------------------------------------------------------------------ | -| `Select` | `options`, `defaultValue`, `onChange`, `isDisabled`, `visibleOptionCount`, `highlightText` | -| `MultiSelect` | `options`, `defaultValue`, `onChange`, `onSubmit`, `isDisabled`, `visibleOptionCount`, `highlightText` | -| `ConfirmInput` | `defaultChoice`, `onConfirm`, `onCancel`, `isDisabled`, `submitOnEnter` | -| `TextInput` | `placeholder`, `defaultValue`, `suggestions`, `onChange`, `onSubmit`, `isDisabled` | -| `PasswordInput` | `placeholder`, `onChange`, `onSubmit`, `isDisabled` | -| `EmailInput` | `placeholder`, `defaultValue`, `domains`, `onChange`, `onSubmit`, `isDisabled` | -| `Spinner` | `label`, `type` | -| `ProgressBar` | `value` (0-100) | -| `Alert` | `children`, `variant`, `title` | -| `StatusMessage` | `children`, `variant` | -| `Badge` | `children`, `color` | -| `OrderedList` | list rendering | -| `UnorderedList` | list rendering | - -### Theming system - -`@inkjs/ui` v2 has a `ThemeProvider` + `extendTheme` + `useComponentTheme` system. Each component reads styles from the theme context. Styles are functions that receive state (`isFocused`, `isSelected`, etc.) and return Ink props (`color`, `bold`, `gap`, `paddingLeft`, etc.). Some components also have a `config` function for swapping symbols/characters. - -### Limitations - -- **`Option` type** is `{ label: string, value: string }` — no `disabled`, no `hint`, value must be `string` -- **No validation** on TextInput or PasswordInput -- **No generic value types** — everything is `string` -- **ConfirmInput** has no custom active/inactive labels, no message header -- **PasswordInput** has no custom mask character -- **Spinner** has no start/stop/message lifecycle (just renders an animated frame + label) -- **ProgressBar** has no `max`, no `style` variants, no label — just `value` (0-100) -- **Components are opinionated black boxes** — no render props, no slot patterns - -## Decision: Build Custom for Full API Coverage - -Every component in the kidd library is built custom on raw Ink primitives. This ensures full clack-level API coverage — disabled options, hints, validation, generic values, lifecycle controls — with a consistent visual style. No component is "theme only" since even the simpler @inkjs/ui components (Spinner, ProgressBar) lack features clack provides. - -`@inkjs/ui` remains available as `@kidd-cli/core/ui/base` for anyone who wants the raw vanilla Ink widgets. - -### Gap analysis: @inkjs/ui vs clack - -| Component | @inkjs/ui gaps | -| -------------------- | ---------------------------------------------------------------------------------------------------------------- | -| **Select** | No per-option `disabled`, `hint`. No generic `TValue`. No `onSubmit`. Value is `string` only. | -| **MultiSelect** | No per-option `disabled`, `hint`. No `required`. No generic `TValue`. Value is `string` only. | -| **ConfirmInput** | No custom active/inactive labels. | -| **TextInput** | No `validate`. | -| **PasswordInput** | No `validate`. No custom mask character. | -| **Spinner** | No start/stop/message lifecycle. Just `label` + `type`. Clack's spinner has `.start()`, `.stop()`, `.message()`. | -| **ProgressBar** | No `max`, no `style` variants (light/heavy/block), no label. Clack has `advance(step, message)`. | -| **Alert** | Adequate for display, but kidd version should match clack `box()` API (width, alignment, padding, rounded). | -| **StatusMessage** | Adequate for display, but kidd version should use clack's symbol set. | -| **GroupMultiSelect** | Missing entirely. | -| **Autocomplete** | Missing entirely. | -| **SelectKey** | Missing entirely. | -| **PathPrompt** | Missing entirely. | - -## Structure - -The codebase splits into two concerns: the **screen runtime** (framework plumbing that mounts Ink, wires context, manages the output store) and the **component library** (what users compose inside their screens). - -```text -packages/core/src/ -├── screen/ -│ ├── screen.tsx # screen() factory — mounts Ink, creates store, wires context -│ ├── provider.tsx # KiddContext provider (React context for screen runtime) -│ ├── output/ -│ │ ├── store.ts # OutputStore factory (internal) -│ │ ├── store-key.ts # Symbol injection for output store (internal) -│ │ ├── screen-log.ts # Screen-backed Log implementation (internal) -│ │ ├── screen-spinner.ts # Screen-backed Spinner implementation (internal) -│ │ ├── screen-report.ts # Screen-backed Report implementation (internal) -│ │ ├── types.ts # OutputEntry, SpinnerState, etc. -│ │ └── index.ts # Internal barrel -│ └── index.ts # Exports: screen, useScreenContext -├── command/ # Existing — command() factory, handler types -├── context/ # Existing — CommandContext, ScreenContext, prompts, status -├── middleware/ # Existing — auth, http, icons, etc. -├── ui/ -│ ├── base/ -│ │ └── index.ts # Raw @inkjs/ui + Ink re-exports (vanilla, no kidd styling) -│ ├── prompts/ -│ │ ├── select.tsx # Single-select with disabled, hints, generic value -│ │ ├── multi-select.tsx # Multi-select with checkboxes, required, generic value -│ │ ├── group-multi-select.tsx # Grouped multi-select with section headers -│ │ ├── confirm.tsx # Confirm with custom active/inactive labels -│ │ ├── text-input.tsx # Text input with validate, placeholder -│ │ ├── password-input.tsx # Password input with validate, custom mask -│ │ ├── autocomplete.tsx # Filtered select with text input -│ │ ├── select-key.tsx # Single-keypress selection -│ │ ├── path-input.tsx # Filesystem path autocomplete -│ │ ├── types.ts # PromptOption, shared prompt types -│ │ └── index.ts # Barrel export for all prompt components -│ ├── display/ -│ │ ├── spinner.tsx # Spinner with start/stop/message lifecycle -│ │ ├── progress-bar.tsx # Progress bar with max, style, label -│ │ ├── alert.tsx # Alert box with variant, title, alignment -│ │ ├── status-message.tsx # Status message with variant icons -│ │ └── index.ts # Barrel export for all display components -│ ├── layout/ -│ │ ├── scroll-area.tsx # Existing (moved) -│ │ ├── tabs.tsx # Existing (moved) -│ │ ├── fullscreen.tsx # Existing (moved) -│ │ ├── use-size.tsx # Existing (moved) -│ │ └── index.ts # Barrel export for all layout components -│ ├── output.tsx # component (reads from screen's output store) -│ ├── theme.ts # Kidd color palette and symbol constants -│ └── index.ts # Root barrel — re-exports prompts/, display/, layout/, Output -``` - -### Separation of concerns - -- **`screen/`** — a peer to `command/` at the core level. Owns `screen()` factory, context provider, and the output store internals (screen-backed log, spinner, report). Users never import from `screen/output/` directly. `screen()` and `useScreenContext()` are exported from the core barrel alongside `command()`, `cli()`, etc. -- **`ui/`** — component library. Everything users compose inside their screens. `` lives here as a component that reads from the store `screen()` creates — it's the user-facing interface to the output system. - -`screen()` imports `FullScreen` from `ui/layout/` when `fullscreen: true` is set. It does not import any prompt or display components — those are purely user-land. - -### Import examples - -```ts -// Core — screen() is a peer to command() -import { command, screen, useScreenContext } from '@kidd-cli/core' - -// Component library — single flat import path -import { - Select, - MultiSelect, - Confirm, - TextInput, - PasswordInput, - Spinner, - ProgressBar, - Alert, - StatusMessage, - Tabs, - ScrollArea, - FullScreen, - Output, -} from '@kidd-cli/core/ui' -``` - -### Export paths - -| Path | What it exports | -| ------------------- | ---------------------------------------------------------------------------------- | -| `@kidd-cli/core` | `cli()`, `command()`, `screen()`, `useScreenContext()`, middleware, context types | -| `@kidd-cli/core/ui` | All kidd components — prompts, display, layout, Output, base Ink primitives, types | - -The directory structure (`prompts/`, `display/`, `layout/`, `base/`) is an internal organizational detail. Users always import from `@kidd-cli/core/ui` — one path, one barrel. - -## Shared Types - -```ts -interface PromptOption { - readonly value: TValue - readonly label: string - readonly hint?: string - readonly disabled?: boolean -} -``` - -This replaces `@inkjs/ui`'s `Option` (`{ label: string, value: string }`) for all kidd components. - -## Component APIs - -### Select - -```tsx -interface SelectProps { - readonly options: readonly PromptOption[] - readonly defaultValue?: TValue - readonly maxVisible?: number - readonly onChange?: (value: TValue) => void - readonly onSubmit?: (value: TValue) => void - readonly isDisabled?: boolean -} -``` - -- Arrow keys navigate, disabled options are skipped -- Focused option shows pointer indicator, selected shows filled dot -- Hints render dimmed beside the label -- Disabled options render dimmed with strikethrough -- Scrolls when list exceeds `maxVisible` - -### MultiSelect - -```tsx -interface MultiSelectProps { - readonly options: readonly PromptOption[] - readonly defaultValue?: readonly TValue[] - readonly maxVisible?: number - readonly required?: boolean - readonly onChange?: (value: readonly TValue[]) => void - readonly onSubmit?: (value: readonly TValue[]) => void - readonly isDisabled?: boolean -} -``` - -- Space toggles selection (filled/empty checkbox) -- Enter submits; if `required` and nothing selected, shows validation message -- Disabled options shown but not toggleable - -### Confirm - -```tsx -interface ConfirmProps { - readonly active?: string // default: "Yes" - readonly inactive?: string // default: "No" - readonly defaultValue?: boolean - readonly onSubmit?: (value: boolean) => void - readonly isDisabled?: boolean -} -``` - -- Left/right or y/n to toggle -- Enter to submit -- Active choice highlighted, inactive dimmed - -### TextInput - -```tsx -interface TextInputProps { - readonly placeholder?: string - readonly defaultValue?: string - readonly validate?: (value: string) => string | undefined - readonly onChange?: (value: string) => void - readonly onSubmit?: (value: string) => void - readonly isDisabled?: boolean -} -``` - -- Full cursor movement (left/right, home/end, backspace/delete) -- Validation runs on submit; error shown below input -- Placeholder shown dimmed when empty - -### PasswordInput - -```tsx -interface PasswordInputProps { - readonly placeholder?: string - readonly mask?: string // default: "*" - readonly validate?: (value: string) => string | undefined - readonly onChange?: (value: string) => void - readonly onSubmit?: (value: string) => void - readonly isDisabled?: boolean -} -``` - -- Same as TextInput but input masked with `mask` character - -### GroupMultiSelect - -```tsx -interface GroupMultiSelectProps { - readonly options: Readonly[]>> - readonly defaultValue?: readonly TValue[] - readonly required?: boolean - readonly selectableGroups?: boolean - readonly onChange?: (value: readonly TValue[]) => void - readonly onSubmit?: (value: readonly TValue[]) => void - readonly isDisabled?: boolean -} -``` - -- Group headers rendered as section labels -- If `selectableGroups`, toggling a group header toggles all its options -- Options within each group are indented - -### Autocomplete - -```tsx -interface AutocompleteProps { - readonly options: readonly PromptOption[] - readonly placeholder?: string - readonly maxVisible?: number - readonly defaultValue?: TValue - readonly filter?: (search: string, option: PromptOption) => boolean - readonly onChange?: (value: TValue) => void - readonly onSubmit?: (value: TValue) => void - readonly isDisabled?: boolean -} -``` - -- Text input filters the option list in real-time -- Default filter: case-insensitive label substring match -- Arrow keys navigate filtered results, enter selects - -### SelectKey - -```tsx -interface SelectKeyProps { - readonly options: readonly PromptOption[] - readonly onSubmit?: (value: TValue) => void - readonly isDisabled?: boolean -} -``` - -- Each option's `value` is a single key character -- Pressing the key immediately selects that option -- Options rendered with key highlighted - -### PathInput - -```tsx -interface PathInputProps { - readonly root?: string - readonly directoryOnly?: boolean - readonly defaultValue?: string - readonly validate?: (value: string) => string | undefined - readonly onChange?: (value: string) => void - readonly onSubmit?: (value: string) => void - readonly isDisabled?: boolean -} -``` - -- Text input with tab-completion from the filesystem -- Suggestions shown below input -- `directoryOnly` filters to directories - -### Spinner - -```tsx -interface SpinnerProps { - readonly label?: string - readonly isActive?: boolean - readonly type?: SpinnerName // from cli-spinners -} -``` - -- Renders animated spinner frame + label -- `isActive` controls whether the spinner animates (default `true`) -- Matches clack's visual style (colored frame, label beside it) - -### ProgressBar - -```tsx -interface ProgressBarProps { - readonly value: number - readonly max?: number // default: 100 - readonly label?: string - readonly style?: 'light' | 'heavy' | 'block' - readonly size?: number // bar width in characters -} -``` - -- Renders a progress bar with completed/remaining segments -- `style` controls the bar characters (light shade, heavy shade, or block) -- `label` shown beside the bar -- Automatically fills available width if `size` not specified - -### Alert - -```tsx -interface AlertProps { - readonly children: ReactNode - readonly variant: 'info' | 'success' | 'error' | 'warning' - readonly title?: string - readonly width?: number | 'auto' - readonly rounded?: boolean - readonly contentAlign?: 'left' | 'center' | 'right' - readonly titleAlign?: 'left' | 'center' | 'right' -} -``` - -- Bordered box with variant-colored border and icon -- Matches clack `box()` API for alignment and width control - -### StatusMessage - -```tsx -interface StatusMessageProps { - readonly children: ReactNode - readonly variant: 'info' | 'success' | 'error' | 'warning' -} -``` - -- Icon + message, variant determines color and symbol -- Uses clack's symbol set (matching `ctx.log.info/success/error/warn`) - -## Visual Style - -All components use a consistent clack-inspired visual language: - -**Select (focused):** - -```text - ● Option A hint text - ○ Option B - ○ Option C hint text - ○ Option D (disabled) -``` - -**MultiSelect (focused):** - -```text - ◼ TypeScript - ◻ ESLint - ◼ Prettier - ◻ Tailwind (disabled) -``` - -**Confirm:** - -```text - Yes / No -``` - -**TextInput:** - -```text - my-project█ -``` - -**TextInput (validation error):** - -```text - █ - Project name is required. -``` - -**Spinner:** - -```text - ◒ Loading... -``` - -**ProgressBar:** - -```text - ████████░░░░░░░░ 50% Installing dependencies -``` - -**Alert:** - -```text - ╭─ Warning ──────────────────╮ - │ ⚠ Config file not found. │ - ╰────────────────────────────╯ -``` - -### Color palette - -| Element | Color | -| ---------------------------------------- | --------------------- | -| Focused option pointer/label | `cyan` | -| Selected indicator (filled dot/checkbox) | `cyan` | -| Unselected indicator | `dim` | -| Disabled option | `dim` + strikethrough | -| Hint text | `dim` | -| Validation error | `red` | -| Active confirm choice | `cyan` + underline | -| Inactive confirm choice | `dim` | -| Cursor | inverse | -| Placeholder | `dim` | -| Spinner frame | `cyan` | -| Progress completed | `cyan` | -| Progress remaining | `dim` | -| Alert info border | `blue` | -| Alert success border | `green` | -| Alert error border | `red` | -| Alert warning border | `yellow` | - -## Stories - -Every component ships with a `.stories.tsx` file using kidd's built-in stories system (`@kidd-cli/core/stories`). Stories are viewable via `kidd stories` in the terminal with live hot-reload, interactive props editing, and keyboard navigation. - -### Story pattern - -Each component gets a story group with variants covering key states: - -```tsx -// ui/prompts/select.stories.tsx -import { stories } from '@kidd-cli/core/stories' -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'), -}) - -export default stories({ - title: 'Select', - component: Select, - 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 hints and disabled option', - }, - Disabled: { - props: { maxVisible: 5, isDisabled: true }, - description: 'Fully disabled select', - }, - }, -}) -``` - -### Required stories per component - -| Component | Variants | -| -------------------- | ------------------------------------------------------------- | -| **Select** | Default, WithHints, DisabledOptions, Scrolling, Disabled | -| **MultiSelect** | Default, WithRequired, DisabledOptions, Preselected, Disabled | -| **Confirm** | Default, CustomLabels, DefaultNo, Disabled | -| **TextInput** | Default, WithPlaceholder, WithValidation, Disabled | -| **PasswordInput** | Default, CustomMask, WithValidation, Disabled | -| **GroupMultiSelect** | Default, SelectableGroups, WithRequired | -| **Autocomplete** | Default, CustomFilter, Disabled | -| **SelectKey** | Default, Disabled | -| **PathInput** | Default, DirectoryOnly, WithValidation | -| **Spinner** | Default, CustomType, Inactive | -| **ProgressBar** | Empty, Half, Full, WithLabel, StyleVariants | -| **Alert** | Info, Success, Error, Warning, WithTitle | -| **StatusMessage** | Info, Success, Error, Warning | - -### Viewing - -```bash -kidd stories # Launch TUI viewer with hot-reload -kidd stories --check # Validate all stories -kidd stories --out Select # Render Select story to stdout -kidd stories --include 'ui/**' # Filter to ui stories only -``` - -## Phases - -### Phase 1: Foundation - -1. Extract `screen/` — move `screen.tsx`, `provider.tsx`, `output/` into `src/screen/` -2. Create `ui/base/index.ts` — move raw Ink + @inkjs/ui re-exports here -3. Create `ui/theme.ts` — kidd color palette and symbol constants -4. Create `ui/prompts/types.ts` — `PromptOption`, shared prompt types -5. Move existing layout components into `ui/layout/` with barrel -6. Move `` component to `ui/output.tsx` (reads from screen's store) -7. Set up barrel exports for each group and root `ui/index.ts` -8. Update package exports in `package.json` for new paths - -### Phase 2: Core Prompts (`ui/prompts/`) - -9. `prompts/select.tsx` + `prompts/select.stories.tsx` -10. `prompts/multi-select.tsx` + `prompts/multi-select.stories.tsx` -11. `prompts/confirm.tsx` + `prompts/confirm.stories.tsx` -12. `prompts/text-input.tsx` + `prompts/text-input.stories.tsx` -13. `prompts/password-input.tsx` + `prompts/password-input.stories.tsx` -14. `prompts/index.ts` — barrel export - -### Phase 3: Extended Prompts (`ui/prompts/`) - -15. `prompts/group-multi-select.tsx` + `prompts/group-multi-select.stories.tsx` -16. `prompts/autocomplete.tsx` + `prompts/autocomplete.stories.tsx` -17. `prompts/select-key.tsx` + `prompts/select-key.stories.tsx` -18. `prompts/path-input.tsx` + `prompts/path-input.stories.tsx` - -### Phase 4: Display Components (`ui/display/`) - -19. `display/spinner.tsx` + `display/spinner.stories.tsx` -20. `display/progress-bar.tsx` + `display/progress-bar.stories.tsx` -21. `display/alert.tsx` + `display/alert.stories.tsx` -22. `display/status-message.tsx` + `display/status-message.stories.tsx` -23. `display/index.ts` — barrel export - -### Phase 5: Integration - -24. Update root `ui/index.ts` to re-export all groups -25. Update component standards doc -26. Run `kidd stories --check` to validate all stories pass - -## Agent Team Execution Plan - -Work is parallelized across agent teams. Phase 1 (foundation) runs first since everything depends on it. After that, phases 2-4 run in parallel — prompts, extended prompts, and display components have no dependencies on each other. - -### Phase 1: Foundation (sequential — blocks everything) - -**Agent: `foundation`** - -- Extract `screen/` directory, move `output/` internals -- Create `ui/base/`, `ui/theme.ts`, `ui/prompts/types.ts` -- Move layout components into `ui/layout/` -- Move `` to `ui/output.tsx` -- Wire barrel exports, update `package.json` -- Run `pnpm check` to verify nothing broke - -### Phase 2-4: Components (parallel teams after Phase 1) - -**Agent: `prompts-core`** (Phase 2) - -- Build Select + stories -- Build MultiSelect + stories -- Build Confirm + stories -- Build TextInput + stories -- Build PasswordInput + stories -- Wire `prompts/index.ts` barrel - -**Agent: `prompts-extended`** (Phase 3) - -- Build GroupMultiSelect + stories -- Build Autocomplete + stories -- Build SelectKey + stories -- Build PathInput + stories - -**Agent: `display`** (Phase 4) - -- Build Spinner + stories -- Build ProgressBar + stories -- Build Alert + stories -- Build StatusMessage + stories -- Wire `display/index.ts` barrel - -### Phase 5: Integration (sequential — after all teams complete) - -**Agent: `integration`** - -- Update root `ui/index.ts` barrel -- Update component standards doc -- Run `pnpm check` + `kidd stories --check` - -### Dependency graph - -``` -foundation - ├── prompts-core ──────┐ - ├── prompts-extended ──┼── integration - └── display ───────────┘ -``` - -## Build Approach - -All custom components are built on raw Ink primitives: - -- **Layout**: `Box` (flexbox), `Text` (styled text) -- **Input**: `useInput` from Ink with `isActive` guard -- **State**: `useState` / `useReducer` (following existing kidd patterns) -- **Conditionals**: `ts-pattern` `match()` — no ternaries, no switch -- **Immutability**: all props `readonly`, all state frozen, no `let` -- **Symbols**: `figures` package for cross-platform indicators -- **Colors**: Ink's `color` / `dimColor` / `bold` props - -No dependency on `@inkjs/ui` internals (OptionMap, etc.) — all custom components are self-contained. - -## Dependencies - -No new dependencies required. Everything used is already in the tree: - -- `ink` (Box, Text, useInput) -- `figures` (symbols) -- `ts-pattern` (conditionals) -- `@inkjs/ui` (re-exported via `ui/base` only) -- `react` (hooks) -- `cli-spinners` (spinner animation frames, already a dep of @inkjs/ui) diff --git a/packages/core/src/ui/prompts/autocomplete.tsx b/packages/core/src/ui/prompts/autocomplete.tsx index aa48f872..196f6d90 100644 --- a/packages/core/src/ui/prompts/autocomplete.tsx +++ b/packages/core/src/ui/prompts/autocomplete.tsx @@ -91,6 +91,9 @@ export function Autocomplete({ useInput( (input, key) => { if (key.upArrow) { + if (filtered.length === 0) { + return + } const next = Math.max(0, focusIndex - 1) setFocusIndex(next) const focused = filtered[next] @@ -101,6 +104,9 @@ export function Autocomplete({ } if (key.downArrow) { + if (filtered.length === 0) { + return + } const next = Math.min(filtered.length - 1, focusIndex + 1) setFocusIndex(next) const focused = filtered[next] diff --git a/packages/core/src/ui/prompts/group-multi-select.tsx b/packages/core/src/ui/prompts/group-multi-select.tsx index 997d1055..3a28b7dd 100644 --- a/packages/core/src/ui/prompts/group-multi-select.tsx +++ b/packages/core/src/ui/prompts/group-multi-select.tsx @@ -332,7 +332,7 @@ function isItemSelected( function itemKey(item: FlatItem): string { return match(item) .with({ kind: 'group' }, (i) => `group-${i.groupName}`) - .with({ kind: 'option' }, (i) => `option-${i.groupName}-${i.option.label}`) + .with({ kind: 'option' }, (i) => `option-${i.groupName}-${String(i.option.value)}`) .exhaustive() } From e64a58fa2b9f5690f6380077dd69f44251d63cf5 Mon Sep 17 00:00:00 2001 From: Zac Rosenbauer Date: Sun, 29 Mar 2026 20:59:15 -0400 Subject: [PATCH 10/12] refactor(core): remove @module banner comments from all files Co-Authored-By: Claude --- packages/core/src/context/prompts.ts | 6 ------ packages/core/src/context/resolve-defaults.ts | 6 ------ packages/core/src/context/status.ts | 6 ------ packages/core/src/lib/log.ts | 6 ------ .../core/src/middleware/auth/oauth-server.ts | 9 --------- .../core/src/middleware/icons/definitions.ts | 9 --------- packages/core/src/middleware/icons/icons.ts | 9 --------- packages/core/src/middleware/report/report.ts | 9 --------- packages/core/src/middleware/report/types.ts | 6 ------ packages/core/src/screen/index.ts | 9 --------- packages/core/src/screen/output/index.ts | 7 ------- packages/core/src/screen/output/screen-log.ts | 7 ------- .../core/src/screen/output/screen-report.ts | 7 ------- .../core/src/screen/output/screen-spinner.ts | 7 ------- packages/core/src/screen/output/store-key.ts | 7 ------- packages/core/src/screen/output/store.ts | 8 -------- packages/core/src/screen/output/types.ts | 6 ------ .../src/screen/output/use-output-store.ts | 6 ------ packages/core/src/stories/index.ts | 9 --------- .../stories/viewer/hooks/use-double-escape.ts | 8 -------- packages/core/src/stories/viewer/utils.ts | 6 ------ packages/core/src/ui/display/alert.tsx | 10 ---------- .../core/src/ui/display/error-message.tsx | 9 --------- packages/core/src/ui/display/index.ts | 6 ------ packages/core/src/ui/display/progress-bar.tsx | 10 ---------- packages/core/src/ui/display/spinner.tsx | 9 --------- .../core/src/ui/display/status-message.tsx | 10 ---------- packages/core/src/ui/index.ts | 10 ---------- packages/core/src/ui/input-barrier.tsx | 20 ------------------- packages/core/src/ui/keys.ts | 8 -------- packages/core/src/ui/layout/fullscreen.tsx | 11 ---------- packages/core/src/ui/layout/index.ts | 6 ------ packages/core/src/ui/layout/scroll-area.tsx | 12 ----------- packages/core/src/ui/layout/tabs.tsx | 11 ---------- packages/core/src/ui/layout/use-size.tsx | 10 ---------- packages/core/src/ui/output.tsx | 8 -------- packages/core/src/ui/prompts/autocomplete.tsx | 10 ---------- packages/core/src/ui/prompts/confirm.tsx | 10 ---------- packages/core/src/ui/prompts/cursor-value.tsx | 9 --------- .../src/ui/prompts/group-multi-select.tsx | 10 ---------- packages/core/src/ui/prompts/index.ts | 6 ------ packages/core/src/ui/prompts/input-state.ts | 9 --------- packages/core/src/ui/prompts/multi-select.tsx | 11 ---------- packages/core/src/ui/prompts/navigation.ts | 9 --------- packages/core/src/ui/prompts/option-row.tsx | 9 --------- .../core/src/ui/prompts/password-input.tsx | 11 ---------- packages/core/src/ui/prompts/path-input.tsx | 10 ---------- packages/core/src/ui/prompts/select-key.tsx | 10 ---------- packages/core/src/ui/prompts/select.tsx | 11 ---------- packages/core/src/ui/prompts/string-utils.ts | 6 ------ packages/core/src/ui/prompts/text-input.tsx | 10 ---------- packages/core/src/ui/prompts/types.ts | 6 ------ packages/core/src/ui/theme.ts | 9 --------- packages/core/src/ui/use-key-binding.ts | 9 --------- packages/core/src/ui/use-key-input.ts | 8 -------- 55 files changed, 476 deletions(-) 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/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 index d740aa5c..7469f4ee 100644 --- a/packages/core/src/screen/index.ts +++ b/packages/core/src/screen/index.ts @@ -1,12 +1,3 @@ -/** - * Screen runtime — mounts Ink, wires context, manages the output store. - * - * A peer to `command/` at the core level. Users never import from - * `screen/output/` directly. - * - * @module - */ - export { screen } from './screen.js' export type { ScreenDef, ScreenExit } from './screen.js' diff --git a/packages/core/src/screen/output/index.ts b/packages/core/src/screen/output/index.ts index fd8d5dc3..e33807cf 100644 --- a/packages/core/src/screen/output/index.ts +++ b/packages/core/src/screen/output/index.ts @@ -1,10 +1,3 @@ -/** - * Screen output system for rendering `ctx.log`, `ctx.status.spinner`, and - * `ctx.report` inside React/Ink screen components. - * - * @module - */ - export { useOutputStore } from './use-output-store.js' export { createOutputStore } from './store.js' diff --git a/packages/core/src/screen/output/screen-log.ts b/packages/core/src/screen/output/screen-log.ts index c2295803..842bf839 100644 --- a/packages/core/src/screen/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/screen/output/screen-report.ts b/packages/core/src/screen/output/screen-report.ts index 7966bdff..7a2f7d07 100644 --- a/packages/core/src/screen/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/screen/output/screen-spinner.ts b/packages/core/src/screen/output/screen-spinner.ts index 4fd60691..553ed4de 100644 --- a/packages/core/src/screen/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/screen/output/store-key.ts b/packages/core/src/screen/output/store-key.ts index 240ab7ef..5dafa8e9 100644 --- a/packages/core/src/screen/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' diff --git a/packages/core/src/screen/output/store.ts b/packages/core/src/screen/output/store.ts index 53241ec9..7dbad25b 100644 --- a/packages/core/src/screen/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/screen/output/types.ts b/packages/core/src/screen/output/types.ts index be700a06..d614080f 100644 --- a/packages/core/src/screen/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/screen/output/use-output-store.ts b/packages/core/src/screen/output/use-output-store.ts index 9ae4276e..8914a23b 100644 --- a/packages/core/src/screen/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/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/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/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/display/alert.tsx b/packages/core/src/ui/display/alert.tsx index 09eb1629..02bb19c0 100644 --- a/packages/core/src/ui/display/alert.tsx +++ b/packages/core/src/ui/display/alert.tsx @@ -1,13 +1,3 @@ -/** - * Alert UI component. - * - * Renders a bordered box with variant-colored borders, an optional title, - * and an icon matching the alert variant. Supports rounded and square - * border styles with configurable width and alignment. - * - * @module - */ - import { Text } from 'ink' import type { ReactElement } from 'react' import { match } from 'ts-pattern' diff --git a/packages/core/src/ui/display/error-message.tsx b/packages/core/src/ui/display/error-message.tsx index bbdb46c9..64115daf 100644 --- a/packages/core/src/ui/display/error-message.tsx +++ b/packages/core/src/ui/display/error-message.tsx @@ -1,12 +1,3 @@ -/** - * Inline validation error message component. - * - * Shared by prompt components that display validation errors below - * their input area. - * - * @module - */ - import { Text } from 'ink' import type { ReactElement } from 'react' import { match } from 'ts-pattern' diff --git a/packages/core/src/ui/display/index.ts b/packages/core/src/ui/display/index.ts index e3fdd752..7c17248f 100644 --- a/packages/core/src/ui/display/index.ts +++ b/packages/core/src/ui/display/index.ts @@ -1,9 +1,3 @@ -/** - * Display components for presenting information in terminal UIs. - * - * @module - */ - export { Alert } from './alert.js' export type { AlertProps, AlertVariant } from './alert.js' diff --git a/packages/core/src/ui/display/progress-bar.tsx b/packages/core/src/ui/display/progress-bar.tsx index 597b1744..8e271300 100644 --- a/packages/core/src/ui/display/progress-bar.tsx +++ b/packages/core/src/ui/display/progress-bar.tsx @@ -1,13 +1,3 @@ -/** - * ProgressBar UI component. - * - * Renders a horizontal progress bar with configurable style, size, and - * label. Displays a percentage alongside the bar and an optional - * descriptive label. - * - * @module - */ - import { Text } from 'ink' import type { ReactElement } from 'react' import { match } from 'ts-pattern' diff --git a/packages/core/src/ui/display/spinner.tsx b/packages/core/src/ui/display/spinner.tsx index b5e785c3..27c8147e 100644 --- a/packages/core/src/ui/display/spinner.tsx +++ b/packages/core/src/ui/display/spinner.tsx @@ -1,12 +1,3 @@ -/** - * Spinner UI component. - * - * Renders an animated spinner with an optional label. Spinner frame data - * is inlined to avoid depending on transitive packages. - * - * @module - */ - import { Text } from 'ink' import type { ReactElement } from 'react' import { useEffect, useState } from 'react' diff --git a/packages/core/src/ui/display/status-message.tsx b/packages/core/src/ui/display/status-message.tsx index 6290cf3d..73128f6a 100644 --- a/packages/core/src/ui/display/status-message.tsx +++ b/packages/core/src/ui/display/status-message.tsx @@ -1,13 +1,3 @@ -/** - * StatusMessage UI component. - * - * Renders an icon and message colored according to a variant. Useful for - * displaying success, error, warning, or informational messages in a - * consistent style. - * - * @module - */ - import { Text } from 'ink' import type { ReactElement } from 'react' import { match } from 'ts-pattern' diff --git a/packages/core/src/ui/index.ts b/packages/core/src/ui/index.ts index ad2055ad..81b5fe5e 100644 --- a/packages/core/src/ui/index.ts +++ b/packages/core/src/ui/index.ts @@ -1,13 +1,3 @@ -/** - * UI components for building interactive terminal interfaces. - * - * Re-exports kidd components (prompts, display, layout), the Output - * component, base Ink primitives, and shared types. Users import - * everything from `@kidd-cli/core/ui` — one path, one barrel. - * - * @module - */ - // --------------------------------------------------------------------------- // Base — raw Ink + @inkjs/ui primitives // --------------------------------------------------------------------------- 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/layout/fullscreen.tsx b/packages/core/src/ui/layout/fullscreen.tsx index 8b90d861..8f385b45 100644 --- a/packages/core/src/ui/layout/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' diff --git a/packages/core/src/ui/layout/index.ts b/packages/core/src/ui/layout/index.ts index 9f143c80..6578e0a9 100644 --- a/packages/core/src/ui/layout/index.ts +++ b/packages/core/src/ui/layout/index.ts @@ -1,9 +1,3 @@ -/** - * Layout components for building terminal UI structures. - * - * @module - */ - export { FullScreen, useFullScreen, useTerminalSize } from './fullscreen.js' export type { FullScreenProps, FullScreenState, TerminalSize } from './fullscreen.js' diff --git a/packages/core/src/ui/layout/scroll-area.tsx b/packages/core/src/ui/layout/scroll-area.tsx index a5555b48..57e24fac 100644 --- a/packages/core/src/ui/layout/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/layout/tabs.tsx b/packages/core/src/ui/layout/tabs.tsx index 8528a76c..91b17659 100644 --- a/packages/core/src/ui/layout/tabs.tsx +++ b/packages/core/src/ui/layout/tabs.tsx @@ -1,14 +1,3 @@ -/** - * 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' diff --git a/packages/core/src/ui/layout/use-size.tsx b/packages/core/src/ui/layout/use-size.tsx index 337ad5be..c803a4dd 100644 --- a/packages/core/src/ui/layout/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/output.tsx b/packages/core/src/ui/output.tsx index cf3802b7..2b80baa3 100644 --- a/packages/core/src/ui/output.tsx +++ b/packages/core/src/ui/output.tsx @@ -1,11 +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 { Box, Text } from 'ink' import type { ReactElement } from 'react' import { useSyncExternalStore } from 'react' diff --git a/packages/core/src/ui/prompts/autocomplete.tsx b/packages/core/src/ui/prompts/autocomplete.tsx index 196f6d90..3eb7fc09 100644 --- a/packages/core/src/ui/prompts/autocomplete.tsx +++ b/packages/core/src/ui/prompts/autocomplete.tsx @@ -1,13 +1,3 @@ -/** - * Autocomplete prompt component. - * - * Provides a text input that filters a list of options in real-time. - * Arrow keys navigate the filtered results and Enter selects the - * focused option. - * - * @module - */ - import { Box, Text, useInput } from 'ink' import type { ReactElement } from 'react' import { useMemo, useState } from 'react' diff --git a/packages/core/src/ui/prompts/confirm.tsx b/packages/core/src/ui/prompts/confirm.tsx index 80fbc36e..3c920263 100644 --- a/packages/core/src/ui/prompts/confirm.tsx +++ b/packages/core/src/ui/prompts/confirm.tsx @@ -1,13 +1,3 @@ -/** - * Confirm prompt component. - * - * A boolean yes/no prompt for terminal UIs. Renders two toggle choices - * that can be switched with left/right arrows or y/n keys. The active - * choice is highlighted with cyan and underline styling. - * - * @module - */ - import { Box, Text, useInput } from 'ink' import type { ReactElement } from 'react' import { useState } from 'react' diff --git a/packages/core/src/ui/prompts/cursor-value.tsx b/packages/core/src/ui/prompts/cursor-value.tsx index 768fa1ce..8cabf133 100644 --- a/packages/core/src/ui/prompts/cursor-value.tsx +++ b/packages/core/src/ui/prompts/cursor-value.tsx @@ -1,12 +1,3 @@ -/** - * Shared cursor-rendered value component for text-based prompts. - * - * Renders a string with a visible cursor (inverse character) at the - * given position. Used by TextInput and PasswordInput. - * - * @module - */ - import { Text } from 'ink' import type { ReactElement } from 'react' import { match } from 'ts-pattern' diff --git a/packages/core/src/ui/prompts/group-multi-select.tsx b/packages/core/src/ui/prompts/group-multi-select.tsx index 3a28b7dd..ba955cf3 100644 --- a/packages/core/src/ui/prompts/group-multi-select.tsx +++ b/packages/core/src/ui/prompts/group-multi-select.tsx @@ -1,13 +1,3 @@ -/** - * Group multi-select prompt component. - * - * Renders a multi-select list with options organized into named groups. - * Each group has a header label, and options within groups are indented. - * Supports toggling entire groups when `selectableGroups` is enabled. - * - * @module - */ - import { Box, Text, useInput } from 'ink' import picocolors from 'picocolors' import type { ReactElement } from 'react' diff --git a/packages/core/src/ui/prompts/index.ts b/packages/core/src/ui/prompts/index.ts index 048b6a53..6eb169a9 100644 --- a/packages/core/src/ui/prompts/index.ts +++ b/packages/core/src/ui/prompts/index.ts @@ -1,9 +1,3 @@ -/** - * Prompt components for interactive terminal input. - * - * @module - */ - export type { PromptOption } from './types.js' export { Autocomplete } from './autocomplete.js' diff --git a/packages/core/src/ui/prompts/input-state.ts b/packages/core/src/ui/prompts/input-state.ts index 93bf9022..4d09b8d9 100644 --- a/packages/core/src/ui/prompts/input-state.ts +++ b/packages/core/src/ui/prompts/input-state.ts @@ -1,12 +1,3 @@ -/** - * Shared input state management for text-based prompt components. - * - * Provides the state type, key descriptor, and state resolution logic - * shared by TextInput and PasswordInput. - * - * @module - */ - import { match } from 'ts-pattern' // --------------------------------------------------------------------------- diff --git a/packages/core/src/ui/prompts/multi-select.tsx b/packages/core/src/ui/prompts/multi-select.tsx index e96c487c..b0da49c7 100644 --- a/packages/core/src/ui/prompts/multi-select.tsx +++ b/packages/core/src/ui/prompts/multi-select.tsx @@ -1,14 +1,3 @@ -/** - * MultiSelect prompt component. - * - * A multi-value checkbox selector for terminal UIs. Renders a scrollable - * list of options with keyboard navigation (up/down arrows), space to - * toggle, and Enter to submit. Supports disabled options, required - * validation, and pre-selected defaults. - * - * @module - */ - import { Box, useInput } from 'ink' import type { ReactElement } from 'react' import { useState } from 'react' diff --git a/packages/core/src/ui/prompts/navigation.ts b/packages/core/src/ui/prompts/navigation.ts index 4ed9d175..fa8e2c63 100644 --- a/packages/core/src/ui/prompts/navigation.ts +++ b/packages/core/src/ui/prompts/navigation.ts @@ -1,12 +1,3 @@ -/** - * Shared keyboard navigation utilities for prompt components. - * - * Provides direction resolution and focus index computation used by - * Select, MultiSelect, and other navigable prompt components. - * - * @module - */ - import { match } from 'ts-pattern' import type { PromptOption } from './types.js' diff --git a/packages/core/src/ui/prompts/option-row.tsx b/packages/core/src/ui/prompts/option-row.tsx index 410cb7b0..865c1a71 100644 --- a/packages/core/src/ui/prompts/option-row.tsx +++ b/packages/core/src/ui/prompts/option-row.tsx @@ -1,12 +1,3 @@ -/** - * Shared option row component for prompt lists. - * - * Used by Select, MultiSelect, and other prompt components that render - * a vertical list of focusable options with indicator, label, and hint. - * - * @module - */ - import { Box, Text } from 'ink' import picocolors from 'picocolors' import type { ReactElement } from 'react' diff --git a/packages/core/src/ui/prompts/password-input.tsx b/packages/core/src/ui/prompts/password-input.tsx index 8d38f4ff..cc800c65 100644 --- a/packages/core/src/ui/prompts/password-input.tsx +++ b/packages/core/src/ui/prompts/password-input.tsx @@ -1,14 +1,3 @@ -/** - * PasswordInput prompt component. - * - * A masked single-line text input for terminal UIs. Behaves identically - * to {@link TextInput} but replaces each character with a configurable - * mask character (default `*`). Supports full cursor movement, - * validation, and placeholder text. - * - * @module - */ - import { Box, Text, useInput } from 'ink' import type { ReactElement } from 'react' import { useState } from 'react' diff --git a/packages/core/src/ui/prompts/path-input.tsx b/packages/core/src/ui/prompts/path-input.tsx index 057912f1..9d10aa86 100644 --- a/packages/core/src/ui/prompts/path-input.tsx +++ b/packages/core/src/ui/prompts/path-input.tsx @@ -1,13 +1,3 @@ -/** - * Path input prompt component. - * - * Provides a text input with tab-completion from the filesystem. - * On Tab press, directory entries matching the current input are - * enumerated and cycled through as suggestions. - * - * @module - */ - import { readdirSync, statSync } from 'node:fs' import { join, resolve } from 'node:path' diff --git a/packages/core/src/ui/prompts/select-key.tsx b/packages/core/src/ui/prompts/select-key.tsx index 8a02bee3..a71e80fc 100644 --- a/packages/core/src/ui/prompts/select-key.tsx +++ b/packages/core/src/ui/prompts/select-key.tsx @@ -1,13 +1,3 @@ -/** - * Select-by-key prompt component. - * - * Renders a list of options where each option is bound to a single - * key character. Pressing the key immediately selects that option - * without requiring Enter confirmation. - * - * @module - */ - import { Box, Text, useInput } from 'ink' import type { ReactElement } from 'react' import { match } from 'ts-pattern' diff --git a/packages/core/src/ui/prompts/select.tsx b/packages/core/src/ui/prompts/select.tsx index 2e7e0d98..e0ae1a01 100644 --- a/packages/core/src/ui/prompts/select.tsx +++ b/packages/core/src/ui/prompts/select.tsx @@ -1,14 +1,3 @@ -/** - * Select prompt component. - * - * A single-value selector for terminal UIs. Renders a scrollable list - * of options with keyboard navigation (up/down arrows), radio-style - * indicators, and optional hint text. Supports disabled options, - * controlled focus, and scroll overflow. - * - * @module - */ - import { useInput } from 'ink' import type { ReactElement } from 'react' import { useState } from 'react' diff --git a/packages/core/src/ui/prompts/string-utils.ts b/packages/core/src/ui/prompts/string-utils.ts index 85e53dff..e31fb6cc 100644 --- a/packages/core/src/ui/prompts/string-utils.ts +++ b/packages/core/src/ui/prompts/string-utils.ts @@ -1,9 +1,3 @@ -/** - * Shared string manipulation utilities for prompt components. - * - * @module - */ - // --------------------------------------------------------------------------- // Exports // --------------------------------------------------------------------------- diff --git a/packages/core/src/ui/prompts/text-input.tsx b/packages/core/src/ui/prompts/text-input.tsx index 2d97bfd6..2c3134bb 100644 --- a/packages/core/src/ui/prompts/text-input.tsx +++ b/packages/core/src/ui/prompts/text-input.tsx @@ -1,13 +1,3 @@ -/** - * TextInput prompt component. - * - * A single-line text input for terminal UIs. Supports full cursor - * movement (left/right, home/end), character insertion, backspace/delete, - * placeholder text, and validation on submit. - * - * @module - */ - import { Box, Text, useInput } from 'ink' import type { ReactElement } from 'react' import { useState } from 'react' diff --git a/packages/core/src/ui/prompts/types.ts b/packages/core/src/ui/prompts/types.ts index 7da321f5..e26ad618 100644 --- a/packages/core/src/ui/prompts/types.ts +++ b/packages/core/src/ui/prompts/types.ts @@ -1,9 +1,3 @@ -/** - * Shared types for kidd prompt components. - * - * @module - */ - // --------------------------------------------------------------------------- // Exports // --------------------------------------------------------------------------- diff --git a/packages/core/src/ui/theme.ts b/packages/core/src/ui/theme.ts index 98d89a8b..250041a7 100644 --- a/packages/core/src/ui/theme.ts +++ b/packages/core/src/ui/theme.ts @@ -1,12 +1,3 @@ -/** - * Kidd color palette and symbol constants for the component library. - * - * All components share a consistent clack-inspired visual language - * defined here. - * - * @module - */ - import figures from 'figures' import { match } from 'ts-pattern' 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' From 57f1abb1c7f1e8c830b64b9d479593a4505a3e0c Mon Sep 17 00:00:00 2001 From: Zac Rosenbauer Date: Sun, 29 Mar 2026 21:03:54 -0400 Subject: [PATCH 11/12] fix(core): address PR review feedback - Clamp focusIndex on upArrow in autocomplete when filtered list shrinks - Sync focusIndex with flatItems length in group-multi-select via useEffect - Move Enter handling before empty list guard so non-required prompts can submit [] Co-Authored-By: Claude --- packages/core/src/ui/prompts/autocomplete.tsx | 3 +- .../src/ui/prompts/group-multi-select.tsx | 33 ++++++++++++------- 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/packages/core/src/ui/prompts/autocomplete.tsx b/packages/core/src/ui/prompts/autocomplete.tsx index 3eb7fc09..102d8b52 100644 --- a/packages/core/src/ui/prompts/autocomplete.tsx +++ b/packages/core/src/ui/prompts/autocomplete.tsx @@ -84,7 +84,8 @@ export function Autocomplete({ if (filtered.length === 0) { return } - const next = Math.max(0, focusIndex - 1) + 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) { diff --git a/packages/core/src/ui/prompts/group-multi-select.tsx b/packages/core/src/ui/prompts/group-multi-select.tsx index ba955cf3..3dec8ce9 100644 --- a/packages/core/src/ui/prompts/group-multi-select.tsx +++ b/packages/core/src/ui/prompts/group-multi-select.tsx @@ -1,7 +1,7 @@ import { Box, Text, useInput } from 'ink' import picocolors from 'picocolors' import type { ReactElement } from 'react' -import { useMemo, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { match } from 'ts-pattern' import { ErrorMessage } from '../display/error-message.js' @@ -71,8 +71,28 @@ export function GroupMultiSelect({ 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 } @@ -98,17 +118,6 @@ export function GroupMultiSelect({ if (onChange) { onChange(nextSelected) } - return - } - - if (key.return) { - if (required && selected.length === 0) { - setError('At least one option must be selected.') - return - } - if (onSubmit) { - onSubmit(selected) - } } }, { isActive: !isDisabled } From 0f85db99a4a823d2eb580f6a6dc7d942f7524e6a Mon Sep 17 00:00:00 2001 From: Zac Rosenbauer Date: Sun, 29 Mar 2026 21:20:50 -0400 Subject: [PATCH 12/12] fix(core): address PR review feedback (round 4) - Fix alert width calculation for multiline content (use max line width) - Guard progress bar repeat counts with Math.max(0) for negative size - Reset spinner frameIndex when type changes to prevent out-of-bounds - Move Private section separator to correct position in navigation.ts - Make select-key matching case-insensitive - Use String(option.value) for stable React keys in select - Convert string-utils to object destructuring per project conventions Co-Authored-By: Claude --- packages/core/src/ui/display/alert.tsx | 3 +- packages/core/src/ui/display/progress-bar.tsx | 5 +-- packages/core/src/ui/display/spinner.tsx | 2 ++ packages/core/src/ui/prompts/autocomplete.tsx | 6 ++-- packages/core/src/ui/prompts/navigation.ts | 8 ++--- packages/core/src/ui/prompts/path-input.tsx | 4 +-- packages/core/src/ui/prompts/select-key.tsx | 2 +- packages/core/src/ui/prompts/select.tsx | 2 +- packages/core/src/ui/prompts/string-utils.ts | 32 +++++++++++++++---- 9 files changed, 43 insertions(+), 21 deletions(-) diff --git a/packages/core/src/ui/display/alert.tsx b/packages/core/src/ui/display/alert.tsx index 02bb19c0..6231d11a 100644 --- a/packages/core/src/ui/display/alert.tsx +++ b/packages/core/src/ui/display/alert.tsx @@ -183,10 +183,11 @@ function resolveInnerWidth({ width, title, contentStr }: InnerWidthOptions): num 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(contentStr.length, titleWidth) + return Math.max(maxLineWidth, titleWidth) } /** diff --git a/packages/core/src/ui/display/progress-bar.tsx b/packages/core/src/ui/display/progress-bar.tsx index 8e271300..a1725b99 100644 --- a/packages/core/src/ui/display/progress-bar.tsx +++ b/packages/core/src/ui/display/progress-bar.tsx @@ -59,8 +59,9 @@ export function ProgressBar({ .with(false, () => 0) .exhaustive() const percentage = Math.round(ratio * 100) - const filledCount = Math.round(ratio * size) - const emptyCount = size - filledCount + 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) diff --git a/packages/core/src/ui/display/spinner.tsx b/packages/core/src/ui/display/spinner.tsx index 27c8147e..07bced50 100644 --- a/packages/core/src/ui/display/spinner.tsx +++ b/packages/core/src/ui/display/spinner.tsx @@ -108,6 +108,8 @@ export function Spinner({ const [frameIndex, setFrameIndex] = useState(0) useEffect(() => { + setFrameIndex(0) + if (!isActive) { return } diff --git a/packages/core/src/ui/prompts/autocomplete.tsx b/packages/core/src/ui/prompts/autocomplete.tsx index 102d8b52..38d2d55a 100644 --- a/packages/core/src/ui/prompts/autocomplete.tsx +++ b/packages/core/src/ui/prompts/autocomplete.tsx @@ -127,7 +127,7 @@ export function Autocomplete({ if (key.backspace) { if (cursorOffset > 0) { - const nextSearch = removeCharAt(search, cursorOffset - 1) + const nextSearch = removeCharAt({ str: search, index: cursorOffset - 1 }) setSearch(nextSearch) setCursorOffset(cursorOffset - 1) setFocusIndex(0) @@ -137,7 +137,7 @@ export function Autocomplete({ if (key.delete) { if (cursorOffset < search.length) { - const nextSearch = removeCharAt(search, cursorOffset) + const nextSearch = removeCharAt({ str: search, index: cursorOffset }) setSearch(nextSearch) setFocusIndex(0) } @@ -145,7 +145,7 @@ export function Autocomplete({ } if (input && !key.ctrl && !key.meta) { - const nextSearch = insertCharAt(search, cursorOffset, input) + const nextSearch = insertCharAt({ str: search, index: cursorOffset, chars: input }) setSearch(nextSearch) setCursorOffset(cursorOffset + input.length) setFocusIndex(0) diff --git a/packages/core/src/ui/prompts/navigation.ts b/packages/core/src/ui/prompts/navigation.ts index fa8e2c63..bedded26 100644 --- a/packages/core/src/ui/prompts/navigation.ts +++ b/packages/core/src/ui/prompts/navigation.ts @@ -63,10 +63,6 @@ export function resolveNextFocusIndex({ return findNextEnabledIndex(options, currentIndex, step) } -// --------------------------------------------------------------------------- -// Private -// --------------------------------------------------------------------------- - /** * Find the first non-disabled option index. * @@ -107,6 +103,10 @@ export function resolveInitialIndex({ 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 diff --git a/packages/core/src/ui/prompts/path-input.tsx b/packages/core/src/ui/prompts/path-input.tsx index 9d10aa86..b11099ac 100644 --- a/packages/core/src/ui/prompts/path-input.tsx +++ b/packages/core/src/ui/prompts/path-input.tsx @@ -130,7 +130,7 @@ export function PathInput({ if (key.backspace || key.delete) { if (cursorOffset > 0) { - const nextValue = removeCharAt(value, cursorOffset - 1) + const nextValue = removeCharAt({ str: value, index: cursorOffset - 1 }) setValue(nextValue) setCursorOffset(cursorOffset - 1) setSuggestions([]) @@ -144,7 +144,7 @@ export function PathInput({ } if (input && !key.ctrl && !key.meta) { - const nextValue = insertCharAt(value, cursorOffset, input) + const nextValue = insertCharAt({ str: value, index: cursorOffset, chars: input }) setValue(nextValue) setCursorOffset(cursorOffset + input.length) setSuggestions([]) diff --git a/packages/core/src/ui/prompts/select-key.tsx b/packages/core/src/ui/prompts/select-key.tsx index a71e80fc..519b57e9 100644 --- a/packages/core/src/ui/prompts/select-key.tsx +++ b/packages/core/src/ui/prompts/select-key.tsx @@ -45,7 +45,7 @@ export function SelectKey({ }: SelectKeyProps): ReactElement { useInput( (input) => { - const matched = options.find((opt) => opt.value === input) + const matched = options.find((opt) => opt.value.toLowerCase() === input.toLowerCase()) if (matched === undefined || matched.disabled) { return } diff --git a/packages/core/src/ui/prompts/select.tsx b/packages/core/src/ui/prompts/select.tsx index e0ae1a01..25b63779 100644 --- a/packages/core/src/ui/prompts/select.tsx +++ b/packages/core/src/ui/prompts/select.tsx @@ -110,7 +110,7 @@ export function Select({ return (