Standards for React/Ink components used in kidd CLI commands. Commands can use screen() from @kidd-cli/core/ui to build interactive terminal UIs with React components. These rules govern file conventions, component structure, colocation, and when to choose screen mode over handler mode.
Command files that contain JSX must use the .tsx extension. Files without JSX use .ts. The autoloader discovers both extensions.
commands/
├── deploy.ts # handler-only command
├── status.tsx # screen command with JSX
└── dashboard/
├── index.tsx # parent screen command
└── _components/
└── StatusTable.tsx
commands/
├── status.ts # contains JSX but uses .ts extension
All React function components use PascalCase names. This applies to both shared and command-private components.
function StatusRow(props: StatusRowProps): React.ReactElement {
return (
<Box>
<Text>{props.name}</Text>
</Box>
)
}function statusRow(props: StatusRowProps): React.ReactElement {
return (
<Box>
<Text>{props.name}</Text>
</Box>
)
}Define props interfaces in the same file as the component. Use readonly on all properties. Name them {ComponentName}Props.
interface StatusRowProps {
readonly name: string
readonly status: 'pass' | 'fail'
readonly duration?: number
}
function StatusRow(props: StatusRowProps): React.ReactElement {
return (
<Box>
<Text>{props.name}</Text>
</Box>
)
}// Props defined in a separate types.ts file for a single component
import type { StatusRowProps } from './types.js'Components used by a single command live in a _components/ directory next to the command file. The leading underscore prevents the autoloader from treating them as commands.
commands/
├── status.tsx
└── status/
└── _components/
├── StatusTable.tsx
└── StatusRow.tsx
Components used by multiple commands live in src/ui/. Import them with the @/ alias.
src/
├── ui/
│ ├── Table.tsx
│ └── Spinner.tsx
└── commands/
├── status.tsx # imports from @/ui/Table.tsx
└── deploy.tsx # imports from @/ui/Table.tsx
Use screen() when the command needs React state, hooks, dynamic updates, or complex layout. Use command() with a handler for sequential operations that log output and exit.
Use screen() when |
Use command() when |
|---|---|
| UI updates over time (spinners, progress) | Sequential log-and-exit flow |
| Interactive selection or input within the view | Simple prompts via ctx.prompts |
| Complex layout with multiple columns/sections | Streaming text output |
| React hooks manage async state | One-shot data fetch and display |
import { screen, Box, Text, useApp } from '@kidd-cli/core/ui'
function Dashboard(): React.ReactElement {
const { exit } = useApp()
// ... interactive UI
return (
<Box>
<Text>Dashboard</Text>
</Box>
)
}
export default screen({
description: 'Show live dashboard',
render: Dashboard,
})import { command } from '@kidd-cli/core'
export default command({
description: 'Deploy the application',
handler(ctx) {
ctx.spinner.start('Deploying...')
// ... deploy logic
ctx.spinner.stop('Deployed')
},
})The screen() factory handles Ink rendering, the KiddProvider, and exit behavior. The component receives parsed args as props. Runtime context (config, meta, store) is available via hooks.
import { screen, useConfig, useMeta } from '@kidd-cli/core/ui'
function StatusView({ env }: { readonly env: string }): React.ReactElement {
const config = useConfig()
const meta = useMeta()
// ... render UI using args (env), config, and meta
}
export default screen({
description: 'Interactive status view',
options: z.object({
env: z.string().default('staging').describe('Target environment'),
}),
render: StatusView,
})Available hooks inside screen components:
| Hook | Returns | Description |
|---|---|---|
useConfig() |
Readonly<TConfig> |
Validated CLI config |
useMeta() |
Readonly<Meta> |
CLI name, version, command path |
useStore() |
Store |
In-memory key-value store |
useApp() |
{ exit } |
Ink app control (from ink) |
Screens default to 'manual' exit — the component stays alive until useApp().exit() is called or the user presses Ctrl-C. Use exit: 'auto' for screens that render once and exit.
// Manual exit (default) — stays alive until explicit exit
export default screen({
description: 'Interactive dashboard',
render: Dashboard,
})
// Auto exit — renders once and exits
export default screen({
description: 'Show status summary',
exit: 'auto',
render: StatusSummary,
})The no let rule still applies at module level in .tsx files. Inside React components, useState and other hooks manage mutable state -- this is the expected pattern for component-local state.
const REFRESH_INTERVAL = 5000
function Dashboard(props: DashboardProps): React.ReactElement {
const [status, setStatus] = useState<Status>('idle')
// ...
}let refreshInterval = 5000 // module-level let is banned
function Dashboard(props: DashboardProps): React.ReactElement {
// ...
}Import all Ink primitives and @inkjs/ui components from @kidd-cli/core/ui. Do not import from ink or @inkjs/ui directly.
import { Box, Text, Spinner, useApp } from '@kidd-cli/core/ui'
function StatusRow(props: StatusRowProps): React.ReactElement {
return (
<Box gap={1}>
<Text color="green">{props.name}</Text>
<Text dimColor>{props.detail}</Text>
</Box>
)
}import { Box, Text } from 'ink' // direct ink import
import { Spinner } from '@inkjs/ui' // direct @inkjs/ui import
function StatusRow(props: StatusRowProps): React.ReactElement {
console.log(`${props.name}: ${props.detail}`)
return <></>
}- Coding Style -- Constraints (no classes, no let, no throw, etc.)
- Design Patterns -- Factories, pipelines, composition
- Naming -- Naming conventions