@bunli/tui
v0.6.0
Published
Terminal User Interface plugin for Bunli CLI framework
Maintainers
Readme
@bunli/tui
A React-based Terminal User Interface library for Bunli CLI framework, powered by OpenTUI's React renderer.
Features
- React-based Components: Build TUIs using familiar React patterns and JSX
- Component Library: Form, layout, feedback, data-display, and chart components for alternate-buffer TUIs
- OpenTUI Integration: Full access to OpenTUI's React hooks and components
- Type Safety: Complete TypeScript support with proper type inference
- Animation Support: Built-in timeline system for smooth animations
- Keyboard Handling: Easy keyboard event management with
useKeyboard - First-Class TUI Support: TUI rendering is a first-class feature, not a plugin
- Theme System: Preset themes with token overrides via
ThemeProvider/createTheme
Installation
bun add @bunli/tui reactQuick Start
import { createCLI, defineCommand } from '@bunli/core'
const cli = await createCLI({
name: 'my-app',
version: '1.0.0'
})
// Define a command with TUI using the render property
const myCommand = defineCommand({
name: 'deploy',
description: 'Deploy application',
render: () => (
<box title="Deployment" style={{ border: true, padding: 2 }}>
<text>Deploying...</text>
</box>
),
handler: async () => {
// Non-TUI fallback when render is skipped
console.log('Deploying application...')
}
})
cli.command(myCommand)
await cli.run()Bunli auto-wires the OpenTUI runtime. Runtime/context APIs now come from @bunli/runtime/app, prompt/session APIs come from @bunli/runtime/prompt, and @bunli/tui focuses on UI components and hooks.
TUI Execution Semantics
- Commands with
renderrun in interactive terminals. - Non-interactive terminals fall back to
handlerwhen present. - Configure fullscreen flows explicitly with
bufferMode: 'alternate'.
Render Lifecycle (Runtime Exit)
Commands that use render must eventually call useRuntime().exit() (for example on submit/cancel/quit), or the command will not exit.
import { useRuntime } from '@bunli/runtime/app'
import { useKeyboard } from '@bunli/tui'
function DeployTUI() {
const runtime = useRuntime()
useKeyboard((key) => {
if (key.name === 'q' || (key.ctrl && key.name === 'c')) {
runtime.exit()
}
})
return <text>Press q to quit</text>
}Buffer Modes (Alternate vs Standard)
OpenTUI can render using either the alternate screen buffer (full-screen TUI) or the standard terminal buffer (leaves output in scrollback).
Configure this in createCLI() config:
const cli = await createCLI({
name: 'my-app',
version: '1.0.0',
tui: {
renderer: {
bufferMode: 'alternate' // or 'standard'
}
}
})Notes:
bufferMode: 'standard'is the default.- Use
bufferMode: 'alternate'for fullscreen/blocking experiences. - Mouse tracking is disabled by default (
useMouse: false) to avoid leaking raw mouse escape sequences after exit in standard-buffer workflows. Enable explicitly when needed.
Usage
Module Split
Use subpath exports depending on mode:
@bunli/tui/interactive: alternate-buffer interactive components.@bunli/tui/charts: terminal-native chart primitives.@bunli/tui: root export that re-exports shared components/hooks.@bunli/runtime/app: runtime lifecycle/context APIs.@bunli/runtime/prompt: prompt/session APIs.
Clack Migration Quick Map
Use this when replacing a clack mental model with Bunli-native APIs:
// clack
// import { intro, outro, confirm, select, multiselect, log } from '@clack/prompts'
// bunli
// prompt is provided via handler args by Bunli
prompt.intro('Setup')
const confirmed = await prompt.confirm('Continue?', { default: true })
const env = await prompt.select('Environment', {
options: [
{ label: 'Development', value: 'dev' },
{ label: 'Production', value: 'prod' }
]
})
const features = await prompt.multiselect('Features', {
options: [
{ label: 'Testing', value: 'testing' },
{ label: 'Docker', value: 'docker' }
],
initialValues: ['testing']
})
prompt.log.success(`Selected ${env}`)
prompt.outro('Done')Basic TUI Component
import { defineCommand } from '@bunli/core'
function MyTUI() {
return (
<box title="My App" style={{ border: true, padding: 2 }}>
<text>Hello from My App!</text>
</box>
)
}
export const myCommand = defineCommand({
name: 'my-command',
description: 'My command with TUI',
render: () => <MyTUI />,
handler: async () => {
console.log('Running my-command in CLI mode')
}
})Using Form Components
import { defineCommand } from '@bunli/core'
import { useRuntime } from '@bunli/runtime/app'
import { SchemaForm } from '@bunli/tui'
import { z } from 'zod'
const configSchema = z.object({
apiUrl: z.string().url('Enter a valid URL'),
region: z.enum(['us-east', 'us-west'])
})
function ConfigTUI() {
const runtime = useRuntime()
const regions = [
{ label: 'US East', value: 'us-east', hint: 'US East region' },
{ label: 'US West', value: 'us-west', hint: 'US West region' }
]
return (
<SchemaForm
title="Configure Settings"
schema={configSchema}
fields={[
{
kind: 'text',
name: 'apiUrl',
label: 'API URL',
placeholder: 'https://api.example.com',
required: true
},
{
kind: 'select',
name: 'region',
label: 'Region',
options: regions
}
]}
onSubmit={(values) => {
console.log('Validated form values:', values)
}}
onCancel={() => runtime.exit()}
/>
)
}
export const configureCommand = defineCommand({
name: 'configure',
description: 'Configure application settings',
render: () => <ConfigTUI />
})Using OpenTUI Hooks
import { useRuntime } from '@bunli/runtime/app'
import { useKeyboard, useTimeline, useTerminalDimensions } from '@bunli/tui'
function InteractiveTUI({ command }) {
const [count, setCount] = useState(0)
const { width, height } = useTerminalDimensions()
const runtime = useRuntime()
const timeline = useTimeline({ duration: 2000 })
useKeyboard((key) => {
if (key.name === 'q') {
runtime.exit()
}
if (key.name === 'space') {
setCount(prev => prev + 1)
}
})
useEffect(() => {
timeline.add({ count: 0 }, {
count: 100,
duration: 2000,
onUpdate: (anim) => setCount(anim.targets[0].count)
})
}, [])
return (
<box title="Interactive Demo" style={{ border: true, padding: 2 }}>
<text>Count: {count}</text>
<text>Terminal: {width}x{height}</text>
<text>Press SPACE to increment, Q to quit</text>
</box>
)
}Component Library
Interactive components are available from @bunli/tui/interactive and root exports.
Included primitives:
- Form:
Form,SchemaForm,FormField,SelectField - Form v2:
NumberField,PasswordField,TextareaField,CheckboxField,MultiSelectField - Layout:
Container,Stack,Grid,Panel,Card,Divider,SectionHeader - Navigation/flow:
Tabs,Menu,CommandPalette,Modal - Feedback:
Alert,Badge,Toast,ProgressBar,EmptyState - Data display:
List,Table,DataTable,KeyValueList,Stat,Markdown,Diff - Charts:
BarChart,LineChart,Sparklinefrom@bunli/tui/charts - Runtime orchestration/hooks: import from
@bunli/runtime/app(DialogProvider,useDialogManager,FocusScopeProvider,useScopedKeyboard, etc.)
Keyboard Contracts
Default keyboard bindings for interactive primitives:
Modal:Esc/Ctrl+Cclose,Tab/Shift+Tabfocus trapDialog confirm:Left/h/y-> confirm,Right/l/n-> cancel choice,Tabtoggle,EntersubmitDialog choose:Up/kprevious,Down/jnext,Entersubmit. Disabled options are skipped for selection/navigation.Menu:Up/k,Down/j,EnterTabs:Left/hprevious tab,Right/lnext tabCommandPalette:Up/k,Down/j,EnterDataTable:Left/hprevious sort column,Right/lnext sort column,Up/kprevious row,Down/jnext row,Enterselect rowForm:Tab/Shift+Tabfield navigation,Ctrl+Ssubmit,Ctrl+Rreset,F8/Shift+F8jump error fields,Esccancel
Dialog Manager
Use the dialog manager to stack confirm/choose flows with consistent priority handling and dismissal semantics.
import { useDialogManager, DialogDismissedError } from '@bunli/runtime/app'
function Screen() {
const dialogs = useDialogManager()
async function deploy() {
try {
const confirmed = await dialogs.confirm({
title: 'Deploy',
message: 'Ship this release now?'
})
if (!confirmed) return
const target = await dialogs.choose({
title: 'Target',
options: [
{ label: 'Staging', value: 'staging', section: 'General' },
{ label: 'Production', value: 'production', section: 'Protected', disabled: true }
]
})
console.log('Deploying to', target)
} catch (error) {
if (error instanceof DialogDismissedError) {
console.log('Dialog dismissed')
}
}
}
return <box />
}Form
A schema-driven container for controlled interactive forms.
const runtime = useRuntime()
<Form
title="My Form"
schema={schema}
onSubmit={(values) => console.log(values)}
onCancel={() => runtime.exit()}
>
{/* Form fields */}
</Form>Props:
title: string- Form titleschema: StandardSchemaV1- Validation schema (Zod and other Standard Schema adapters supported)onSubmit: (values) => void | Promise<void>- Submit handler with schema-validated valuesonCancel?: () => void- Cancel handler (optional)onValidationError?: (errors: Record<string, string>) => void- Validation error callbackinitialValues?: Partial<InferOutput<schema>>- Initial controlled valuesvalidateOnChange?: boolean- Validate while typing/selecting (defaulttrue)submitHint?: string- Footer hint overrideonReset?: () => void- Reset callbackonDirtyChange?: (isDirty, dirtyFields) => void- Dirty-state callbackonSubmitStateChange?: ({ isSubmitting, isValidating }) => void- async state callbackscopeId?: string- keyboard scope boundary id for nested interactive flows
SchemaForm
A higher-level schema form builder that renders fields from descriptors.
<SchemaForm
title="Deploy"
schema={schema}
fields={[
{ kind: 'text', name: 'service', label: 'Service' },
{ kind: 'select', name: 'env', label: 'Environment', options: envOptions },
{ kind: 'checkbox', name: 'telemetry', label: 'Enable telemetry' },
{
kind: 'textarea',
name: 'notes',
label: 'Release notes',
visibleWhen: (values) => values.env === 'production'
}
]}
onSubmit={(values) => console.log(values)}
/>Supported SchemaForm field kinds:
textselectmultiselectnumberpasswordtextareacheckbox
Field-level behavior:
visibleWhen(values) => booleanfor conditional rendering.deriveDefault(values) => unknownfor dependent default initialization.
FormField
A controlled text field bound to form context.
<FormField
label="Username"
name="username"
placeholder="Enter username"
required
defaultValue=""
/>Props:
label: string- Field labelname: string- Field nameplaceholder?: string- Placeholder textrequired?: boolean- Whether field is requireddescription?: string- Helper textdefaultValue?: string- Initial value for form stateonChange?: (value: string) => void- Change handleronSubmit?: (value: string) => void- Submit handler
SelectField
A controlled select field bound to form context.
<SelectField
label="Environment"
name="env"
options={[
{ label: 'Development', value: 'dev', hint: 'Development environment' },
{ label: 'Production', value: 'prod', hint: 'Production environment' }
]}
defaultValue="dev"
onChange={setEnvironment}
/>Props:
label: string- Field labelname: string- Field nameoptions: SelectOption[]- Available optionsrequired?: boolean- Whether field is requireddescription?: string- Helper textdefaultValue?: SelectOption['value']- Initial selected valueonChange?: (value: SelectOption['value']) => void- Change handler
ProgressBar
A progress bar component for showing completion status.
<ProgressBar
value={75}
label="Upload Progress"
color="#00ff00"
/>Props:
value: number- Progress value (0-100)label?: string- Progress labelcolor?: string- Progress bar color
Chart Primitives
@bunli/tui/charts supports negative and sparse values, axis labels, and multi-series palettes.
import { BarChart, LineChart } from '@bunli/tui/charts'
<BarChart
series={[
{ name: 'build', points: [{ label: 'Mon', value: -2 }, { label: 'Tue', value: 6 }] },
{ name: 'test', points: [{ label: 'Mon', value: null }, { label: 'Tue', value: 4 }] }
]}
axis={{ yLabel: 'Jobs', xLabel: 'Day', showRange: true }}
/>
<LineChart
series={{ name: 'latency', points: [{ value: 120 }, { value: 98 }, { value: null }, { value: 104 }] }}
axis={{ yLabel: 'ms' }}
/>ThemeProvider and Tokens
Use ThemeProvider to apply a built-in theme preset or token overrides.
import { ThemeProvider, createTheme } from '@bunli/tui/interactive'
const customTheme = createTheme({
preset: 'dark',
tokens: {
accent: '#3ec7ff',
textSuccess: '#3cd89b'
}
})
function App() {
return (
<ThemeProvider theme={customTheme}>
<Panel title="Deploy status">
<Alert tone="success" message="Ready to ship" />
</Panel>
</ThemeProvider>
)
}OpenTUI Hooks
The package re-exports useful OpenTUI React hooks:
useKeyboard
Handle keyboard events.
import { useRuntime } from '@bunli/runtime/app'
import { useKeyboard } from '@bunli/tui'
const runtime = useRuntime()
useKeyboard((key) => {
if (key.name === 'escape') {
runtime.exit()
}
})useRenderer
Access the OpenTUI renderer instance.
import { useRenderer } from '@bunli/tui'
const renderer = useRenderer()
renderer.console.show()Use useRenderer() for advanced renderer inspection/control only. Use useRuntime().exit() for normal command completion.
useTerminalDimensions
Get current terminal dimensions.
import { useTerminalDimensions } from '@bunli/tui'
const { width, height } = useTerminalDimensions()useTimeline
Create and manage animations.
import { useTimeline } from '@bunli/tui'
const timeline = useTimeline({ duration: 2000 })
timeline.add({ x: 0 }, {
x: 100,
duration: 2000,
onUpdate: (anim) => setX(anim.targets[0].x)
})useOnResize
Handle terminal resize events.
import { useOnResize } from '@bunli/tui'
useOnResize((width, height) => {
console.log(`Terminal resized to ${width}x${height}`)
})Renderer Configuration
Renderer options are passed via createCLI({ tui: { renderer } }) and optional command-level overrides in command.tui.renderer.
OpenTUI Components
You can use any OpenTUI React components directly:
import { render } from '@opentui/react'
function MyComponent() {
return (
<box style={{ border: true, padding: 2 }}>
<text>Hello World</text>
<input placeholder="Type here..." />
<select options={options} />
</box>
)
}Available components:
<box>- Container with borders and layout<text>- Text display with styling<input>- Text input field<select>- Dropdown selection<scrollbox>- Scrollable container<ascii-font>- ASCII art text<tab-select>- Tab-based selection
Examples
See the examples/tui-demo directory for complete examples:
- Deploy Command: Animated progress bar with timeline
- Configure Command: Form with input and select fields
TypeScript Support
The package provides full TypeScript support:
import type { TuiComponent, TuiComponentProps } from '@bunli/tui'
const MyTUI: TuiComponent = ({ command, args, store }) => {
// Fully typed props
return <box>{command.name}</box>
}License
MIT
