@bunli/plugin-ui
Bunli plugin for integrating React-based terminal UIs into CLI commands
@bunli/plugin-ui
The @bunli/plugin-ui
package provides seamless integration between Bunli CLI commands and React-based terminal UIs. It extends your commands with a UI context, enabling rich interactive experiences.
Installation
bun add @bunli/plugin-ui @bunli/ui @bunli/renderer
Overview
This plugin enables:
- UI Context Injection: Adds UI rendering capabilities to any command
- Lifecycle Management: Handles mounting, updating, and cleanup
- Helper Functions: Utilities for common UI patterns
- Type Safety: Full TypeScript support with proper type inference
- Signal Handling: Graceful cleanup on process termination
Basic Usage
Setting up the Plugin
import { createCLI } from '@bunli/core'
import { uiPlugin } from '@bunli/plugin-ui'
const cli = await createCLI({
name: 'my-cli',
version: '1.0.0',
plugins: [uiPlugin] // Add the UI plugin
})
// Your commands now have access to the UI context
Using UI in Commands
import { defineCommand } from '@bunli/core'
import { Box, Text } from '@bunli/renderer'
export const interactiveCommand = defineCommand({
name: 'interactive',
description: 'An interactive command',
handler: async ({ ui }) => {
// Render a React component
await ui.render(
<Box padding={2}>
<Text>Hello from UI!</Text>
</Box>
)
// Keep the UI running until user exits
await ui.waitForExit()
}
})
UI Context API
When the plugin is active, all commands receive a ui
object with these methods:
render(element)
Renders a React element to the terminal:
await ui.render(<MyComponent prop="value" />)
- Returns a Promise that resolves when the UI is mounted
- Automatically cleans up any existing UI before rendering
unmount()
Manually unmounts the current UI:
ui.unmount()
update(element)
Updates the current UI with new props:
// Initial render
await ui.render(<Counter count={0} />)
// Update later
ui.update(<Counter count={1} />)
waitForExit()
Waits for the user to exit (Ctrl+C):
await ui.waitForExit()
- Handles SIGINT and SIGTERM signals
- Cleans up the UI before resolving
isActive()
Checks if a UI is currently rendered:
if (ui.isActive()) {
ui.unmount()
}
Helper Functions
The plugin provides several helper functions for common patterns:
defineUICommand
Define a command with direct UI access:
import { defineUICommand } from '@bunli/plugin-ui'
import { Box, Text } from '@bunli/renderer'
import { Button, Input } from '@bunli/ui'
import { z } from 'zod'
import { option } from '@bunli/core'
export const formCommand = defineUICommand({
name: 'form',
description: 'Interactive form example',
options: {
title: option(
z.string().default('Form'),
{ description: 'Form title' }
)
},
handler: async ({ ui, flags }) => {
let name = ''
await ui.render(
<Box padding={2}>
<Text>{flags.title}</Text>
<Input
value={name}
onChange={setName}
onSubmit={() => ui.unmount()}
/>
<Button onClick={() => ui.unmount()}>
Submit
</Button>
</Box>
)
await ui.waitForExit()
}
})
defineComponentCommand
Create a command where the component IS the command:
import { defineComponentCommand } from '@bunli/plugin-ui'
import { Dashboard } from './components/Dashboard'
export const dashboardCommand = defineComponentCommand({
name: 'dashboard',
description: 'Show project dashboard',
// Component receives flags as props
component: Dashboard,
// Optional: Fetch data before rendering
getData: async ({ flags }) => {
const stats = await fetchProjectStats(flags.project)
return { stats }
},
options: {
project: option(
z.string().optional(),
{ description: 'Project name' }
)
}
})
// Dashboard component
function Dashboard({ project, stats }) {
return (
<Box padding={2}>
<Text>Project: {project || 'default'}</Text>
<Text>Stats: {JSON.stringify(stats)}</Text>
</Box>
)
}
defineRoutedCommand
Create multi-screen applications with routing:
import { defineRoutedCommand } from '@bunli/plugin-ui'
import { MainLayout } from './layouts/Main'
import { Home, Projects, Settings } from './screens'
export const appCommand = defineRoutedCommand({
name: 'app',
description: 'Multi-screen application',
// Optional layout wrapper
layout: MainLayout,
// Define routes
routes: [
{
path: '/',
component: Home,
getData: async () => ({ user: await fetchUser() })
},
{
path: '/projects',
component: Projects,
getData: async () => ({ projects: await fetchProjects() })
},
{
path: '/settings',
component: Settings
}
]
})
// Layout component
function MainLayout({ children }) {
return (
<Box>
<Box style={{ borderBottom: 'single' }}>
<Text>My App</Text>
</Box>
{children}
</Box>
)
}
withUI
Enhance existing commands with UI capabilities:
import { withUI } from '@bunli/plugin-ui'
import { existingCommand } from './commands'
export const enhancedCommand = withUI(
existingCommand,
async ({ ui }, runOriginal) => {
// Show loading UI
await ui.render(<LoadingScreen />)
// Run original command
await runOriginal()
// Show completion UI
await ui.render(<CompletionScreen />)
await ui.waitForExit()
}
)
Advanced Patterns
State Management
Use React hooks for state management:
function InteractiveApp() {
const [items, setItems] = useState<string[]>([])
const [input, setInput] = useState('')
const addItem = () => {
setItems([...items, input])
setInput('')
}
return (
<Box>
<Input
value={input}
onChange={setInput}
onSubmit={addItem}
placeholder="Add item..."
/>
<List
items={items.map((item, i) => ({
id: String(i),
label: item
}))}
/>
<Text>Total: {items.length}</Text>
</Box>
)
}
export const todoCommand = defineComponentCommand({
name: 'todo',
component: InteractiveApp
})
Async Operations
Handle async operations with loading states:
function AsyncComponent() {
const [data, setData] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
fetchData()
.then(setData)
.catch(setError)
.finally(() => setLoading(false))
}, [])
if (loading) return <Spinner label="Loading..." />
if (error) return <Alert type="error">{error.message}</Alert>
return <DataDisplay data={data} />
}
Progressive Enhancement
Start with a simple command and add UI progressively:
// Original command
const basicList = defineCommand({
name: 'list',
handler: async () => {
const items = await fetchItems()
items.forEach(item => console.log(item))
}
})
// Enhanced with UI
const interactiveList = withUI(basicList, async ({ ui }, runOriginal) => {
const items = await fetchItems()
await ui.render(
<SelectList
items={items}
onSelect={(item) => {
console.log('Selected:', item)
ui.unmount()
}}
/>
)
await ui.waitForExit()
})
Error Handling
Handle errors gracefully in UI commands:
export const safeCommand = defineUICommand({
name: 'safe',
handler: async ({ ui }) => {
try {
await ui.render(<App />)
await ui.waitForExit()
} catch (error) {
// Show error UI
await ui.render(
<Alert type="error" title="Error">
{error.message}
</Alert>
)
// Wait a moment before exiting
await new Promise(resolve => setTimeout(resolve, 3000))
} finally {
ui.unmount()
}
}
})
Integration Examples
With Forms
import { ProgressiveForm } from '@bunli/ui'
export const wizardCommand = defineComponentCommand({
name: 'wizard',
component: SetupWizard
})
function SetupWizard() {
return (
<ProgressiveForm
fields={[
{
name: 'projectName',
type: 'text',
label: 'Project name',
validate: (val) => val.length > 0 || 'Required'
},
{
name: 'template',
type: 'select',
label: 'Template',
options: ['minimal', 'full', 'custom']
}
]}
onSubmit={async (data) => {
await createProject(data)
process.exit(0)
}}
/>
)
}
With Data Fetching
export const dataCommand = defineComponentCommand({
name: 'data',
component: DataView,
options: {
refresh: option(
z.number().default(5000),
{ description: 'Refresh interval (ms)' }
)
},
getData: async ({ flags }) => {
return {
initialData: await fetchData(),
refreshInterval: flags.refresh
}
}
})
function DataView({ initialData, refreshInterval }) {
const [data, setData] = useState(initialData)
useEffect(() => {
const interval = setInterval(async () => {
setData(await fetchData())
}, refreshInterval)
return () => clearInterval(interval)
}, [refreshInterval])
return <DataDisplay data={data} />
}
Best Practices
- Always handle cleanup: Use
ui.unmount()
orwaitForExit()
- Show loading states: For async operations
- Handle errors gracefully: Show error UI instead of crashing
- Provide keyboard shortcuts: For common actions
- Test with Ctrl+C: Ensure proper cleanup on interrupt
- Keep UI responsive: Avoid blocking operations in render
- Use TypeScript: For better type inference and safety
TypeScript
The plugin provides full TypeScript support:
import type { UIHandlerArgs } from '@bunli/plugin-ui'
// Your handler args are properly typed
interface MyOptions {
name: string
verbose?: boolean
}
const handler = async (args: UIHandlerArgs<MyOptions>) => {
// args.ui is typed
// args.flags is typed as MyOptions
await args.ui.render(<App name={args.flags.name} />)
}