All skills
msmps avatar

/opentui

@0d61d6d
by Matt Simpsonmsmps/opentui-skill225 stars
9

OpenTUI skill for building terminal user interfaces with the Core, React, or Solid APIs. Use for any TUI task including components, layout, keyboard and keymap handling, animations, and testing.

Use this Skill: https://skilld.dev/gh/msmps/opentui-skill/opentui

This session only. Nothing lands on disk.

referencescorepatterns.md

≈2.2k tokens on demand. Your agent reads this file only when SKILL.md points to it.

Core Patterns

Composition Patterns

Imperative Composition

Create renderables and compose with .add():

import { createCliRenderer, BoxRenderable, TextRenderable } from "@opentui/core"

const renderer = await createCliRenderer()

// Create parent
const container = new BoxRenderable(renderer, {
  id: "container",
  flexDirection: "column",
  padding: 1,
})

// Create children
const header = new TextRenderable(renderer, {
  id: "header",
  content: "Header",
  fg: "#00FF00",
})

const body = new TextRenderable(renderer, {
  id: "body",
  content: "Body content",
})

// Compose tree
container.add(header)
container.add(body)
renderer.root.add(container)

Declarative Composition (Constructs)

Use VNode functions for cleaner composition:

import { createCliRenderer, Box, Text, Input, delegate } from "@opentui/core"

const renderer = await createCliRenderer()

// Compose as function calls
const ui = Box(
  { flexDirection: "column", padding: 1 },
  Text({ content: "Header", fg: "#00FF00" }),
  Box(
    { flexDirection: "row", gap: 2 },
    Text({ content: "Name:" }),
    Input({ id: "name", placeholder: "Enter name..." }),
  ),
)

renderer.root.add(ui)

Reusable Components

Create factory functions for reusable UI pieces:

// Imperative factory
function createLabeledInput(
  renderer: RenderContext,
  props: { id: string; label: string; placeholder: string }
) {
  const container = new BoxRenderable(renderer, {
    id: `${props.id}-container`,
    flexDirection: "row",
    gap: 1,
  })
  
  container.add(new TextRenderable(renderer, {
    id: `${props.id}-label`,
    content: props.label,
  }))
  
  container.add(new InputRenderable(renderer, {
    id: `${props.id}-input`,
    placeholder: props.placeholder,
    width: 20,
  }))
  
  return container
}

// Declarative factory
function LabeledInput(props: { id: string; label: string; placeholder: string }) {
  return delegate(
    { focus: `${props.id}-input` },
    Box(
      { flexDirection: "row", gap: 1 },
      Text({ content: props.label }),
      Input({
        id: `${props.id}-input`,
        placeholder: props.placeholder,
        width: 20,
      }),
    ),
  )
}

Focus Delegation

Route focus calls to nested elements:

import { delegate, Box, Input, Text } from "@opentui/core"

const form = delegate(
  {
    focus: "email-input",     // Route .focus() to this child
    blur: "email-input",      // Route .blur() to this child
  },
  Box(
    { border: true, padding: 1 },
    Text({ content: "Email:" }),
    Input({ id: "email-input", placeholder: "you@example.com" }),
  ),
)

// This focuses the input inside, not the box
form.focus()

Event Handling

Keyboard Events

const renderer = await createCliRenderer()

// Global keyboard handler
renderer.keyInput.on("keypress", (key) => {
  if (key.name === "escape") {
    renderer.destroy()
    process.exit(0)
  }
  
  if (key.ctrl && key.name === "c") {
    // Ctrl+C handling (if exitOnCtrlC is false)
  }
  
  if (key.name === "tab") {
    // Tab navigation
    focusNext()
  }
})

// Paste events
renderer.keyInput.on("paste", (event) => {
  const text = decodePasteBytes(event.bytes)
  currentInput?.setValue(currentInput.value + text)
})

Component Events

import { InputRenderable, InputRenderableEvents } from "@opentui/core"

const input = new InputRenderable(renderer, {
  id: "search",
  placeholder: "Search...",
})

input.on(InputRenderableEvents.CHANGE, (value) => {
  performSearch(value)
})

// Select events
const select = new SelectRenderable(renderer, {
  id: "menu",
  options: [...],
})

select.on(SelectRenderableEvents.ITEM_SELECTED, (index, option) => {
  handleSelection(option)
})

select.on(SelectRenderableEvents.SELECTION_CHANGED, (index, option) => {
  showPreview(option)
})

Mouse Events

const button = new BoxRenderable(renderer, {
  id: "button",
  border: true,
  onMouseDown: (event) => {
    button.setBackgroundColor("#444444")
  },
  onMouseUp: (event) => {
    button.setBackgroundColor("#222222")
    handleClick()
  },
  onMouseMove: (event) => {
    // Hover effect
  },
})

State Management

Local State

Manage state in closures or objects:

// Closure-based state
function createCounter(renderer: RenderContext) {
  let count = 0
  
  const display = new TextRenderable(renderer, {
    id: "count",
    content: `Count: ${count}`,
  })
  
  const increment = () => {
    count++
    display.setContent(`Count: ${count}`)
  }
  
  return { display, increment }
}

// Class-based state
class CounterWidget {
  private count = 0
  private display: TextRenderable
  
  constructor(renderer: RenderContext) {
    this.display = new TextRenderable(renderer, {
      id: "count",
      content: this.formatCount(),
    })
  }
  
  private formatCount() {
    return `Count: ${this.count}`
  }
  
  increment() {
    this.count++
    this.display.setContent(this.formatCount())
  }
  
  getRenderable() {
    return this.display
  }
}

Focus Management

Track and manage focus across components:

class FocusManager {
  private focusables: Renderable[] = []
  private currentIndex = 0
  
