Design document for kidd's React/Ink UI component library with full clack-level API coverage.
kidd has two rendering paths:
- Handler mode (
ctx.prompts,ctx.log,ctx.status) — imperative, delegates to@clack/prompts. Thefeat/log-configbranch adds full clack API coverage here. - Screen mode (React/Ink) — declarative components for TUI screens. Currently re-exports
@inkjs/uicomponents 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.
| 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 |
@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.
Optiontype is{ label: string, value: string }— nodisabled, nohint, value must bestring- 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, nostylevariants, no label — justvalue(0-100) - Components are opinionated black boxes — no render props, no slot patterns
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.
| 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. |
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<TValue>, 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 # <Output /> 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
screen/— a peer tocommand/at the core level. Ownsscreen()factory, context provider, and the output store internals (screen-backed log, spinner, report). Users never import fromscreen/output/directly.screen()anduseScreenContext()are exported from the core barrel alongsidecommand(),cli(), etc.ui/— component library. Everything users compose inside their screens.<Output />lives here as a component that reads from the storescreen()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.
// 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'| 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.
interface PromptOption<TValue> {
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.
interface SelectProps<TValue> {
readonly options: readonly PromptOption<TValue>[]
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
interface MultiSelectProps<TValue> {
readonly options: readonly PromptOption<TValue>[]
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
requiredand nothing selected, shows validation message - Disabled options shown but not toggleable
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
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
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
maskcharacter
interface GroupMultiSelectProps<TValue> {
readonly options: Readonly<Record<string, readonly PromptOption<TValue>[]>>
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
interface AutocompleteProps<TValue> {
readonly options: readonly PromptOption<TValue>[]
readonly placeholder?: string
readonly maxVisible?: number
readonly defaultValue?: TValue
readonly filter?: (search: string, option: PromptOption<TValue>) => 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
interface SelectKeyProps<TValue extends string> {
readonly options: readonly PromptOption<TValue>[]
readonly onSubmit?: (value: TValue) => void
readonly isDisabled?: boolean
}- Each option's
valueis a single key character - Pressing the key immediately selects that option
- Options rendered with key highlighted
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
directoryOnlyfilters to directories
interface SpinnerProps {
readonly label?: string
readonly isActive?: boolean
readonly type?: SpinnerName // from cli-spinners
}- Renders animated spinner frame + label
isActivecontrols whether the spinner animates (defaulttrue)- Matches clack's visual style (colored frame, label beside it)
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
stylecontrols the bar characters (light shade, heavy shade, or block)labelshown beside the bar- Automatically fills available width if
sizenot specified
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
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)
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. │
╰────────────────────────────╯
| 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 |
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.
Each component gets a story group with variants covering key states:
// 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',
},
},
})| 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 |
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- Extract
screen/— movescreen.tsx,provider.tsx,output/intosrc/screen/ - Create
ui/base/index.ts— move raw Ink + @inkjs/ui re-exports here - Create
ui/theme.ts— kidd color palette and symbol constants - Create
ui/prompts/types.ts—PromptOption<TValue>, shared prompt types - Move existing layout components into
ui/layout/with barrel - Move
<Output />component toui/output.tsx(reads from screen's store) - Set up barrel exports for each group and root
ui/index.ts - Update package exports in
package.jsonfor new paths
prompts/select.tsx+prompts/select.stories.tsxprompts/multi-select.tsx+prompts/multi-select.stories.tsxprompts/confirm.tsx+prompts/confirm.stories.tsxprompts/text-input.tsx+prompts/text-input.stories.tsxprompts/password-input.tsx+prompts/password-input.stories.tsxprompts/index.ts— barrel export
prompts/group-multi-select.tsx+prompts/group-multi-select.stories.tsxprompts/autocomplete.tsx+prompts/autocomplete.stories.tsxprompts/select-key.tsx+prompts/select-key.stories.tsxprompts/path-input.tsx+prompts/path-input.stories.tsx
display/spinner.tsx+display/spinner.stories.tsxdisplay/progress-bar.tsx+display/progress-bar.stories.tsxdisplay/alert.tsx+display/alert.stories.tsxdisplay/status-message.tsx+display/status-message.stories.tsxdisplay/index.ts— barrel export
- Update root
ui/index.tsto re-export all groups - Update component standards doc
- Run
kidd stories --checkto validate all stories pass
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.
Agent: foundation
- Extract
screen/directory, moveoutput/internals - Create
ui/base/,ui/theme.ts,ui/prompts/types.ts - Move layout components into
ui/layout/ - Move
<Output />toui/output.tsx - Wire barrel exports, update
package.json - Run
pnpm checkto verify nothing broke
Agent: prompts-core (Phase 2)
- Build Select + stories
- Build MultiSelect + stories
- Build Confirm + stories
- Build TextInput + stories
- Build PasswordInput + stories
- Wire
prompts/index.tsbarrel
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.tsbarrel
Agent: integration
- Update root
ui/index.tsbarrel - Update component standards doc
- Run
pnpm check+kidd stories --check
foundation
├── prompts-core ──────┐
├── prompts-extended ──┼── integration
└── display ───────────┘
All custom components are built on raw Ink primitives:
- Layout:
Box(flexbox),Text(styled text) - Input:
useInputfrom Ink withisActiveguard - State:
useState/useReducer(following existing kidd patterns) - Conditionals:
ts-patternmatch()— no ternaries, no switch - Immutability: all props
readonly, all state frozen, nolet - Symbols:
figurespackage for cross-platform indicators - Colors: Ink's
color/dimColor/boldprops
No dependency on @inkjs/ui internals (OptionMap, etc.) — all custom components are self-contained.
No new dependencies required. Everything used is already in the tree:
ink(Box, Text, useInput)figures(symbols)ts-pattern(conditionals)@inkjs/ui(re-exported viaui/baseonly)react(hooks)cli-spinners(spinner animation frames, already a dep of @inkjs/ui)