@boba-cli/dsl
v1.0.0-alpha.4
Published
Declarative DSL for building CLI applications with minimal ceremony
Readme
@boba-cli/dsl
The main API for building Boba CLI applications. A declarative DSL for building terminal UIs with minimal ceremony using a fluent builder API and view primitives inspired by SwiftUI.
Install
pnpm add @boba-cli/dslQuick Start
import { createApp, spinner, vstack, hstack, text, Style } from '@boba-cli/dsl'
const app = createApp()
.state({ message: 'Loading something amazing...' })
.component('loading', spinner({ style: new Style().foreground('#50fa7b') }))
.onKey(['q', 'ctrl+c'], ({ quit }) => quit())
.view(({ state, components }) =>
vstack(
text('🧋 My App').bold().foreground('#ff79c6'),
spacer(),
hstack(components.loading, text(' ' + state.message)),
spacer(),
text('Press [q] to quit').dim()
)
)
.build()
await app.run()Why the DSL?
The DSL is the recommended way to build Boba CLI applications. For advanced use cases requiring fine-grained control, you can use the low-level Elm Architecture API directly.
| Aspect | DSL (Main API) | Low-Level TEA |
|--------|----------------|---------------|
| Lines of code | ~35 lines | ~147 lines |
| Boilerplate | Declarative builder, automatic state handling | Manual class, state management, instanceof checks |
| Type safety | Phantom types provide compile-time safety | Manual type guards, verbose generics |
| View composition | Composable view primitives | String concatenation |
| Component integration | Automatic component lifecycle management | Manual model wrapping and message routing |
See examples/spinner (DSL) vs examples/spinner-low-level (low-level) for a real comparison.
API Reference
App Builder
createApp()
Creates a new application builder. Start here to build your CLI app.
const app = createApp()
.state({ count: 0 })
.view(({ state }) => text(`Count: ${state.count}`))
.build()AppBuilder.state<S>(initial: S)
Sets the initial application state. The state type is inferred from the provided object.
.state({ count: 0, name: 'World' })AppBuilder.component<K, M>(key: K, builder: ComponentBuilder<M>)
Registers a component with a unique key. The component's rendered view is available in the view function via components[key].
.component('spinner', spinner())
.component('input', textInput())AppBuilder.onKey(keys: string | string[], handler: KeyHandler)
Registers a key handler. Supports single keys, key arrays, and modifiers.
.onKey('q', ({ quit }) => quit())
.onKey(['up', 'k'], ({ state, update }) => update({ index: state.index - 1 }))
.onKey('ctrl+c', ({ quit }) => quit())Key handler context:
state- Current application statecomponents- Current component viewsupdate(patch)- Merge partial state (shallow merge)setState(newState)- Replace entire statequit()- Gracefully quit the application
AppBuilder.view(fn: ViewFunction)
Sets the view function. Called on every render cycle. Returns a view node tree describing the UI.
.view(({ state, components }) =>
vstack(
text('Hello ' + state.name),
components.spinner
)
)AppBuilder.build()
Finalizes the builder chain and creates an App ready to run.
const app = builder.build()
await app.run()View Primitives
text(content: string): TextNode
Creates a text node with chainable style methods.
text('Hello').bold().foreground('#ff79c6')
text('Warning').dim().italic()
text('Success').background('#282a36')Style methods:
bold()- Apply bold stylingdim()- Apply dim stylingitalic()- Apply italic stylingforeground(color)- Set foreground color (hex or named)background(color)- Set background color (hex or named)
vstack(...children: ViewNode[]): LayoutNode
Arranges child views vertically with newlines between them.
vstack(
text('Line 1'),
text('Line 2'),
text('Line 3')
)hstack(...children: ViewNode[]): LayoutNode
Arranges child views horizontally on the same line.
hstack(
text('Left'),
text(' | '),
text('Right')
)spacer(height?: number): string
Creates empty vertical space. Default height is 1 line.
vstack(
text('Header'),
spacer(2),
text('Content')
)divider(char?: string, width?: number): string
Creates a horizontal divider line. Default is 40 '─' characters.
vstack(
text('Section 1'),
divider(),
text('Section 2'),
divider('=', 50)
)Conditional Helpers
when(condition: boolean, node: ViewNode): ViewNode
Conditionally renders a node. Returns empty string if condition is false.
vstack(
text('Always visible'),
when(state.showHelp, text('Help text'))
)choose(condition: boolean, ifTrue: ViewNode, ifFalse: ViewNode): ViewNode
Chooses between two nodes based on a condition.
choose(
state.isLoading,
text('Loading...').dim(),
text('Ready!').bold()
)map<T>(items: T[], render: (item: T, index: number) => ViewNode): ViewNode[]
Maps an array of items to view nodes. Spread the result into a layout.
vstack(
...map(state.items, (item, index) =>
text(`${index + 1}. ${item.name}`)
)
)Component Builders
The DSL provides 17 component builders for common CLI patterns. All components integrate seamlessly with the App builder pattern and provide declarative configuration.
code(options: CodeBuilderOptions): ComponentBuilder<CodeModel>
Displays syntax-highlighted source code from files with scrolling support.
import { NodeFileSystemAdapter, NodePathAdapter } from '@boba-cli/machine/node'
.component('viewer', code({
filesystem: new NodeFileSystemAdapter(),
path: new NodePathAdapter(),
active: true,
theme: 'dracula',
width: 80,
height: 24
}))Options:
filesystem- Filesystem adapter (required, useNodeFileSystemAdapterfor Node.js)path- Path adapter (required, useNodePathAdapterfor Node.js)active- Whether component receives keyboard input (default:false)theme- Syntax theme like"dracula","monokai","github-light"(default:"dracula")width- Viewer width in characters (default:0)height- Viewer height in lines (default:0)
filepicker(options: FilepickerBuilderOptions): ComponentBuilder<FilepickerModel>
Interactive file system browser with selection support.
import { NodeFileSystemAdapter, NodePathAdapter } from '@boba-cli/machine/node'
.component('picker', filepicker({
filesystem: new NodeFileSystemAdapter(),
path: new NodePathAdapter(),
currentPath: process.cwd(),
height: 15,
width: 60
}))Options:
filesystem- Filesystem adapter (required)path- Path adapter (required)currentPath- Initial directory pathheight- Picker height in lineswidth- Picker width in characters- Additional styling and behavior options available
filetree(options: FiletreeBuilderOptions): ComponentBuilder<FiletreeModel>
Directory tree viewer with expandable folders.
.component('tree', filetree({
root: { name: 'src', isDirectory: true, children: [...] }
}))Options:
root- Root directory item (required, must be aDirectoryItem)showFiles- Whether to show files or only directories (default:true)- Custom styling options available
help(options?: HelpBuilderOptions): ComponentBuilder<HelpModel>
Full-screen key binding help display.
.component('help', help({
keyBindings: [
{ key: 'q', description: 'Quit application' },
{ key: '↑/↓', description: 'Navigate list' }
],
showHelp: true
}))Options:
keyBindings- Array of key binding descriptionsshowHelp- Whether help is visible (default:false)- Custom styling options available
helpBubble(options?: HelpBubbleBuilderOptions): ComponentBuilder<HelpBubbleModel>
Compact inline help bubble with keyboard shortcuts.
.component('shortcuts', helpBubble({
entries: [
{ key: 'enter', description: 'Select' },
{ key: 'q', description: 'Quit' }
]
}))Options:
entries- Array of shortcut entries withkeyanddescription- Custom styling options available
list<T>(options: ListBuilderOptions<T>): ComponentBuilder<ListModel<T>>
Filterable, paginated list with keyboard navigation. Items must implement the Item interface.
import { list, type Item } from '@boba-cli/dsl'
interface TodoItem extends Item {
filterValue: () => string
title: () => string
description: () => string
}
const items: TodoItem[] = [
{ filterValue: () => 'Buy milk', title: () => 'Buy milk', description: () => 'From the store' }
]
.component('todos', list({ items, title: 'Tasks', height: 20 }))Options:
items- Array of items implementingIteminterface (required)title- List titleheight- List height in lineswidth- List width in charactersshowTitle,showFilter,showPagination,showHelp,showStatusBar- Toggle UI elementsfilteringEnabled- Enable/disable filtering (default:true)styles- Custom styles for list componentskeyMap- Custom key bindingsdelegate- Custom item rendering
markdown(options?: MarkdownBuilderOptions): ComponentBuilder<MarkdownModel>
Renders markdown with syntax highlighting.
.component('docs', markdown({
content: '# Hello\n\nThis is **markdown**.',
width: 80
}))Options:
content- Markdown content to renderwidth- Rendering width in characters- Custom styling options available
paginator(options?: PaginatorBuilderOptions): ComponentBuilder<PaginatorModel>
Dot-style page indicator (e.g., ● ○ ○).
.component('pages', paginator({
totalPages: 5,
currentPage: 0
}))Options:
totalPages- Total number of pages (default:3)currentPage- Current page index (default:0)- Custom styling options available
progress(options?: ProgressBuilderOptions): ComponentBuilder<ProgressModel>
Animated progress bar with gradient support and spring physics.
.component('progress', progress({
width: 40,
gradient: {
start: '#5A56E0',
end: '#EE6FF8',
scaleGradientToProgress: false
},
showPercentage: true,
spring: {
frequency: 18,
damping: 1
}
}))Options:
width- Progress bar width in characters (default:40)full- Character for filled portion (default:'█')empty- Character for empty portion (default:'░')fullColor- Color for filled portion (default:'#7571F9')emptyColor- Color for empty portion (default:'#606060')showPercentage- Display percentage value (default:true)percentFormat- Printf-style format string (default:' %3.0f%%')gradient- Gradient configuration withstart,end,scaleGradientToProgressspring- Spring physics withfrequency,dampingpercentageStyle- Style for percentage text
spinner(options?: SpinnerBuilderOptions): ComponentBuilder<SpinnerModel>
Animated loading spinner.
.component('loading', spinner({
spinner: dot,
style: new Style().foreground('#50fa7b')
}))Options:
spinner- Animation to use (default:line). Available:line,dot,miniDot,pulse,points,moon,meter,ellipsisstyle- Style for rendering (default: unstyled)
Re-exported spinners:
import { line, dot, miniDot, pulse, points, moon, meter, ellipsis } from '@boba-cli/dsl'statusBar(options?: StatusBarBuilderOptions): ComponentBuilder<StatusBarModel>
Multi-column status bar for displaying key-value pairs.
.component('status', statusBar({
columns: [
{ key: 'mode', value: 'NORMAL' },
{ key: 'line', value: '42' }
]
}))Options:
columns- Array of column definitions withkeyandvalue- Custom styling options available
stopwatch(options?: StopwatchBuilderOptions): ComponentBuilder<StopwatchModel>
Displays elapsed time since start.
.component('elapsed', stopwatch({
format: 'mm:ss.SSS',
running: true
}))Options:
format- Time format string (default:'mm:ss.SSS')running- Whether stopwatch is running (default:false)- Custom styling options available
table(options?: TableBuilderOptions): ComponentBuilder<TableModel>
Scrollable data table with headers and rows.
.component('data', table({
headers: ['Name', 'Age', 'City'],
rows: [
['Alice', '30', 'NYC'],
['Bob', '25', 'SF']
],
height: 10
}))Options:
headers- Column headersrows- Data rowsheight- Table height in lineswidth- Table width in characters- Custom styling and scrolling options available
textArea(options?: TextAreaBuilderOptions): ComponentBuilder<TextAreaModel>
Multi-line text editor with scrolling and editing.
.component('editor', textArea({
value: 'Initial text',
width: 80,
height: 20,
active: true
}))Options:
value- Initial text contentwidth- Editor width in charactersheight- Editor height in linesactive- Whether editor receives keyboard input (default:false)- Custom styling options available
textInput(options?: TextInputBuilderOptions): ComponentBuilder<TextInputModel>
Single-line text input with validation, placeholders, and echo modes.
.component('input', textInput({
placeholder: 'Enter your name...',
value: '',
active: true,
validate: (value) => value.length > 0 ? null : 'Required'
}))Options:
value- Initial input valueplaceholder- Placeholder textactive- Whether input receives keyboard input (default:false)validate- Validation function returning error message ornullechoMode- Input display mode (normal, password, none)cursorMode- Cursor style- Custom styling options available
Re-exported types:
import { EchoMode, CursorMode, type ValidateFunc } from '@boba-cli/dsl'timer(options?: TimerBuilderOptions): ComponentBuilder<TimerModel>
Countdown timer display.
.component('countdown', timer({
duration: 60000, // 60 seconds in milliseconds
format: 'mm:ss',
running: true
}))Options:
duration- Total duration in millisecondsformat- Time format string (default:'mm:ss')running- Whether timer is running (default:false)- Custom styling options available
viewport(options?: ViewportBuilderOptions): ComponentBuilder<ViewportModel>
Scrollable content viewport for displaying large content.
.component('content', viewport({
content: longTextContent,
width: 80,
height: 20,
active: true
}))Options:
content- Content to display (string or array of strings)width- Viewport width in charactersheight- Viewport height in linesactive- Whether viewport receives keyboard input for scrolling (default:false)- Custom styling options available
Re-exported Types
For convenience, the DSL re-exports commonly used types from underlying packages:
// From @boba-cli/chapstick
import { Style } from '@boba-cli/dsl'
// From @boba-cli/spinner
import { type Spinner, line, dot, miniDot, pulse, points, moon, meter, ellipsis } from '@boba-cli/dsl'
// From @boba-cli/textinput
import { type TextInputModel, EchoMode, CursorMode, type ValidateFunc } from '@boba-cli/dsl'
// From @boba-cli/list
import { type Item } from '@boba-cli/dsl'
// From @boba-cli/filetree
import { type DirectoryItem } from '@boba-cli/dsl'
// From @boba-cli/help-bubble
import { type Entry } from '@boba-cli/dsl'Type Safety
The DSL uses phantom types to provide compile-time guarantees about application structure:
State Type Safety
const app = createApp()
.state({ count: 0 })
.view(({ state }) => text(`Count: ${state.count}`))
// ^^^^^ TypeScript knows this is numberComponent Type Safety
const app = createApp()
.component('spinner', spinner())
.view(({ components }) => components.spinner)
// ^^^^^^^^^^^^^^^^^^ ComponentViewIf you try to access a component that doesn't exist, TypeScript will error:
.view(({ components }) => components.doesNotExist)
// ^^^^^^^^^^^^ Error: Property 'doesNotExist' does not existBuilder Chain Validation
The builder enforces that view() is called before build():
createApp()
.state({ count: 0 })
.build()
// Error: AppBuilder: view() must be called before build()Advanced Usage
Accessing the Low-Level TEA Model
For advanced use cases, you can access the generated TEA model:
const app = createApp()
.state({ count: 0 })
.view(({ state }) => text(`Count: ${state.count}`))
.build()
const model = app.getModel()
// model is a TEA Model<Msg> instanceCustom Component Builders
You can create custom component builders by implementing the ComponentBuilder interface:
import type { ComponentBuilder } from '@boba-cli/dsl'
import type { Cmd, Msg } from '@boba-cli/tea'
interface MyComponentModel {
value: number
}
const myComponent = (): ComponentBuilder<MyComponentModel> => ({
init() {
return [{ value: 0 }, null]
},
update(model, msg) {
// Handle messages
return [model, null]
},
view(model) {
return `Value: ${model.value}`
}
})
// Use it
createApp()
.component('custom', myComponent())
.view(({ components }) => components.custom)Examples
Counter with State Updates
import { createApp, vstack, hstack, text } from '@boba-cli/dsl'
const app = createApp()
.state({ count: 0 })
.onKey('up', ({ state, update }) => update({ count: state.count + 1 }))
.onKey('down', ({ state, update }) => update({ count: state.count - 1 }))
.onKey('q', ({ quit }) => quit())
.view(({ state }) =>
vstack(
text('Counter').bold(),
spacer(),
text(`Count: ${state.count}`),
spacer(),
text('[↑/↓] adjust • [q] quit').dim()
)
)
.build()
await app.run()Todo List with Conditional Rendering
const app = createApp()
.state({
items: ['Buy milk', 'Write docs', 'Build CLI'],
selected: 0
})
.onKey('up', ({ state, update }) =>
update({ selected: Math.max(0, state.selected - 1) })
)
.onKey('down', ({ state, update }) =>
update({ selected: Math.min(state.items.length - 1, state.selected + 1) })
)
.onKey('q', ({ quit }) => quit())
.view(({ state }) =>
vstack(
text('Todo List').bold(),
divider(),
...map(state.items, (item, index) =>
choose(
index === state.selected,
text(`> ${item}`).foreground('#50fa7b'),
text(` ${item}`)
)
),
divider(),
text('[↑/↓] navigate • [q] quit').dim()
)
)
.build()
await app.run()Multiple Components
import { createApp, spinner, vstack, hstack, text, Style, dot, pulse } from '@boba-cli/dsl'
const app = createApp()
.state({ status: 'Initializing...' })
.component('spinner1', spinner({ spinner: dot, style: new Style().foreground('#50fa7b') }))
.component('spinner2', spinner({ spinner: pulse, style: new Style().foreground('#ff79c6') }))
.onKey('q', ({ quit }) => quit())
.view(({ state, components }) =>
vstack(
text('Multi-Spinner Demo').bold(),
spacer(),
hstack(components.spinner1, text(' Loading data...')),
hstack(components.spinner2, text(' Processing...')),
spacer(),
text(`Status: ${state.status}`).dim(),
spacer(),
text('[q] quit').dim()
)
)
.build()
await app.run()Scripts
pnpm -C packages/dsl buildpnpm -C packages/dsl testpnpm -C packages/dsl lintpnpm -C packages/dsl generate:api-report
License
MIT