  register(renderable: Renderable) {
    this.focusables.push(renderable)
  }
  
  focusNext() {
    this.focusables[this.currentIndex]?.blur()
    this.currentIndex = (this.currentIndex + 1) % this.focusables.length
    this.focusables[this.currentIndex]?.focus()
  }
  
  focusPrevious() {
    this.focusables[this.currentIndex]?.blur()
    this.currentIndex = (this.currentIndex - 1 + this.focusables.length) % this.focusables.length
    this.focusables[this.currentIndex]?.focus()
  }
}

// Usage
const focusManager = new FocusManager()
focusManager.register(input1)
focusManager.register(input2)
focusManager.register(select1)

renderer.keyInput.on("keypress", (key) => {
  if (key.name === "tab") {
    key.shift ? focusManager.focusPrevious() : focusManager.focusNext()
  }
})

Lifecycle Patterns

Cleanup

Always clean up resources:

const renderer = await createCliRenderer()

// Track intervals/timeouts
const intervals: Timer[] = []

intervals.push(setInterval(() => {
  updateClock()
}, 1000))

// Cleanup on exit
process.on("SIGINT", () => {
  intervals.forEach(clearInterval)
  renderer.destroy()
  process.exit(0)
})

// Or use onDestroy callback
const renderer = await createCliRenderer({
  onDestroy: () => {
    intervals.forEach(clearInterval)
  },
})

Dynamic Updates

Update UI based on external data:

async function createDashboard(renderer: RenderContext) {
  const statsText = new TextRenderable(renderer, {
    id: "stats",
    content: "Loading...",
  })
  
  // Poll for updates
  const updateStats = async () => {
    const data = await fetchStats()
    statsText.setContent(`CPU: ${data.cpu}% | Memory: ${data.memory}%`)
  }
  
  // Initial load
  await updateStats()
  
  // Periodic updates
  setInterval(updateStats, 5000)
  
  return statsText
}

Layout Patterns

Responsive Layout

Adapt to terminal size:

const renderer = await createCliRenderer()

const mainPanel = new BoxRenderable(renderer, {
  id: "main",
  width: "100%",
  height: "100%",
  flexDirection: renderer.width > 80 ? "row" : "column",
})

// Listen for resize
process.stdout.on("resize", () => {
  mainPanel.setFlexDirection(renderer.width > 80 ? "row" : "column")
})

Split Panels

function createSplitView(renderer: RenderContext, ratio = 0.3) {
  const container = new BoxRenderable(renderer, {
    id: "split",
    flexDirection: "row",
    width: "100%",
    height: "100%",
  })
  
  const left = new BoxRenderable(renderer, {
    id: "left",
    width: `${ratio * 100}%`,
    border: true,
  })
  
  const right = new BoxRenderable(renderer, {
    id: "right",
    flexGrow: 1,
    border: true,
  })
  
  container.add(left)
  container.add(right)
  
  return { container, left, right }
}

Debugging Patterns

Console Overlay

Use the built-in console for debugging:

const renderer = await createCliRenderer({
  consoleOptions: {
    startInDebugMode: true,
  },
})

// Show console
renderer.console.show()

// All console methods work
console.log("Debug info")
console.warn("Warning")
console.error("Error")

// Toggle with keyboard
renderer.keyInput.on("keypress", (key) => {
  if (key.name === "f12") {
    renderer.console.toggle()
  }
})

State Inspection

function debugState(label: string, state: unknown) {
  console.log(`[${label}]`, JSON.stringify(state, null, 2))
}

// In your update logic
debugState("form", { name: nameInput.value, email: emailInput.value })

Source: SKILL.md on GitHub

No alerts17d5 checks · Risk SAFE
  • Gen Agent Trust Hub17d

    This skill contains documentation and usage patterns for the OpenTUI framework, designed for building terminal user interfaces across Core, React, and SolidJS. The analysis confirmed that the skill provides legitimate development guidance; no malicious code, persistence mechanisms, or obfuscation were detected. Findings are limited to standard platform capabilities and recommended scaffolding tools.

  • Socket17d

    No alerts

  • Snyk17d

    Risk: LOW · No issues

  • Runlayer7mo

    1/26 files flagged

  • ZeroLeaks5mo

    Score: 93/100 · 2 sections analyzed

Signed by skilld at 0d61d6d. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub last month.

Steadyupdated last month
Other metadata
metadata
{
  "references": "core, react, solid, components, layout, keyboard, keymap, animation, testing"
}
  • React
  • Testing
  • terminal-ui
  • tui
  • solid
  • components
  • keyboard-input
  • animations
  • layout

README badge

README badge for msmps/opentui-skill

Builds terminal user interfaces with OpenTUI using an imperative core API or React/Solid reconcilers. Covers layout, keyboard handling, animations, components, and testing for full-featured TUI applications.

Generated from the current SKILL.md.

Does this skill cover all three OpenTUI frameworks?
Yes. The skill consolidates the core imperative API, React reconciler, and Solid reconciler. Use the decision tree in the skill to pick the right framework for your task.
How do I start a new OpenTUI project?
Use `create-tui` with options before arguments: `bunx create-tui -t react my-app` (not `bunx create-tui my-app -t react`). Then read the REFERENCE.md for your chosen framework.
What runtime does OpenTUI require?
OpenTUI runs on Bun and uses Zig for native builds. See `./references/core/gotchas.md` for runtime requirements and build guidance.
Can I use `process.exit()` to shut down my TUI?
No. Always use `renderer.destroy()` instead. Calling `process.exit()` directly is a common pitfall covered in `core/gotchas.md`.
How do I apply text styling in React or Solid?
Use nested modifier elements, not props. See `components/text-display.md` for the correct pattern.

Generated from the current SKILL.md. These answers refresh after source changes.