Skip to main content

Overview

Bunli TUI provides React hooks for building interactive terminal applications. These hooks integrate with OpenTUI’s renderer and give you access to keyboard input, terminal dimensions, rendering, and more.

Imports

import {
  useKeyboard,
  useRenderer,
  useTerminalDimensions,
  useTimeline,
  useOnResize,
  useTuiTheme,
  useFormContext,
  useFormField,
  useScopedKeyboard
} from '@bunli/tui'

OpenTUI Hooks

useKeyboard

Register keyboard event handlers.
function useKeyboard(
  handler: (key: KeyEvent) => void | boolean,
  deps?: React.DependencyList
): void

Parameters

handler
(key: KeyEvent) => void | boolean
required
Callback invoked on key press. Return true to mark event as handled and prevent propagation.
deps
React.DependencyList
Dependency array for the handler

KeyEvent Interface

name
string
Key name (e.g., 'a', 'return', 'escape')
sequence
string
Raw key sequence
ctrl
boolean
Whether Ctrl key is pressed
shift
boolean
Whether Shift key is pressed
meta
boolean
Whether Meta/Alt key is pressed

Example

import { useKeyboard } from '@bunli/tui'
import { useState } from 'react'

function Counter() {
  const [count, setCount] = useState(0)

  useKeyboard((key) => {
    if (key.name === 'up') {
      setCount(c => c + 1)
      return true // handled
    }
    if (key.name === 'down') {
      setCount(c => c - 1)
      return true
    }
    return false // not handled
  }, [count])

  return <text content={`Count: ${count}`} />
}

useRenderer

Access the OpenTUI renderer instance.
function useRenderer(): CliRenderer

Example

import { useRenderer } from '@bunli/tui'

function Debug() {
  const renderer = useRenderer()

  return (
    <box>
      <text content={`Focused: ${renderer.currentFocusedRenderable?.id ?? 'none'}`} />
    </box>
  )
}

useTerminalDimensions

Get current terminal width and height.
function useTerminalDimensions(): { width: number; height: number }

Example

import { useTerminalDimensions } from '@bunli/tui'

function ResponsiveLayout() {
  const { width, height } = useTerminalDimensions()

  return (
    <box>
      <text content={`Terminal: ${width}x${height}`} />
      {width > 80 ? (
        <text content="Wide layout" />
      ) : (
        <text content="Narrow layout" />
      )}
    </box>
  )
}

useOnResize

Register a handler for terminal resize events.
function useOnResize(
  handler: (dimensions: { width: number; height: number }) => void,
  deps?: React.DependencyList
): void

Example

import { useOnResize } from '@bunli/tui'
import { useState } from 'react'

function ResizeLogger() {
  const [resizes, setResizes] = useState(0)

  useOnResize(() => {
    setResizes(r => r + 1)
  }, [])

  return <text content={`Resized ${resizes} times`} />
}

useTimeline

Create an animation timeline with frame-based updates.
function useTimeline(
  fps?: number
): {
  elapsed: number
  frame: number
  start: () => void
  stop: () => void
  reset: () => void
}

Parameters

fps
number
default:"30"
Frames per second for the timeline

Example

import { useTimeline } from '@bunli/tui'

function AnimatedSpinner() {
  const timeline = useTimeline(10) // 10 FPS
  const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
  const spinner = frames[timeline.frame % frames.length]

  return <text content={`${spinner} Loading...`} />
}

Theme Hooks

useTuiTheme

Access the current TUI theme.
function useTuiTheme(): TuiTheme

Returns

tokens
TuiThemeTokens
Theme color tokens

Example

import { useTuiTheme } from '@bunli/tui'

function ThemedText() {
  const { tokens } = useTuiTheme()

  return (
    <box>
      <text content="Primary" fg={tokens.textPrimary} />
      <text content="Muted" fg={tokens.textMuted} />
      <text content="Accent" fg={tokens.accent} />
    </box>
  )
}

Form Hooks

useFormContext

Access form context (must be used inside a <Form> component).
function useFormContext(): FormContextValue

Returns

values
Record<string, unknown>
Current form values
errors
Record<string, string>
Validation errors by field name
touched
Record<string, boolean>
Touched state by field name
dirtyFields
Record<string, boolean>
Dirty state by field name
isDirty
boolean
Whether any field has been modified
isSubmitting
boolean
Whether form is currently submitting
isValidating
boolean
Whether form is currently validating
setFieldValue
(name: string, value: unknown) => void
Update a field’s value
submit
() => void
Submit the form
reset
() => void
Reset form to initial values

Example

import { Form, useFormContext } from '@bunli/tui'
import { z } from 'zod'

const schema = z.object({
  name: z.string().min(1)
})

function FormDebug() {
  const form = useFormContext()

  return (
    <box>
      <text content={`Dirty: ${form.isDirty}`} />
      <text content={`Submitting: ${form.isSubmitting}`} />
    </box>
  )
}

function App() {
  return (
    <Form
      title="My Form"
      schema={schema}
      onSubmit={(values) => {
        console.log(values)
      }}
    >
      <FormDebug />
    </Form>
  )
}

useFormField

Register and manage a form field.
function useFormField<T = unknown>(
  name: string,
  options?: UseFormFieldOptions<T>
): UseFormFieldResult<T>

Parameters

name
string
required
Field name (must match schema property)
options
UseFormFieldOptions<T>

Returns

value
T
Current field value
error
string | undefined
Validation error message
touched
boolean
Whether field has been touched
dirty
boolean
Whether field value has changed
active
boolean
Whether field is currently focused
setValue
(value: T) => void
Update field value
markTouched
() => void
Mark field as touched
focus
() => void
Focus the field

Example

import { Form, useFormField } from '@bunli/tui'
import { z } from 'zod'

const schema = z.object({
  email: z.string().email()
})

function EmailField() {
  const field = useFormField<string>('email', {
    defaultValue: ''
  })

  return (
    <box style={{ flexDirection: 'column' }}>
      <text content={`Email: ${field.value}`} />
      {field.error && <text content={field.error} fg="#ff0000" />}
      {field.active && <text content="(focused)" />}
    </box>
  )
}

Keyboard Scope Hooks

useScopedKeyboard

Register a keyboard handler with priority and scope support.
function useScopedKeyboard(
  scopeId: string,
  handler: (key: KeyEvent) => boolean,
  options?: UseScopedKeyboardOptions
): void

Parameters

scopeId
string
required
Unique identifier for this keyboard scope
handler
(key: KeyEvent) => boolean
required
Handler function. Return true to mark event as handled.
options
UseScopedKeyboardOptions

Example

import { useScopedKeyboard } from '@bunli/tui'
import { useState } from 'react'

function Modal({ isOpen, onClose }) {
  useScopedKeyboard(
    'modal',
    (key) => {
      if (key.name === 'escape') {
        onClose()
        return true
      }
      return false
    },
    { active: isOpen, priority: 100 }
  )

  if (!isOpen) return null

  return (
    <box border>
      <text content="Press Esc to close" />
    </box>
  )
}

Best Practices

Dependency Arrays

Always provide dependency arrays to useKeyboard and useOnResize to avoid stale closures

Keyboard Scopes

Use useScopedKeyboard with priorities for modals and overlays to prevent key conflicts

Performance

  • Use useTimeline for animations instead of setInterval
  • Debounce expensive operations in useOnResize
  • Return true from keyboard handlers to stop propagation

Form Validation

  • Use useFormField for custom form inputs
  • Leverage schema validation instead of manual checks
  • Show errors only after field is touched

Build docs developers (and LLMs) love